From 565fc98ce8a996a920c5ff41d5f9746d0d14023a Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 10:40:27 +0200 Subject: [PATCH] feat(ai): scope opencode sessions by directory --- apis/libai.lua | 74 +++++++++++++++++++++++++++++++---- docs/opencode_api.md | 3 ++ docs/opencode_server_guide.md | 6 +++ manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-ai/ccpm.json | 2 +- packages/trapos/ccpm.json | 2 +- programs/ai.lua | 1 + tests/ai.lua | 54 +++++++++++++++++++++++++ 9 files changed, 135 insertions(+), 13 deletions(-) diff --git a/apis/libai.lua b/apis/libai.lua index 1fb4715..1618328 100644 --- a/apis/libai.lua +++ b/apis/libai.lua @@ -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 diff --git a/docs/opencode_api.md b/docs/opencode_api.md index 43af22d..1f02934 100644 --- a/docs/opencode_api.md +++ b/docs/opencode_api.md @@ -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 [ diff --git a/docs/opencode_server_guide.md b/docs/opencode_server_guide.md index 71b2f08..24317ee 100644 --- a/docs/opencode_server_guide.md +++ b/docs/opencode_server_guide.md @@ -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 diff --git a/manifest.json b/manifest.json index dda857e..f14bee6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.8.7", + "version": "0.8.8", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index e132486..04c237f 100644 --- a/packages/index.json +++ b/packages/index.json @@ -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" } } diff --git a/packages/trapos-ai/ccpm.json b/packages/trapos-ai/ccpm.json index 117432b..34b80f6 100644 --- a/packages/trapos-ai/ccpm.json +++ b/packages/trapos-ai/ccpm.json @@ -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": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 5d10f25..5b46c1a 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -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": [], diff --git a/programs/ai.lua b/programs/ai.lua index 17e8e74..3baeb3d 100644 --- a/programs/ai.lua +++ b/programs/ai.lua @@ -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)'); diff --git a/tests/ai.lua b/tests/ai.lua index 907a04d..b6074b9 100644 --- a/tests/ai.lua +++ b/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() });