diff --git a/skills/xmemo/CHANGELOG.md b/skills/xmemo/CHANGELOG.md index e1e5242..2fd3d7b 100644 --- a/skills/xmemo/CHANGELOG.md +++ b/skills/xmemo/CHANGELOG.md @@ -1,5 +1,12 @@ # XMemo Skill Change Log +## 1.1.13 + +- Add the read-only `recall-context` command for the service's bounded, + prompt-ready `/v1/recall/context` response, with client-side budget validation. +- Preserve existing authentication, scopes, temporary-sandbox limits, and all + other runtime commands. + ## 1.1.12 - Add a short first-successful-run path: anonymous service health check, diff --git a/skills/xmemo/SKILL.md b/skills/xmemo/SKILL.md index 55e5b5d..3157dfe 100644 --- a/skills/xmemo/SKILL.md +++ b/skills/xmemo/SKILL.md @@ -182,8 +182,15 @@ node scripts/xmemo-skill.mjs auth claim-confirm [--allow-plaintext] node scripts/xmemo-skill.mjs auth claim-deny [--allow-plaintext] node scripts/xmemo-skill.mjs logout [--revoke-environment-token] node scripts/xmemo-skill.mjs doctor +node scripts/xmemo-skill.mjs recall-context --query "recent project progress" --max_items 5 --max_tokens 1000 ``` +`recall-context` is a read-only prompt-context helper backed by +`/v1/recall/context`. It returns the service's bounded `context_text` and, with +`--json`, the structured context items. It requires a formal read-capable +credential; temporary sandboxes remain limited to `remember`, `recall`, and +`search`. + `logout` revokes and removes a user credential file. When `XMEMO_KEY` supplies the active credential, logout leaves that externally managed token unchanged unless `--revoke-environment-token` is explicitly passed; unset the environment diff --git a/skills/xmemo/scripts/xmemo-skill.mjs b/skills/xmemo/scripts/xmemo-skill.mjs index ad03ac3..42ca6b6 100644 --- a/skills/xmemo/scripts/xmemo-skill.mjs +++ b/skills/xmemo/scripts/xmemo-skill.mjs @@ -13,7 +13,7 @@ import os from 'node:os'; import readline from 'node:readline'; import { randomUUID } from 'node:crypto'; -const SKILL_VERSION = '1.1.12'; +const SKILL_VERSION = '1.1.13'; const credentialsPath = path.join(os.homedir(), '.xmemo', 'skill-credentials.json'); const registrationPath = path.join(os.homedir(), '.xmemo', 'skill-registration.json'); const SCRIPT_COMMAND = 'node scripts/xmemo-skill.mjs'; @@ -31,7 +31,7 @@ const DEFAULT_TEMPORARY_LIMITS = Object.freeze({ const warnedCredentialOrigins = new Set(); const REST_COMMANDS = new Set([ 'remember', 'recall', 'search', 'save-state', 'restore-state', 'state-save', 'state-restore', - 'restart-snapshot', 'restart-restore', + 'restart-snapshot', 'restart-restore', 'recall-context', 'todo-add', 'todo-list', 'todo-done', 'expense-add', 'doctor', ]); const COMMAND_FLAGS = { @@ -48,6 +48,7 @@ const COMMAND_FLAGS = { 'state-restore': new Set(['key', 'state_key', 'bucket', 'scope']), 'restart-snapshot': new Set(['session_id', 'state_key', 'timeline_limit', 'reminder_limit', 'decision_limit', 'metadata', 'bucket', 'scope', 'path', 'ttl_seconds']), 'restart-restore': new Set(['snapshot_id', 'source_session_id', 'target_session_id', 'state_key', 'restore_state', 'record_restore_event', 'ttl_seconds', 'bucket', 'scope']), + 'recall-context': new Set(['query', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'status', 'threshold', 'max_items', 'max_tokens', 'limit', 'prefer_working']), 'todo-add': new Set(['content', 'due_at', 'bucket', 'scope', 'path']), 'todo-list': new Set(['bucket', 'scope', 'status']), 'todo-done': new Set(['id', 'todo_id', 'note']), @@ -186,6 +187,7 @@ function printUsage(command) { remember: 'remember --content [--path ] [--metadata ]', recall: 'recall --query [--limit ] [--explain ] [--prefer_working ] [--compact]', search: 'search --query [--limit ] [--explain ] [--prefer_working ] [--compact]', + 'recall-context': 'recall-context --query [--max_items ] [--max_tokens ] [--prefer_working ]', 'save-state': 'save-state --key [--content ] [--ttl_seconds <0..604800>]', 'restore-state': 'restore-state --key ', 'state-save': 'state-save --key [--content ] [--ttl_seconds <0..604800>] (legacy alias)', @@ -288,7 +290,7 @@ function validateCommandInput(command, subcommand, positionals, options, flags) ? AUTH_FLAGS[subcommand] || new Set() : COMMAND_FLAGS[command] || new Set(); for (const key of Object.keys(flags)) { - if (/token|api[-_]?key|bearer|authorization|cookie|secret/i.test(key) && key !== 'from-stdin') { + if (/^(token|api[-_]?key|bearer|authorization|cookie|secret)$/i.test(key) && key !== 'from-stdin') { throw new Error(`Refusing sensitive command-line option --${key}. Use XMEMO_KEY or --from-stdin where documented.`); } if (!allowedFlags.has(key)) { @@ -300,6 +302,7 @@ function validateCommandInput(command, subcommand, positionals, options, flags) remember: ['content'], recall: ['query'], search: ['query'], + 'recall-context': ['query'], 'todo-add': ['content'], 'todo-done': ['id|todo_id'], 'expense-add': ['item', 'amount'], @@ -312,6 +315,9 @@ function validateCommandInput(command, subcommand, positionals, options, flags) } if (flags.limit !== undefined) parsePositiveInteger(flags.limit, '--limit', 100); + for (const key of ['max_items', 'max_tokens']) { + if (flags[key] !== undefined) flags[key] = parsePositiveInteger(flags[key], `--${key}`, key === 'max_items' ? 100 : 50_000); + } if (flags.ttl_seconds !== undefined) { const ttlMax = command.startsWith('restart-') ? MAX_STATE_TTL_SECONDS : 604_800; const parsedTtl = parseIntegerInRange(flags.ttl_seconds, '--ttl_seconds', 0, ttlMax); @@ -1271,6 +1277,46 @@ async function main() { return; } + if (command === 'recall-context') { + const body = { + query: flags.query, + path: flags.path || '%', + bucket: flags.bucket || '%', + scope: flags.scope, + team_id: flags.team_id, + memory_type: flags.memory_type || 'auto', + status: flags.status || 'active', + threshold: flags.threshold === undefined ? undefined : Number(flags.threshold), + max_items: flags.max_items, + max_tokens: flags.max_tokens, + limit: flags.limit, + prefer_working: flags.prefer_working === undefined ? true : flags.prefer_working, + }; + Object.keys(body).forEach((key) => body[key] === undefined && delete body[key]); + try { + const res = await makeHttpRequest(options.baseUrl, '/v1/recall/context', 'POST', body, { + 'Authorization': `Bearer ${token}` + }, options.timeoutMs); + const data = parseJsonResponse(res, 'Recall context request'); + const succeeded = res.statusCode >= 200 && res.statusCode < 300 && data.ok !== false; + if (options.json) { + console.log(safeJson(data)); + process.exit(succeeded ? 0 : 1); + } + if (!succeeded) { + console.error(`Error: ${apiErrorMessage(data)} (Code: ${data.error?.code || `HTTP ${res.statusCode}`})`); + process.exit(1); + } + const items = Array.isArray(data.items) ? data.items.length : 0; + const contextText = sanitizeTerminalText(data.context_text || ''); + console.log(`XMemo Context: ${items} item${items === 1 ? '' : 's'}\n${contextText || 'No matching memories found.'}`); + } catch (e) { + console.error('Recall context failed:', e.message); + process.exit(1); + } + return; + } + // Normalize commands for operations mapping let opName = command; if (command === 'save-state' || command === 'state-save') opName = 'state-save'; diff --git a/test/xmemo-standalone-skill.test.js b/test/xmemo-standalone-skill.test.js index 7ebf505..3767680 100644 --- a/test/xmemo-standalone-skill.test.js +++ b/test/xmemo-standalone-skill.test.js @@ -10,6 +10,41 @@ import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const skillScript = path.join(repoRoot, 'skills/xmemo/scripts/xmemo-skill.mjs'); +test('skill script recall-context calls the bounded direct REST endpoint', async () => { + const testServer = createTestServer(); + const baseUrl = await testServer.start(); + testServer.setResponse({ + context_text: 'Recent project progress', + items: [{ id: 'memory-1', content: 'Recent project progress' }], + }); + + try { + const res = await runScript([ + 'recall-context', '--query', 'recent project progress', '--max_items', '5', '--max_tokens', '1000', '--json' + ], { baseUrl, env: { XMEMO_KEY: 'secret-token-key' } }); + assert.equal(res.code, 0); + const payload = JSON.parse(res.stdout); + assert.equal(payload.context_text, 'Recent project progress'); + assert.equal(testServer.requests.length, 1); + const req = testServer.requests[0]; + assert.equal(req.url, '/v1/recall/context'); + assert.equal(req.method, 'POST'); + assert.deepEqual(req.body, { + query: 'recent project progress', + path: '%', + bucket: '%', + memory_type: 'auto', + status: 'active', + max_items: 5, + max_tokens: 1000, + prefer_working: true, + }); + assert.equal(req.headers.authorization, 'Bearer secret-token-key'); + } finally { + await testServer.stop(); + } +}); + // Helper to run the script in a child process async function runScript(args, options = {}) { return new Promise((resolve, reject) => {