feat(ai): scope opencode sessions by directory
This commit is contained in:
parent
b0bb9949ee
commit
565fc98ce8
@ -34,10 +34,27 @@ local function trimTrailingSlash(s)
|
|||||||
return (s:gsub('/+$', ''));
|
return (s:gsub('/+$', ''));
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function urlEncode(s)
|
||||||
|
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
|
||||||
|
return string.format('%%%02X', string.byte(c));
|
||||||
|
end));
|
||||||
|
end
|
||||||
|
|
||||||
local function isBlank(s)
|
local function isBlank(s)
|
||||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function queryString(params)
|
||||||
|
local parts = {};
|
||||||
|
for _, item in ipairs(params) do
|
||||||
|
if not isBlank(item[2]) then
|
||||||
|
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #parts == 0 then return ''; end
|
||||||
|
return '?' .. table.concat(parts, '&');
|
||||||
|
end
|
||||||
|
|
||||||
local function readAllAndClose(response)
|
local function readAllAndClose(response)
|
||||||
local body = response.readAll();
|
local body = response.readAll();
|
||||||
response.close();
|
response.close();
|
||||||
@ -226,11 +243,13 @@ local function createAi(opts)
|
|||||||
end
|
end
|
||||||
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
|
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
|
||||||
local password = options.password or settingsLib.get('opencc.password') or '';
|
local password = options.password or settingsLib.get('opencc.password') or '';
|
||||||
|
local directory = options.directory or settingsLib.get('opencc.directory');
|
||||||
local providerId, modelId = resolveModel(options);
|
local providerId, modelId = resolveModel(options);
|
||||||
return {
|
return {
|
||||||
url = trimTrailingSlash(url),
|
url = trimTrailingSlash(url),
|
||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
|
directory = directory,
|
||||||
providerID = providerId,
|
providerID = providerId,
|
||||||
modelID = modelId,
|
modelID = modelId,
|
||||||
agent = resolveAgent(options),
|
agent = resolveAgent(options),
|
||||||
@ -436,6 +455,22 @@ local function createAi(opts)
|
|||||||
};
|
};
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function listSessionsWithDirectory(cfg, directory)
|
||||||
|
return doGet(cfg, '/session' .. queryString({ { 'directory', directory } }));
|
||||||
|
end
|
||||||
|
|
||||||
|
local function decodeSessionList(body, log)
|
||||||
|
local decoded = textutils.unserializeJSON(body);
|
||||||
|
if type(decoded) ~= 'table' then
|
||||||
|
log('list sessions failed: invalid response');
|
||||||
|
return nil, 'reponse invalide';
|
||||||
|
end
|
||||||
|
table.sort(decoded, function(a, b)
|
||||||
|
return sessionTime(a) > sessionTime(b);
|
||||||
|
end);
|
||||||
|
return decoded, nil;
|
||||||
|
end
|
||||||
|
|
||||||
function api.clearSession(options)
|
function api.clearSession(options)
|
||||||
options = options or {};
|
options = options or {};
|
||||||
settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
|
settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
|
||||||
@ -448,8 +483,16 @@ local function createAi(opts)
|
|||||||
local cfg, err = resolveConfig(options);
|
local cfg, err = resolveConfig(options);
|
||||||
if not cfg then return false, err; end
|
if not cfg then return false, err; end
|
||||||
|
|
||||||
|
local directory = cfg.directory;
|
||||||
|
local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY;
|
||||||
log('listing sessions from ' .. cfg.url);
|
log('listing sessions from ' .. cfg.url);
|
||||||
local body, code = doGet(cfg, '/session');
|
local body, code;
|
||||||
|
if isBlank(directory) then
|
||||||
|
body, code = doGet(cfg, '/session');
|
||||||
|
else
|
||||||
|
log('listing sessions for directory ' .. tostring(directory));
|
||||||
|
body, code = listSessionsWithDirectory(cfg, directory);
|
||||||
|
end
|
||||||
if not body then
|
if not body then
|
||||||
log('list sessions failed: ' .. tostring(code));
|
log('list sessions failed: ' .. tostring(code));
|
||||||
return false, code;
|
return false, code;
|
||||||
@ -459,14 +502,29 @@ local function createAi(opts)
|
|||||||
return false, 'erreur serveur: HTTP ' .. tostring(code);
|
return false, 'erreur serveur: HTTP ' .. tostring(code);
|
||||||
end
|
end
|
||||||
|
|
||||||
local decoded = textutils.unserializeJSON(body);
|
local decoded, decodeErr = decodeSessionList(body, log);
|
||||||
if type(decoded) ~= 'table' then
|
if not decoded then return false, decodeErr; end
|
||||||
log('list sessions failed: invalid response');
|
|
||||||
return false, 'reponse invalide';
|
if #decoded == 0 and isBlank(directory) then
|
||||||
|
local sessionId = options.sessionId or settingsLib.get(sessionSettingKey);
|
||||||
|
if not isBlank(sessionId) then
|
||||||
|
log('session list empty; resolving directory from ' .. tostring(sessionId));
|
||||||
|
local sessionBody, sessionCode = doGet(cfg, '/session/' .. sessionId);
|
||||||
|
if sessionBody and (not sessionCode or sessionCode == 200) then
|
||||||
|
local session = textutils.unserializeJSON(sessionBody);
|
||||||
|
if type(session) == 'table' and not isBlank(session.directory) then
|
||||||
|
log('retrying sessions for directory ' .. tostring(session.directory));
|
||||||
|
local scopedBody, scopedCode = listSessionsWithDirectory(cfg, session.directory);
|
||||||
|
if scopedBody and (not scopedCode or scopedCode == 200) then
|
||||||
|
local scoped, scopedErr = decodeSessionList(scopedBody, log);
|
||||||
|
if not scoped then return false, scopedErr; end
|
||||||
|
decoded = scoped;
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
table.sort(decoded, function(a, b)
|
|
||||||
return sessionTime(a) > sessionTime(b);
|
|
||||||
end);
|
|
||||||
log('sessions returned: ' .. tostring(#decoded));
|
log('sessions returned: ' .. tostring(#decoded));
|
||||||
return true, decoded;
|
return true, decoded;
|
||||||
end
|
end
|
||||||
|
|||||||
@ -50,6 +50,9 @@ Health check. Returns `200` when the server is up.
|
|||||||
|
|
||||||
List all sessions for the current project.
|
List all sessions for the current project.
|
||||||
|
|
||||||
|
Optional query parameters used by `ai`:
|
||||||
|
- `directory` — scope results to an opencode project directory.
|
||||||
|
|
||||||
**Response** `200`:
|
**Response** `200`:
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
|||||||
@ -110,6 +110,12 @@ Optional — select an opencode agent for requests from this computer:
|
|||||||
set opencc.agent computercraft
|
set opencc.agent computercraft
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional — scope `ai sessions` to a specific opencode project directory. If omitted, `ai sessions` falls back to the directory recorded on the saved `opencc.session_id` when the unscoped list is empty:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set opencc.directory /Users/garm/trap/cc-libs
|
||||||
|
```
|
||||||
|
|
||||||
Optional but recommended: pick the provider and model. When both are set, `ai` posts to `/session/:id/prompt_async` and polls for completion. Without them, `ai` falls back to blocking `/session/:id/message`, which can use the server default model but is more exposed to HTTP timeouts:
|
Optional but recommended: pick the provider and model. When both are set, `ai` posts to `/session/:id/prompt_async` and polls for completion. Without them, `ai` falls back to blocking `/session/:id/message`, which can use the server default model but is more exposed to HTTP timeouts:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.8.7",
|
"version": "0.8.8",
|
||||||
"branch": "next",
|
"branch": "next",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
"trapos-boot": "0.3.2",
|
"trapos-boot": "0.3.2",
|
||||||
"trapos-net": "0.3.0",
|
"trapos-net": "0.3.0",
|
||||||
"trapos-ui": "0.2.2",
|
"trapos-ui": "0.2.2",
|
||||||
"trapos-ai": "0.6.5",
|
"trapos-ai": "0.6.6",
|
||||||
"trapos-sandbox": "0.2.0",
|
"trapos-sandbox": "0.2.0",
|
||||||
"trapos": "0.8.7"
|
"trapos": "0.8.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-ai",
|
"name": "trapos-ai",
|
||||||
"version": "0.6.5",
|
"version": "0.6.6",
|
||||||
"description": "TrapOS AI client for opencode serve",
|
"description": "TrapOS AI client for opencode serve",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.8.7",
|
"version": "0.8.8",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||||
"files": [],
|
"files": [],
|
||||||
|
|||||||
@ -48,6 +48,7 @@ local function printUsage()
|
|||||||
print(' opencc.username (default: opencode)');
|
print(' opencc.username (default: opencode)');
|
||||||
print(' opencc.password (Basic Auth password)');
|
print(' opencc.password (Basic Auth password)');
|
||||||
print(' opencc.session_id (auto-managed)');
|
print(' opencc.session_id (auto-managed)');
|
||||||
|
print(' opencc.directory (optional session list scope)');
|
||||||
print(' opencc.agent (e.g. computercraft)');
|
print(' opencc.agent (e.g. computercraft)');
|
||||||
print(' opencc.provider_id (e.g. anthropic)');
|
print(' opencc.provider_id (e.g. anthropic)');
|
||||||
print(' opencc.model_id (e.g. claude-opus-4-7)');
|
print(' opencc.model_id (e.g. claude-opus-4-7)');
|
||||||
|
|||||||
54
tests/ai.lua
54
tests/ai.lua
@ -223,6 +223,60 @@ testlib.test('listSessions logs session count when verbose', function()
|
|||||||
testlib.assertEquals(logs[2], 'sessions returned: 2');
|
testlib.assertEquals(logs[2], 'sessions returned: 2');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
testlib.test('listSessions sends configured directory query', function()
|
||||||
|
local sessions = {
|
||||||
|
{ id = 'ses_1', title = 'one', time = { updated = 10 } },
|
||||||
|
};
|
||||||
|
local httpStub = fakeHttp({}, { response(200, textutils.serializeJSON(sessions)) });
|
||||||
|
local settingsStub = fakeSettings({
|
||||||
|
['opencc.server_url'] = 'http://host',
|
||||||
|
['opencc.directory'] = '/Users/garm/trap/cc-libs',
|
||||||
|
});
|
||||||
|
local ai = createAi({ http = httpStub, settings = settingsStub });
|
||||||
|
|
||||||
|
local ok, result = ai.listSessions();
|
||||||
|
|
||||||
|
testlib.assertTrue(ok, tostring(result));
|
||||||
|
testlib.assertEquals(#result, 1);
|
||||||
|
testlib.assertEquals(httpStub.getCalls[1].url,
|
||||||
|
'http://host/session?directory=%2FUsers%2Fgarm%2Ftrap%2Fcc-libs');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('listSessions retries with persisted session directory when list is empty', function()
|
||||||
|
local scopedSessions = {
|
||||||
|
{ id = 'ses_existing', title = 'existing', time = { updated = 20 } },
|
||||||
|
};
|
||||||
|
local session = {
|
||||||
|
id = 'ses_existing',
|
||||||
|
title = 'existing',
|
||||||
|
directory = '/Users/garm/trap/cc-libs',
|
||||||
|
};
|
||||||
|
local httpStub = fakeHttp({}, {
|
||||||
|
response(200, '[]'),
|
||||||
|
response(200, textutils.serializeJSON(session)),
|
||||||
|
response(200, textutils.serializeJSON(scopedSessions)),
|
||||||
|
});
|
||||||
|
local settingsStub = fakeSettings({
|
||||||
|
['opencc.server_url'] = 'http://host',
|
||||||
|
['opencc.session_id'] = 'ses_existing',
|
||||||
|
});
|
||||||
|
local logs = {};
|
||||||
|
local ai = createAi({ http = httpStub, settings = settingsStub });
|
||||||
|
|
||||||
|
local ok, result = ai.listSessions({
|
||||||
|
log = function(message) logs[#logs + 1] = message; end,
|
||||||
|
});
|
||||||
|
|
||||||
|
testlib.assertTrue(ok, tostring(result));
|
||||||
|
testlib.assertEquals(#result, 1);
|
||||||
|
testlib.assertEquals(result[1].id, 'ses_existing');
|
||||||
|
testlib.assertEquals(httpStub.getCalls[1].url, 'http://host/session');
|
||||||
|
testlib.assertEquals(httpStub.getCalls[2].url, 'http://host/session/ses_existing');
|
||||||
|
testlib.assertEquals(httpStub.getCalls[3].url,
|
||||||
|
'http://host/session?directory=%2FUsers%2Fgarm%2Ftrap%2Fcc-libs');
|
||||||
|
testlib.assertTrue(string.find(logs[2], 'resolving directory', 1, true) ~= nil);
|
||||||
|
end);
|
||||||
|
|
||||||
testlib.test('listSessions fails when server_url missing', function()
|
testlib.test('listSessions fails when server_url missing', function()
|
||||||
local httpStub = fakeHttp({}, {});
|
local httpStub = fakeHttp({}, {});
|
||||||
local ai = createAi({ http = httpStub, settings = fakeSettings() });
|
local ai = createAi({ http = httpStub, settings = fakeSettings() });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user