Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,22 @@ jobs:
- run: npm run lint
- run: npm test
- run: npm run pack:dry-run

compatibility:
name: ${{ matrix.os }} / Node ${{ matrix.node }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run pack:dry-run
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@xmemo/client",
"version": "0.4.181",
"version": "0.4.182",
"description": "Privacy-first CLI client and MCP setup tool for XMemo.",
"mcpName": "io.github.yonro/xmemo",
"type": "module",
Expand All @@ -12,6 +12,7 @@
"files": [
"bin",
"docs/assets",
"scripts",
"src",
"skills",
"plugins/kiro",
Expand All @@ -21,7 +22,7 @@
],
"scripts": {
"lint": "node scripts/check-js.mjs",
"test": "node --test \"test/**/*.test.js\"",
"test": "node scripts/run-tests.mjs",
"pack:dry-run": "npm pack --dry-run",
"release:check": "node scripts/check-release-version.mjs",
"prepublishOnly": "npm run release:check && npm run lint && npm test && npm run pack:dry-run"
Expand Down
26 changes: 26 additions & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readdir } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const testDirectory = path.join(repositoryRoot, 'test');
const testFiles = (await readdir(testDirectory))
.filter((name) => name.endsWith('.test.js'))
.sort()
.map((name) => path.join(testDirectory, name));

if (testFiles.length === 0) {
console.error(`No root CLI tests found in ${testDirectory}.`);
process.exitCode = 1;
} else {
const child = spawn(process.execPath, ['--test', ...testFiles], { stdio: 'inherit' });
child.on('error', (error) => {
console.error(`Could not start the Node test runner: ${error.message}`);
process.exitCode = 1;
});
child.on('exit', (code, signal) => {
if (signal) process.exitCode = 1;
else process.exitCode = code ?? 1;
});
}
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
{
"registryType": "npm",
"identifier": "@xmemo/client",
"version": "0.4.181",
"version": "0.4.182",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
Expand Down
47 changes: 44 additions & 3 deletions src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ export function createServiceClient({
throw new UsageError('timeoutMs must be a positive integer.');
}

async function request({ method, path, query, body, sideEffect = false, retry = 'none', operation, timeoutMs: requestTimeoutMs = timeoutMs }) {
async function request({ method, path, query, body, sideEffect = false, retry = 'none', operation, timeoutMs: requestTimeoutMs = timeoutMs, deadlineMs }) {
if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs <= 0) throw new UsageError('Request timeout must be a positive integer.');
if (deadlineMs !== undefined && (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0)) throw new UsageError('Request deadline must be a positive integer.');
if (io.signal?.aborted) throw new InterruptedError('Local request interrupted before transmission.');
const url = buildUrl(serviceBaseUrl, path, query);
const headers = {
Expand All @@ -64,10 +65,13 @@ export function createServiceClient({
}

const attempts = retry === 'bounded' && !sideEffect ? 2 : 1;
const deadline = deadlineMs === undefined ? null : Date.now() + deadlineMs;
let lastError;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const { response, payload } = await fetchWithTimeout(url, init, requestTimeoutMs, io, async (response) => {
const remaining = deadline === null ? requestTimeoutMs : deadline - Date.now();
if (remaining <= 0) throw new ServiceClientError(`Service request deadline exceeded: ${method} ${path}.`, { code: 'REQUEST_DEADLINE_EXCEEDED' });
const { response, payload } = await fetchWithTimeout(url, init, Math.min(requestTimeoutMs, remaining), io, async (response) => {
let payload;
try { payload = await readJsonResponse(response, maxResponseBytes); }
catch (error) {
Expand All @@ -79,7 +83,14 @@ export function createServiceClient({
if (!response.ok) {
const safePayload = safeErrorData(payload, token);
const details = classifyHttpFailure(response.status, safePayload);
if (details.retryable && attempt + 1 < attempts) continue;
if (details.retryable && attempt + 1 < attempts) {
const retryAfterMs = retryDelayMs(response.headers?.get?.('retry-after'), attempt);
if (deadline !== null && retryAfterMs >= deadline - Date.now()) {
throw new ServiceClientError(`Service request deadline exceeded while waiting to retry: ${method} ${path}.`, { code: 'REQUEST_DEADLINE_EXCEEDED', data: { retryAfterMs } });
}
await waitForRetry(retryAfterMs, io);
continue;
}
if ((details.httpStatus === 404 || details.httpStatus === 405) && operation?.contractRequired) {
throw new ContractRequiredError(`Server contract is unavailable for ${operation.name ?? path}.`, details);
}
Expand Down Expand Up @@ -116,6 +127,36 @@ export function createServiceClient({
return Object.freeze({ baseUrl: serviceBaseUrl, request });
}

function retryDelayMs(retryAfter, attempt) {
if (typeof retryAfter === 'string' && /^\d+(?:\.\d+)?$/u.test(retryAfter.trim())) return Math.ceil(Number(retryAfter) * 1000);
if (typeof retryAfter === 'string') {
const dateMs = Date.parse(retryAfter);
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
}
const baseMs = attempt === 0 ? 250 : 1000;
return Math.round(baseMs * (0.8 + Math.random() * 0.4));
}

async function waitForRetry(delayMs, io) {
if (delayMs <= 0) return;
if (io.signal?.aborted) throw new InterruptedError('Local request interrupted while waiting to retry.');
await new Promise((resolve, reject) => {
let settled = false;
const finish = (callback) => {
if (settled) return;
settled = true;
io.signal?.removeEventListener?.('abort', abort);
callback();
};
const timer = setTimeout(() => finish(resolve), delayMs);
const abort = () => {
clearTimeout(timer);
finish(() => reject(new InterruptedError('Local request interrupted while waiting to retry.')));
};
io.signal?.addEventListener?.('abort', abort, { once: true });
});
}

function buildUrl(baseUrl, path, query) {
const url = new URL(endpointUrl(baseUrl, path));
if (query && typeof query === 'object') {
Expand Down
1 change: 1 addition & 0 deletions src/api/confirmation.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ConfirmationRequiredError } from './errors.js';

export async function confirmRemoteAction(args, io, message) {
if (hasFlag(args, '--yes')) return;
if (io.preflightOnly) throw new ConfirmationRequiredError(message);
if (hasFlag(args, '--json') || !io.stdin?.isTTY) throw new ConfirmationRequiredError(message);
let accepted;
if (typeof io.confirm === 'function') {
Expand Down
9 changes: 9 additions & 0 deletions src/api/contracts/command-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export const COMMAND_REGISTRY = Object.freeze([
availability: 'current',
...read
},
{
command: 'memory.read',
domain: 'memory',
method: 'GET',
path: '/api/v1/memories/{memory_id}/explain',
scopes: ['memory:read'],
availability: 'current',
...read
},
{
command: 'context.recall',
domain: 'context',
Expand Down
33 changes: 26 additions & 7 deletions src/api/contracts/help-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ const common = Object.freeze({
'--json': { type: 'boolean', description: '输出单个 JSON envelope。' },
'--base-url': { type: 'https-url', description: '目标 XMemo 服务地址。' },
'--timeout-ms': { type: 'integer>0', description: '单次 HTTP 请求超时。' },
'--timeout': { type: 'duration', description: '单次 HTTP 请求超时;兼容 --timeout-ms。' },
'--deadline': { type: 'duration', description: '整个服务调用的总预算,例如 30s。' },
'--allow-legacy-credential': { type: 'boolean', description: '仅允许无 origin 元数据的旧凭证连接默认服务;推荐重新登录迁移。' }
});

const INPUT_COMMANDS = new Set([
'memory.add', 'memory.search', 'context.recall', 'state.save', 'state.restore',
'memory.add', 'memory.search', 'memory.read', 'context.recall', 'state.save', 'state.restore',
'restart.snapshot', 'restart.restore', 'knowledge.add', 'knowledge.search',
'knowledge.read', 'knowledge.update', 'dream.preview', 'dream.show', 'dream.apply',
'cloud-skill.add', 'cloud-skill.list', 'cloud-skill.show', 'cloud-skill.update', 'cloud-skill.run'
Expand All @@ -20,23 +22,24 @@ const option = (type, description) => ({ type, description });
const COMMAND_OPTIONS = Object.freeze({
'memory.add': { '--content': option('string', '记忆正文。'), '--path': option('string', '记忆路径。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。'), '--team': option('id', '团队 ID。') },
'memory.search': { '<query>': option('string', '检索文本。'), '--limit': option('integer>0', '结果上限。'), '--team': option('id', '团队 ID。'), '--bucket': option('string', '数据桶。'), '--path': option('string', '路径过滤。'), '--prefer-working': option('boolean', '优先 working 记忆。') },
'context.recall': { '<query>': option('string', '召回目标。'), '--include-knowledge': option('boolean', '包含知识库结果。'), '--team': option('id', '团队 ID。') },
'memory.read': { '<memory-id>': option('id', '完整记忆 ID。'), '--team': option('id', '团队 ID。') },
'context.recall': { '<query>': option('string', '召回目标。'), '--max-tokens': option('integer>0', '上下文 token 预算。'), '--max-items': option('integer>0', '最大记忆条目数。'), '--include-knowledge': option('boolean', '包含知识库结果。'), '--team': option('id', '团队 ID。') },
'state.save': { '--state-key': option('string', '状态槽。'), '--content': option('string', '状态正文。'), '--current-task': option('string', '当前任务。'), '--next-action': option('string', '下一动作。'), '--blocked-reason': option('string', '阻塞原因。'), '--ttl-seconds': option('integer>=0', '存活时间。') },
'state.restore': { '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') },
'restart.snapshot': { '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') },
'restart.restore': { '--snapshot-id': option('id', '快照 ID。'), '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') },
'restart.restore': { '--snapshot-id': option('id', '快照 ID。'), '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。'), '--preview': option('boolean', '只读取恢复结果,不修改工作状态。'), '--apply': option('boolean', '恢复工作状态并记录事件;需要 --yes。'), '--yes': option('boolean', '确认应用恢复。') },
'knowledge.add': { '--base': option('id', '知识库 ID。'), '--create-base': option('string', '显式新建知识库。'), '--title': option('string', '条目标题。'), '--text': option('string', '文本内容。'), '--file': option('path', '文本或文档文件。'), '--document': option('id', '已有 Document ID。'), '--document-version': option('integer>0', 'Document 版本。'), '--publish': option('boolean', '创建为发布状态。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') },
'knowledge.search': { '<query>': option('string', '检索文本。'), '--base': option('id', '知识库 ID。'), '--limit': option('integer>0', '结果上限。'), '--cursor': option('string', '服务端游标。'), '--team': option('id', '团队 ID。') },
'knowledge.read': { '<item-id>': option('id', '知识条目 ID。'), '--offset': option('integer>=0', '正文偏移。'), '--limit-chars': option('integer>0', '本页字符数。'), '--team': option('id', '团队 ID。') },
'knowledge.read': { '<item-id>': option('id', '知识条目 ID。'), '--offset': option('integer>=0', '正文偏移。'), '--limit-chars': option('integer>0', '本页字符数。'), '--receipt-out': option('path', '只保存版本回执,不含正文或凭证。'), '--team': option('id', '团队 ID。') },
'knowledge.update': { '<item-id>': option('id', '知识条目 ID。'), '--from': option('path', 'knowledge read JSON。'), '--text': option('string', '新文本。'), '--file': option('path', '新文本文件。'), '--document': option('id', '同一来源 Document ID。'), '--document-version': option('integer>0', 'Document 版本。'), '--publish': option('boolean', '修改线上内容或发布草稿。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') },
'dream.preview': { '--window-days': option('1..365', '回看天数。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--idempotency-key': option('string', '复用同一预览意图。'), '--team': option('id', '团队 ID。') },
'dream.show': { '<run-id>': option('id', 'Dream run ID。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--team': option('id', '团队 ID。') },
'dream.show': { '<run-id>': option('id', 'Dream run ID。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--receipt-out': option('path', '只保存版本回执,不含正文或凭证。'), '--team': option('id', '团队 ID。') },
'dream.apply': { '<run-id>': option('id', 'Dream run ID。'), '--item': option('id', '单个候选 ID。'), '--from': option('path', 'dream show JSON。'), '--yes': option('boolean', '确认写入。'), '--team': option('id', '团队 ID。') },
'cloud-skill.add': { '--file': option('SKILL.md', '单文件技能。'), '--dir': option('directory', '技能目录。'), '--name': option('string', '显示名。'), '--slug': option('string', '唯一 slug。'), '--publish': option('boolean', '请求发布。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') },
'cloud-skill.list': { '--team': option('id', '团队 ID。') },
'cloud-skill.show': { '<skill-id>': option('id', '技能 ID。'), '--draft': option('boolean', '读取最新维护版本。'), '--team': option('id', '团队 ID。') },
'cloud-skill.show': { '<skill-id>': option('id', '技能 ID。'), '--draft': option('boolean', '读取最新维护版本。'), '--receipt-out': option('path', '只保存版本回执,不含正文或凭证。'), '--team': option('id', '团队 ID。') },
'cloud-skill.update': { '<skill-id>': option('id', '技能 ID。'), '--file': option('SKILL.md', '单文件技能。'), '--dir': option('directory', '技能目录。'), '--from': option('path', 'cloud-skill show JSON。'), '--publish': option('boolean', '请求发布。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') },
'cloud-skill.run': { '<skill-id>': option('id', '技能 ID。'), '--script': option('logical-path', '明确脚本入口。'), '--input': option('path', '包含 input_args 的 JSON。'), '--from': option('path', 'published show JSON。'), '--yes': option('boolean', '确认远端执行。'), '--timeout-seconds': option('1..60', '脚本运行上限。'), '--team': option('id', '团队 ID。') }
'cloud-skill.run': { '<skill-id>': option('id', '技能 ID。'), '--script': option('logical-path', '明确脚本入口。'), '--input': option('path', '包含 input_args 的 JSON。'), '--from': option('path', 'published show JSON。'), '--yes': option('boolean', '确认远端执行。'), '--timeout-seconds': option('1..60', '脚本运行上限;兼容 --execution-timeout 30s。'), '--team': option('id', '团队 ID。') }
});

const CONFIRMATION = Object.freeze({
Expand Down Expand Up @@ -86,3 +89,19 @@ export function writeServiceHelpSchema(io, command) {
writeLine(io.stdout, JSON.stringify(schema));
return true;
}

export function writeHumanServiceHelp(io, command) {
const schema = serviceHelpSchema(command);
if (!schema) return false;
writeLine(io.stdout, `Usage: xmemo ${command.replace('.', ' ')} [options]`);
writeLine(io.stdout, '');
writeLine(io.stdout, 'Options:');
for (const [name, details] of Object.entries(schema.options)) {
writeLine(io.stdout, ` ${name.padEnd(22)} ${details.description}`);
}
writeLine(io.stdout, '');
writeLine(io.stdout, `Example: ${schema.examples[0].invocation}`);
if (schema.confirmation) writeLine(io.stdout, `Impact: confirmation required (${schema.confirmation.flag}).`);
writeLine(io.stdout, 'Exit codes: 0 success; 2 input; 3 authentication; 4 permission; 6 conflict; 7 service; 10 confirmation; 11 unknown outcome.');
return true;
}
1 change: 1 addition & 0 deletions src/api/contracts/input-schema.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const definitions = {
'memory.add': ['content path bucket scope team_id metadata:object memory_type', { content: 'Synthetic memory', path: 'examples/cli' }],
'memory.search': ['query limit:integer team_id bucket path prefer_working:boolean', { query: 'Synthetic', limit: 5 }],
'memory.read': ['memory_id team_id', { memory_id: 'memory-id' }],
'context.recall': ['query include_knowledge:boolean team_id scope limit:integer max_items:integer max_tokens:integer path bucket memory_type status threshold:number prefer_working:boolean', { query: 'Synthetic', max_items: 5, include_knowledge: false }],
'state.save': ['state_key content current_task next_action blocked_reason metadata:object source bucket scope path ttl_seconds:integer', { state_key: 'active_task', current_task: 'Review changes', next_action: 'Run checks' }],
'state.restore': ['state_key bucket scope', { state_key: 'active_task' }],
Expand Down
Loading
Loading