feat(ai): scope opencode sessions by directory

This commit is contained in:
Guillaume ARM 2026-06-11 10:40:27 +02:00
parent b0bb9949ee
commit 565fc98ce8
9 changed files with 135 additions and 13 deletions

View File

@ -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
end
end
end
end
table.sort(decoded, function(a, b)
return sessionTime(a) > sessionTime(b);
end);
log('sessions returned: ' .. tostring(#decoded));
return true, decoded;
end

View File

@ -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
[

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.8.7",
"version": "0.8.8",
"branch": "next",
"packages": [
"trapos"

View File

@ -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"
}
}

View File

@ -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": [

View File

@ -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": [],

View File

@ -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)');

View File

@ -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() });