local PING_PROMPT = 'reply with exactly: pong'; local DEFAULT_TIMEOUT_SECONDS = 60; local MAX_TIMEOUT_SECONDS = 60; local DEFAULT_POLL_TIMEOUT_SECONDS = 300; local DEFAULT_POLL_INTERVAL_SECONDS = 2; local DEFAULT_LUA_EXEC_MAX_RETRIES = 2; local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5; local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id'; local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent'; local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; local function base64encode(s) local pad = (3 - #s % 3) % 3; s = s .. string.rep('\0', pad); local r = {}; for i = 1, #s, 3 do local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2); local n = a * 65536 + b * 256 + c; r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1) .. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1) .. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) .. B64:sub(n % 64 + 1, n % 64 + 1); end local result = table.concat(r); if pad > 0 then result = result:sub(1, #result - pad) .. string.rep('=', pad); end return result; end 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(); return body; end local function statusCode(response) if response.getResponseCode then return response.getResponseCode(); end return nil; end local function extractTextParts(parts) if type(parts) ~= 'table' then return ''; end local texts = {}; for _, part in ipairs(parts) do if type(part) == 'table' and part.type == 'text' and type(part.text) == 'string' then texts[#texts + 1] = part.text; end end return table.concat(texts, ''); end local function nowSeconds() if os.epoch then return os.epoch('utc') / 1000; end return os.clock(); end local function tablePack(...) return { n = select('#', ...), ... }; end local function endsWithNewline(s) return type(s) == 'string' and string.sub(s, -1) == '\n'; end local function valuesToLine(values, first, last) local parts = {}; for i = first, last do parts[#parts + 1] = tostring(values[i]); end return table.concat(parts, '\t'); end local function classifyLuaRuntimeError(err) local text = tostring(err or ''); if string.find(text, 'attempt to', 1, true) and string.find(text, 'nil value', 1, true) then return 'identifier'; end if string.find(text, 'global', 1, true) and string.find(text, 'nil', 1, true) then return 'identifier'; end return 'other'; end local function renderOutput(output) if output == nil or output == '' then return '(no output)'; end return output; end local function buildLuaExecPrompt(userPrompt) return table.concat({ 'Write ComputerCraft Lua code to answer this user request.', 'Reply with raw Lua code only. Do not use markdown fences or explanations.', 'The code runs locally with normal ComputerCraft globals available.', 'Use print() or write() for values that should be sent back. Returned values are captured too.', '', 'User request:', userPrompt, }, '\n'); end local function buildLuaCorrectionPrompt(userPrompt, code, err, errorKind) return table.concat({ 'The previous ComputerCraft Lua failed.', 'Reply with corrected raw Lua code only. Do not use markdown fences or explanations.', '', 'Original user request:', userPrompt, '', 'Error kind: ' .. tostring(errorKind), 'Error:', tostring(err), '', 'Previous code:', code, }, '\n'); end local function buildLuaOutputPrompt(userPrompt, output) return table.concat({ 'The Lua executed successfully.', 'Answer the original user request in natural language using the output below.', 'Do not write more Lua unless the user explicitly asked for code.', '', 'Original user request:', userPrompt, '', 'Lua output:', renderOutput(output), }, '\n'); end local function readOsValue(osLib, name) if type(osLib) ~= 'table' or type(osLib[name]) ~= 'function' then return nil; end local ok, value = pcall(osLib[name]); if not ok then return nil; end return value; end local function buildPromptWithCallerContext(prompt, osLib) local lines = { ''; lines[#lines + 1] = ''; lines[#lines + 1] = 'User prompt:'; lines[#lines + 1] = prompt; return table.concat(lines, '\n'); end local function sessionTime(session) if type(session) ~= 'table' or type(session.time) ~= 'table' then return 0; end return tonumber(session.time.updated or session.time.created) or 0; end local function createAi(opts) opts = opts or {}; local httpLib = opts.http or http; local settingsLib = opts.settings or settings; local eventloopFactory = opts.eventloop or require('/apis/eventloop'); local nowFunc = opts.now or nowSeconds; local osLib = opts.os or os; local api = {}; local function resolveTimeout(options) local raw = options.timeoutSeconds; if raw == nil then raw = settingsLib.get('opencc.timeout_seconds'); end local n = tonumber(raw); if not n or n <= 0 then n = DEFAULT_TIMEOUT_SECONDS; end if n > MAX_TIMEOUT_SECONDS then n = MAX_TIMEOUT_SECONDS; end return n; end local function resolvePollTimeout(options) local raw = options.pollTimeoutSeconds; if raw == nil then raw = settingsLib.get('opencc.poll_timeout_seconds'); end local n = tonumber(raw); if not n or n <= 0 then n = DEFAULT_POLL_TIMEOUT_SECONDS; end return n; end local function resolvePollInterval(options) local raw = options.pollIntervalSeconds; if raw == nil then raw = settingsLib.get('opencc.poll_interval_seconds'); end local n = tonumber(raw); if not n or n <= 0 then n = DEFAULT_POLL_INTERVAL_SECONDS; end return n; end local function resolveLuaExecMaxRetries(options) local n = tonumber(options.maxRetries); if n and n >= 0 then return math.floor(n); end return DEFAULT_LUA_EXEC_MAX_RETRIES; end local function resolveLuaExecTimeout(options) if options.luaTimeoutSeconds == false then return nil; end local n = tonumber(options.luaTimeoutSeconds); if n and n > 0 then return n; end return DEFAULT_LUA_EXEC_TIMEOUT_SECONDS; end local function resolveModel(options) local providerId = options.providerID or settingsLib.get('opencc.provider_id'); local modelId = options.modelID or settingsLib.get('opencc.model_id'); if isBlank(providerId) or isBlank(modelId) then return nil, nil; end return providerId, modelId; end local function resolveAgent(options) local agent = options.agent; if agent == nil then agent = settingsLib.get(DEFAULT_AGENT_SETTING_KEY); end if isBlank(agent) then return nil; end return agent; end local function resolveConfig(options) local url = options.serverUrl or settingsLib.get('opencc.server_url'); if not url or url == '' then return nil, 'missing opencc.server_url; run: set opencc.server_url '; 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), timeoutSeconds = resolveTimeout(options), pollTimeoutSeconds = resolvePollTimeout(options), pollIntervalSeconds = resolvePollInterval(options), }; end local function buildPromptBody(cfg, messageId, prompt) local body = { messageID = messageId, parts = { { type = 'text', text = prompt } }, }; if cfg.providerID and cfg.modelID then body.model = { providerID = cfg.providerID, modelID = cfg.modelID }; end if cfg.agent then body.agent = cfg.agent; end return body; end local function buildMessageBody(cfg, prompt) local body = { parts = { { type = 'text', text = prompt } }, }; if cfg.providerID and cfg.modelID then body.model = { providerID = cfg.providerID, modelID = cfg.modelID }; end if cfg.agent then body.agent = cfg.agent; end return body; end local function createMessageId() local t = math.floor(nowFunc() * 1000); return 'msg_' .. tostring(t) .. '_' .. tostring(math.random(100000, 999999)); end local function isMessageComplete(message) if type(message) ~= 'table' or type(message.info) ~= 'table' then return false; end if type(message.info.finish) == 'string' then return true; end return type(message.info.time) == 'table' and message.info.time.completed ~= nil; end local function decodeMessage(value) local decoded = value; if type(value) == 'string' then decoded = textutils.unserializeJSON(value); end if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then return nil, 'reponse message invalide'; end return decoded, nil; end local function findAssistantMessage(messages, submittedMessageId) local seenSubmitted = false; for _, message in ipairs(messages) do if type(message) == 'table' and type(message.info) == 'table' then if message.info.id == submittedMessageId and message.info.role == 'assistant' then return message; elseif message.info.id == submittedMessageId then seenSubmitted = true; elseif seenSubmitted and message.info.role == 'assistant' then return message; end end end return nil; end local function handleMissingSession(persist, sessionSettingKey) if persist then settingsLib.unset(sessionSettingKey or DEFAULT_SESSION_SETTING_KEY); if settingsLib.save then settingsLib.save(); end end return false, 'session introuvable; lance: ai new '; end local doGet; local doPost; local function pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey, log) local loop = eventloopFactory(); local deadline = nowFunc() + cfg.pollTimeoutSeconds; local resultOk, resultValue; local attemptCount = 0; log = log or function() end; local function finish(ok, value) resultOk, resultValue = ok, value; loop.stopLoop(); end local function attempt() attemptCount = attemptCount + 1; local body, code = doGet(cfg, '/session/' .. sessionId .. '/message'); if not body then return finish(false, code); end if code == 404 then local ok, value = handleMissingSession(persist, sessionSettingKey); return finish(ok, value); end if code and code ~= 200 then return finish(false, 'erreur message: HTTP ' .. tostring(code)); end local messages = textutils.unserializeJSON(body); if type(messages) ~= 'table' then return finish(false, 'reponse message invalide'); end local decoded = findAssistantMessage(messages, messageId); local reply = decoded and extractTextParts(decoded.parts) or ''; local complete = decoded and isMessageComplete(decoded) or false; local matchedId = decoded and type(decoded.info) == 'table' and decoded.info.id or 'nil'; log('poll #' .. tostring(attemptCount) .. ': messages=' .. tostring(#messages) .. ', found=' .. tostring(matchedId) .. ', complete=' .. tostring(complete) .. ', text=' .. tostring(reply ~= '')); if decoded and reply ~= '' and complete then log('async reply completed'); return finish(true, { reply = reply, sessionId = sessionId, messageId = messageId }); end if nowFunc() >= deadline then return finish(false, 'delai depasse en attendant la reponse AI'); end loop.setTimeout(attempt, cfg.pollIntervalSeconds); end loop.setTimeout(attempt, 0); loop.runLoop(); return resultOk, resultValue; end local function buildHeaders(cfg) local headers = { ['Content-Type'] = 'application/json', ['Accept'] = 'application/json', }; if cfg.password and cfg.password ~= '' then headers['Authorization'] = 'Basic ' .. base64encode(cfg.username .. ':' .. cfg.password); end return headers; end local function callHttp(method, request) local ok, response, httpErr, errorResponse = pcall(httpLib[method], request); if not ok then return nil, 'http ' .. method .. ' threw: ' .. tostring(response); end response = response or errorResponse; if not response then return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error'); end local code = statusCode(response); local body = readAllAndClose(response); return body, code; end function doGet(cfg, path) return callHttp('get', { url = cfg.url .. path, headers = buildHeaders(cfg), timeout = cfg.timeoutSeconds, }); end function doPost(cfg, path, payload) return callHttp('post', { url = cfg.url .. path, body = textutils.serializeJSON(payload), headers = buildHeaders(cfg), timeout = cfg.timeoutSeconds, }); end local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log) log('sending blocking message'); local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', buildMessageBody(cfg, prompt)); if not body then return false, code; end if code == 404 then return handleMissingSession(persist, sessionSettingKey); end if code and code ~= 200 then return false, 'erreur message: HTTP ' .. tostring(code); end local decoded, decodeErr = decodeMessage(body); if not decoded then return false, decodeErr; end local reply = extractTextParts(decoded.parts); if reply == '' then return false, 'reponse vide'; end return true, { reply = reply, sessionId = sessionId, messageId = type(decoded.info) == 'table' and decoded.info.id or nil, }; 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); if settingsLib.save then settingsLib.save(); end end function api.listSessions(options) options = options or {}; local log = options.log or function() end; 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; 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; end if code and code ~= 200 then log('list sessions failed: HTTP ' .. tostring(code)); return false, 'erreur serveur: HTTP ' .. tostring(code); end 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 log('sessions returned: ' .. tostring(#decoded)); return true, decoded; end function api.ask(prompt, options) options = options or {}; local log = options.log or function() end; if isBlank(prompt) then return false, 'missing prompt; usage: ai '; end local cfg, err = resolveConfig(options); if not cfg then return false, err; end local persist = options.persist ~= false; local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY; local sessionId = options.sessionId; if persist and sessionId == nil then sessionId = settingsLib.get(sessionSettingKey); end if not sessionId or sessionId == '' then log('creating session'); local body, code = doPost(cfg, '/session', { title = options.sessionTitle or 'cc-ai' }); if not body then return false, code; end if code and code ~= 200 then return false, 'impossible de creer une session: HTTP ' .. tostring(code); end local decoded = textutils.unserializeJSON(body); if type(decoded) ~= 'table' or type(decoded.id) ~= 'string' then return false, 'reponse session invalide'; end sessionId = decoded.id; if persist then settingsLib.set(sessionSettingKey, sessionId); if settingsLib.save then settingsLib.save(); end end else log('reusing session ' .. sessionId); end local promptWithContext = prompt; if options.includeCallerContext ~= false then promptWithContext = buildPromptWithCallerContext(prompt, osLib); end if not (cfg.providerID and cfg.modelID) then log('provider/model unset; using blocking message endpoint'); return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log); end local messageId = options.messageId or createMessageId(); log('sending async prompt ' .. messageId); local body, code = doPost(cfg, '/session/' .. sessionId .. '/prompt_async', buildPromptBody(cfg, messageId, promptWithContext)); if not body then return false, code; end if code == 404 then return handleMissingSession(persist, sessionSettingKey); end if code and code ~= 204 and code ~= 200 then return false, 'erreur message: HTTP ' .. tostring(code); end if code == 200 and body and body ~= '' then local decoded, decodeErr = decodeMessage(body); if not decoded then return false, decodeErr; end local reply = extractTextParts(decoded.parts); if reply == '' then return false, 'reponse vide'; end return true, { reply = reply, sessionId = sessionId, messageId = messageId }; end return pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey, log); end function api.createLuaExecutor(options) options = options or {}; local baseEnv = options.env or _G; local live = options.live ~= false; local livePrint = options.print or print; local liveWrite = options.write or write; local timeoutSeconds = resolveLuaExecTimeout(options); return function(code) local buffer = {}; local function append(text) buffer[#buffer + 1] = text; end local function capturedPrint(...) local values = tablePack(...); local line = valuesToLine(values, 1, values.n); append(line .. '\n'); if live then livePrint(...); end end local function capturedWrite(text) text = tostring(text or ''); append(text); if live then liveWrite(text); end end local env = setmetatable({ print = capturedPrint, write = capturedWrite, }, { __index = baseEnv }); local chunk, loadErr = load(code, 'ai-lua-exec', 't', env); if not chunk then return false, tostring(loadErr), 'syntax'; end local result; local finished = false; local function runner() result = tablePack(pcall(chunk)); finished = true; end if timeoutSeconds then parallel.waitForAny(runner, function() sleep(timeoutSeconds); end); else runner(); end if not finished then return false, 'lua execution timed out after ' .. tostring(timeoutSeconds) .. 's', 'other'; end if not result[1] then return false, tostring(result[2]), classifyLuaRuntimeError(result[2]); end if result.n > 1 then if #buffer > 0 and not endsWithNewline(buffer[#buffer]) then append('\n'); end append(valuesToLine(result, 2, result.n) .. '\n'); end return true, table.concat(buffer), nil; end; end function api.luaExec(userPrompt, options) options = options or {}; if isBlank(userPrompt) then return false, { error = 'missing prompt; usage: ai lua-exec ', attempts = 0 }; end local log = options.log or function() end; local executor = options.executor or api.createLuaExecutor(options); local maxRetries = resolveLuaExecMaxRetries(options); local maxAttempts = maxRetries + 1; local sessionId; local function askOptions() return { persist = false, sessionId = sessionId, sessionTitle = 'cc-ai lua-exec', serverUrl = options.serverUrl, username = options.username, password = options.password, providerID = options.providerID, modelID = options.modelID, agent = options.agent, timeoutSeconds = options.timeoutSeconds, }; end log('requesting Lua from AI'); local ok, result = api.ask(buildLuaExecPrompt(userPrompt), askOptions()); if not ok then return false, { error = result, attempts = 0, errorKind = 'ai' }; end sessionId = result.sessionId; log('session: ' .. sessionId); local code = result.reply; for attempt = 1, maxAttempts do log('attempt ' .. tostring(attempt) .. '/' .. tostring(maxAttempts)); log('code:\n' .. code); local execOk, outputOrErr, errorKind = executor(code); if execOk then local output = outputOrErr or ''; log('output:\n' .. renderOutput(output)); log('requesting final reply'); local finalOk, finalResult = api.ask(buildLuaOutputPrompt(userPrompt, output), askOptions()); if not finalOk then return false, { error = finalResult, attempts = attempt, errorKind = 'ai', code = code, output = output, sessionId = sessionId, }; end log('final reply received'); return true, { reply = finalResult.reply, output = output, code = code, attempts = attempt, sessionId = sessionId, }; end errorKind = errorKind or 'other'; log('error (' .. tostring(errorKind) .. '):\n' .. tostring(outputOrErr)); if (errorKind ~= 'syntax' and errorKind ~= 'identifier') or attempt >= maxAttempts then return false, { error = outputOrErr, attempts = attempt, errorKind = errorKind, code = code, sessionId = sessionId, retryExhausted = attempt >= maxAttempts, }; end log('requesting corrected Lua'); local correctionOk, correctionResult = api.ask( buildLuaCorrectionPrompt(userPrompt, code, outputOrErr, errorKind), askOptions() ); if not correctionOk then return false, { error = correctionResult, attempts = attempt, errorKind = 'ai', code = code, sessionId = sessionId, }; end code = correctionResult.reply; end return false, { error = 'lua-exec failed unexpectedly', attempts = maxAttempts }; end function api.ping(options) options = options or {}; options.includeCallerContext = false; return api.ask(PING_PROMPT, options); end return api; end return createAi;