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('/+$', ''));
|
||||
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)
|
||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||
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 body = response.readAll();
|
||||
response.close();
|
||||
@ -226,11 +243,13 @@ local function createAi(opts)
|
||||
end
|
||||
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
|
||||
local password = options.password or settingsLib.get('opencc.password') or '';
|
||||
local directory = options.directory or settingsLib.get('opencc.directory');
|
||||
local providerId, modelId = resolveModel(options);
|
||||
return {
|
||||
url = trimTrailingSlash(url),
|
||||
username = username,
|
||||
password = password,
|
||||
directory = directory,
|
||||
providerID = providerId,
|
||||
modelID = modelId,
|
||||
agent = resolveAgent(options),
|
||||
@ -436,6 +455,22 @@ local function createAi(opts)
|
||||
};
|
||||
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)
|
||||
options = options or {};
|
||||
settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
|
||||
@ -448,8 +483,16 @@ local function createAi(opts)
|
||||
local cfg, err = resolveConfig(options);
|
||||
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);
|
||||
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
|
||||
log('list sessions failed: ' .. tostring(code));
|
||||
return false, code;
|
||||
@ -459,14 +502,29 @@ local function createAi(opts)
|
||||
return false, 'erreur serveur: HTTP ' .. tostring(code);
|
||||
end
|
||||
|
||||
local decoded = textutils.unserializeJSON(body);
|
||||
if type(decoded) ~= 'table' then
|
||||
log('list sessions failed: invalid response');
|
||||
return false, 'reponse invalide';
|
||||
local decoded, decodeErr = decodeSessionList(body, log);
|
||||
if not decoded then return false, decodeErr; end
|
||||
|
||||
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
|
||||
table.sort(decoded, function(a, b)
|
||||
return sessionTime(a) > sessionTime(b);
|
||||
end);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
log('sessions returned: ' .. tostring(#decoded));
|
||||
return true, decoded;
|
||||
end
|
||||
|
||||
@ -50,6 +50,9 @@ Health check. Returns `200` when the server is up.
|
||||
|
||||
List all sessions for the current project.
|
||||
|
||||
Optional query parameters used by `ai`:
|
||||
- `directory` — scope results to an opencode project directory.
|
||||
|
||||
**Response** `200`:
|
||||
```json
|
||||
[
|
||||
|
||||
@ -110,6 +110,12 @@ Optional — select an opencode agent for requests from this computer:
|
||||
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:
|
||||
|
||||
```sh
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.8.7",
|
||||
"version": "0.8.8",
|
||||
"branch": "next",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
"trapos-boot": "0.3.2",
|
||||
"trapos-net": "0.3.0",
|
||||
"trapos-ui": "0.2.2",
|
||||
"trapos-ai": "0.6.5",
|
||||
"trapos-ai": "0.6.6",
|
||||
"trapos-sandbox": "0.2.0",
|
||||
"trapos": "0.8.7"
|
||||
"trapos": "0.8.8"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos-ai",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.6",
|
||||
"description": "TrapOS AI client for opencode serve",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.8.7",
|
||||
"version": "0.8.8",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||
"files": [],
|
||||
|
||||
@ -48,6 +48,7 @@ local function printUsage()
|
||||
print(' opencc.username (default: opencode)');
|
||||
print(' opencc.password (Basic Auth password)');
|
||||
print(' opencc.session_id (auto-managed)');
|
||||
print(' opencc.directory (optional session list scope)');
|
||||
print(' opencc.agent (e.g. computercraft)');
|
||||
print(' opencc.provider_id (e.g. anthropic)');
|
||||
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');
|
||||
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()
|
||||
local httpStub = fakeHttp({}, {});
|
||||
local ai = createAi({ http = httpStub, settings = fakeSettings() });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user