@xmemo/skill 1.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/bin/install.mjs +251 -0
- package/package.json +24 -0
- package/skill/CHANGELOG.md +279 -0
- package/skill/SKILL.md +464 -0
- package/skill/references/ledger-operations.md +147 -0
- package/skill/references/memory-operations.md +231 -0
- package/skill/references/runtime-operations.md +118 -0
- package/skill/references/troubleshooting.md +147 -0
- package/skill/scripts/commands/account.mjs +194 -0
- package/skill/scripts/commands/auth-login.mjs +234 -0
- package/skill/scripts/commands/auth-manage.mjs +201 -0
- package/skill/scripts/commands/ledger.mjs +175 -0
- package/skill/scripts/commands/memory.mjs +306 -0
- package/skill/scripts/commands/ops.mjs +236 -0
- package/skill/scripts/lib/api.mjs +311 -0
- package/skill/scripts/lib/auth-state.mjs +253 -0
- package/skill/scripts/lib/cli-input.mjs +288 -0
- package/skill/scripts/lib/core.mjs +247 -0
- package/skill/scripts/lib/help.mjs +179 -0
- package/skill/scripts/lib/muse-vault.mjs +198 -0
- package/skill/scripts/lib/openclaw-egress.mjs +63 -0
- package/skill/scripts/xmemo-skill.mjs +184 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
credentialsPath,
|
|
7
|
+
registrationPath,
|
|
8
|
+
PLAINTEXT_STORAGE,
|
|
9
|
+
EXIT_CODE,
|
|
10
|
+
exitCodeForHttpStatus,
|
|
11
|
+
exitCodeForErrorCode,
|
|
12
|
+
} from './core.mjs';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
makeHttpRequest,
|
|
16
|
+
parseJsonResponse,
|
|
17
|
+
extractRequestId,
|
|
18
|
+
apiErrorMessage,
|
|
19
|
+
safeJson,
|
|
20
|
+
sanitizeTerminalText,
|
|
21
|
+
formatMemoryContent,
|
|
22
|
+
extractList,
|
|
23
|
+
extractId,
|
|
24
|
+
warnedCredentialOrigins,
|
|
25
|
+
} from './api.mjs';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
getVaultSurrogate,
|
|
29
|
+
isSurrogateToken,
|
|
30
|
+
VaultKeyError,
|
|
31
|
+
} from './muse-vault.mjs';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
isOpenClawSentinel,
|
|
35
|
+
} from './openclaw-egress.mjs';
|
|
36
|
+
|
|
37
|
+
export { warnedCredentialOrigins, isSurrogateToken, isOpenClawSentinel };
|
|
38
|
+
|
|
39
|
+
// Read credential helper
|
|
40
|
+
export async function getStoredToken() {
|
|
41
|
+
const credential = await getStoredCredential();
|
|
42
|
+
return credential?.token || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getStoredCredential() {
|
|
46
|
+
if (process.env.XMEMO_KEY) {
|
|
47
|
+
const raw = process.env.XMEMO_KEY;
|
|
48
|
+
const token = typeof raw === 'string' ? raw.trim() : '';
|
|
49
|
+
if (isOpenClawSentinel(token)) {
|
|
50
|
+
return { token, credential_type: 'environment', storage: 'openclaw-secret' };
|
|
51
|
+
}
|
|
52
|
+
return { token: raw, credential_type: 'environment', storage: 'environment' };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const surrogate = await getVaultSurrogate();
|
|
56
|
+
if (surrogate) {
|
|
57
|
+
return { token: surrogate, credential_type: 'vault', storage: 'vault' };
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof VaultKeyError) {
|
|
61
|
+
if (err.code === 'authd_error') {
|
|
62
|
+
console.error(`⚠️ Meta Muse vault error: ${sanitizeTerminalText(err.message)}`);
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
console.error(`⚠️ Meta Muse vault error: ${sanitizeTerminalText(err.message)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const data = await fs.readFile(credentialsPath, 'utf8');
|
|
70
|
+
const parsed = JSON.parse(data);
|
|
71
|
+
if (!parsed.token) return null;
|
|
72
|
+
if (parsed.storage !== PLAINTEXT_STORAGE || parsed.plaintext_storage_consent !== true) {
|
|
73
|
+
console.error(`⚠️ Legacy plaintext XMemo credential detected at ${credentialsPath}. Rotate it with XMEMO_KEY, or explicitly recreate it with --allow-plaintext.`);
|
|
74
|
+
}
|
|
75
|
+
return parsed;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function bestEffortChmod(targetPath, mode) {
|
|
82
|
+
try {
|
|
83
|
+
await fs.chmod(targetPath, mode);
|
|
84
|
+
} catch {
|
|
85
|
+
// Some platforms do not implement POSIX permission bits. Never claim this is encryption.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function plaintextStorageAllowed(options, credential = null) {
|
|
90
|
+
return options?.allowPlaintext === true
|
|
91
|
+
|| (credential?.storage === PLAINTEXT_STORAGE && credential?.plaintext_storage_consent === true);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function requirePlaintextStorageConsent(options, action) {
|
|
95
|
+
if (options?.allowPlaintext === true) return;
|
|
96
|
+
throw new Error(`${action} needs to persist a token between commands. XMEMO_KEY is preferred and is never copied to disk. To explicitly permit unencrypted storage in ${credentialsPath}, rerun with --allow-plaintext.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function warnPlaintextStorage() {
|
|
100
|
+
console.error(`⚠️ Plaintext credential storage explicitly enabled. The token will be stored unencrypted at ${credentialsPath} and may be read by processes running as your OS user. Prefer XMEMO_KEY or a managed secret store; never share or commit this file.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Save credential helper. Every caller must prove explicit consent or carry forward recorded consent.
|
|
104
|
+
export async function saveToken(token, details = {}, { allowPlaintext = false, warn = false } = {}) {
|
|
105
|
+
if (isSurrogateToken(token)) {
|
|
106
|
+
throw new Error('Refusing to persist Meta Muse surrogate token to disk. Surrogate tokens are dynamically managed by Meta Muse.');
|
|
107
|
+
}
|
|
108
|
+
if (isOpenClawSentinel(token)) {
|
|
109
|
+
throw new Error('Refusing to persist OpenClaw sentinel token to disk. Sentinels are dynamic and managed by OpenClaw.');
|
|
110
|
+
}
|
|
111
|
+
if (!allowPlaintext) {
|
|
112
|
+
throw new Error(`Refusing unencrypted credential storage without --allow-plaintext. Prefer XMEMO_KEY.`);
|
|
113
|
+
}
|
|
114
|
+
if (warn) warnPlaintextStorage();
|
|
115
|
+
const credentialDir = path.dirname(credentialsPath);
|
|
116
|
+
await fs.mkdir(credentialDir, { recursive: true, mode: 0o700 });
|
|
117
|
+
await bestEffortChmod(credentialDir, 0o700);
|
|
118
|
+
const {
|
|
119
|
+
token: _discardToken,
|
|
120
|
+
created_at: _discardCreatedAt,
|
|
121
|
+
storage: _discardStorage,
|
|
122
|
+
plaintext_storage_consent: _discardConsent,
|
|
123
|
+
plaintext_storage_consent_at: _discardConsentAt,
|
|
124
|
+
claim_code: _discardClaimCode,
|
|
125
|
+
...safeDetails
|
|
126
|
+
} = details;
|
|
127
|
+
const data = JSON.stringify({
|
|
128
|
+
token,
|
|
129
|
+
created_at: new Date().toISOString(),
|
|
130
|
+
credential_type: 'formal',
|
|
131
|
+
...safeDetails,
|
|
132
|
+
storage: PLAINTEXT_STORAGE,
|
|
133
|
+
plaintext_storage_consent: true,
|
|
134
|
+
plaintext_storage_consent_at: new Date().toISOString(),
|
|
135
|
+
}, null, 2);
|
|
136
|
+
await fs.writeFile(credentialsPath, `${data}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
137
|
+
await bestEffortChmod(credentialsPath, 0o600);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function getInstallationFingerprint() {
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(await fs.readFile(registrationPath, 'utf8'));
|
|
143
|
+
if (typeof data.installation_fingerprint === 'string' && data.installation_fingerprint) {
|
|
144
|
+
return data.installation_fingerprint;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Create a non-secret stable ID below when no local registration file exists.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const installation_fingerprint = randomUUID();
|
|
151
|
+
const registrationDir = path.dirname(registrationPath);
|
|
152
|
+
await fs.mkdir(registrationDir, { recursive: true, mode: 0o700 });
|
|
153
|
+
await bestEffortChmod(registrationDir, 0o700);
|
|
154
|
+
await fs.writeFile(registrationPath, `${JSON.stringify({ installation_fingerprint, created_at: new Date().toISOString() }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
155
|
+
await bestEffortChmod(registrationPath, 0o600);
|
|
156
|
+
return installation_fingerprint;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function printMemoryResults(result, compact) {
|
|
160
|
+
const results = extractList(result);
|
|
161
|
+
if (results.length === 0) {
|
|
162
|
+
console.log('No matching memories found.');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
results.forEach((item, index) => {
|
|
166
|
+
console.log(`[${index + 1}] ID: ${sanitizeTerminalText(item?.id || item?.memory_id || '(unknown)')} | Path: ${sanitizeTerminalText(item?.path || '(unknown)')}`);
|
|
167
|
+
console.log(`Content: ${formatMemoryContent(item?.content, compact)}`);
|
|
168
|
+
console.log('---');
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function requestTemporaryMemoryOperation(command, options, flags, credential) {
|
|
173
|
+
const headers = { Authorization: `Bearer ${credential.token}` };
|
|
174
|
+
let res;
|
|
175
|
+
if (command === 'remember') {
|
|
176
|
+
const body = Object.fromEntries(Object.entries(flags).filter(([, value]) => value !== undefined));
|
|
177
|
+
body.content = flags.content || '';
|
|
178
|
+
body.path = flags.path || 'memories';
|
|
179
|
+
res = await makeHttpRequest(options.baseUrl, '/v1/remember', 'POST', body, headers, options.timeoutMs);
|
|
180
|
+
} else {
|
|
181
|
+
const params = new URLSearchParams({ query: flags.query || '', limit: String(flags.limit || 5) });
|
|
182
|
+
for (const key of ['threshold', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'explain', 'prefer_working']) {
|
|
183
|
+
if (flags[key] !== undefined) params.set(key, String(flags[key]));
|
|
184
|
+
}
|
|
185
|
+
const apiPath = command === 'search' ? '/v1/memories/search' : '/v1/recall';
|
|
186
|
+
res = await makeHttpRequest(options.baseUrl, `${apiPath}?${params}`, 'GET', null, headers, options.timeoutMs);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const data = parseJsonResponse(res, `Temporary ${command} request`);
|
|
190
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
191
|
+
const challenge = data?.detail;
|
|
192
|
+
if (res.statusCode === 428 && challenge?.errorType === 'binding_confirmation_required') {
|
|
193
|
+
const allowPlaintext = plaintextStorageAllowed(options, credential);
|
|
194
|
+
const pending = {
|
|
195
|
+
credential_type: 'temporary',
|
|
196
|
+
agent_id: credential.agent_id,
|
|
197
|
+
bind_url: credential.bind_url,
|
|
198
|
+
registration_reason: credential.registration_reason,
|
|
199
|
+
pending_confirmation_token: challenge.confirmation_token,
|
|
200
|
+
};
|
|
201
|
+
await saveToken(credential.token, pending, { allowPlaintext, warn: options.allowPlaintext && !credential.plaintext_storage_consent });
|
|
202
|
+
if (options.json) {
|
|
203
|
+
console.log(safeJson(data));
|
|
204
|
+
} else {
|
|
205
|
+
console.error('Your human account has a pending bind confirmation. Run "auth claim-confirm" to finish the formal-token handoff. Do not share the bind URL or confirmation value.');
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
const reqId = extractRequestId(data);
|
|
209
|
+
const reqSuffix = reqId ? ` (request_id: ${reqId})` : '';
|
|
210
|
+
console.error(`Temporary ${command} failed: ${apiErrorMessage(data, safeJson(data))}${reqSuffix}`);
|
|
211
|
+
}
|
|
212
|
+
const exitCode = exitCodeForErrorCode(data?.error?.code) ?? exitCodeForHttpStatus(res.statusCode);
|
|
213
|
+
process.exit(exitCode);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (options.json) {
|
|
217
|
+
console.log(safeJson(data));
|
|
218
|
+
process.exit(EXIT_CODE.SUCCESS);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (command === 'remember') {
|
|
222
|
+
console.log(`✅ Saved to temporary XMemo memory.\nID: ${sanitizeTerminalText(extractId(data.result || data))}`);
|
|
223
|
+
} else {
|
|
224
|
+
printMemoryResults(data.result || data, options.compact);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function claimStatus(baseUrl, credential, options) {
|
|
229
|
+
const res = await makeHttpRequest(baseUrl, '/v1/agents/status', 'GET', null, {
|
|
230
|
+
Authorization: `Bearer ${credential.token}`,
|
|
231
|
+
}, options.timeoutMs);
|
|
232
|
+
const data = parseJsonResponse(res, 'Claim status request');
|
|
233
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
234
|
+
const err = new Error(`Claim status request failed: ${apiErrorMessage(data, safeJson(data))}`);
|
|
235
|
+
err.statusCode = res.statusCode;
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
if (typeof data.formal_token === 'string' && data.formal_token) {
|
|
239
|
+
const allowPlaintext = plaintextStorageAllowed(options, credential);
|
|
240
|
+
await saveToken(data.formal_token, { credential_type: 'formal', agent_id: credential.agent_id }, {
|
|
241
|
+
allowPlaintext,
|
|
242
|
+
warn: options.allowPlaintext && !credential.plaintext_storage_consent,
|
|
243
|
+
});
|
|
244
|
+
console.log('✅ Formal XMemo credential received and stored in the explicitly approved user credential file. Temporary access has been replaced.');
|
|
245
|
+
return data;
|
|
246
|
+
}
|
|
247
|
+
if (options.json) {
|
|
248
|
+
console.log(safeJson(data));
|
|
249
|
+
} else {
|
|
250
|
+
console.log(`Claim status: ${sanitizeTerminalText(data.status || 'unknown')}`);
|
|
251
|
+
}
|
|
252
|
+
return data;
|
|
253
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_BASE_URL,
|
|
4
|
+
DEFAULT_TIMEOUT_MS,
|
|
5
|
+
MAX_STATE_TTL_SECONDS,
|
|
6
|
+
MAX_MEMORY_CONTENT_BYTES,
|
|
7
|
+
COMMAND_FLAGS,
|
|
8
|
+
AUTH_FLAGS,
|
|
9
|
+
parseStrictBoolean,
|
|
10
|
+
parsePositiveInteger,
|
|
11
|
+
parseIntegerInRange,
|
|
12
|
+
parseJsonObject,
|
|
13
|
+
} from './core.mjs';
|
|
14
|
+
import { outputContentTooLarge } from './api.mjs';
|
|
15
|
+
|
|
16
|
+
export function isStdoutTty() {
|
|
17
|
+
const env = process.env;
|
|
18
|
+
if (['1', 'true'].includes(env.XMEMO_FORCE_TTY)) return true;
|
|
19
|
+
if (['0', 'false'].includes(env.XMEMO_FORCE_TTY)) return false;
|
|
20
|
+
if (env.XMEMO_OUTPUT_MODE === 'terminal') return true;
|
|
21
|
+
if (env.XMEMO_OUTPUT_MODE === 'json') return false;
|
|
22
|
+
return Boolean(process.stdout?.isTTY);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function rejectBooleanValue(key, inlineValue) {
|
|
26
|
+
if (inlineValue !== undefined) throw new Error(`--${key} does not accept a value; pass it as a bare flag.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readOptionValue(args, index, key, inlineValue) {
|
|
30
|
+
if (inlineValue !== undefined) {
|
|
31
|
+
if (!inlineValue) throw new Error(`--${key} requires a value.`);
|
|
32
|
+
return { value: inlineValue, index };
|
|
33
|
+
}
|
|
34
|
+
const value = args[index + 1];
|
|
35
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`--${key} requires a value.`);
|
|
36
|
+
return { value, index: index + 1 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper to parse arguments
|
|
40
|
+
export function parseArgs(args) {
|
|
41
|
+
const options = {
|
|
42
|
+
json: false, terminal: false,
|
|
43
|
+
baseUrl: process.env.XMEMO_BASE_URL || DEFAULT_BASE_URL,
|
|
44
|
+
timeoutMs: process.env.XMEMO_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS),
|
|
45
|
+
verify: false, compact: false, help: false, version: false,
|
|
46
|
+
allowPlaintext: false, anonymous: false, revokeEnvironmentToken: false,
|
|
47
|
+
};
|
|
48
|
+
const positionals = [];
|
|
49
|
+
const flags = {};
|
|
50
|
+
let explicitJson = false;
|
|
51
|
+
let explicitTerminal = false;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < args.length; i++) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
if (arg.startsWith('--')) {
|
|
56
|
+
const rawKey = arg.slice(2);
|
|
57
|
+
const eq = rawKey.indexOf('=');
|
|
58
|
+
const key = eq === -1 ? rawKey : rawKey.slice(0, eq);
|
|
59
|
+
const inlineValue = eq === -1 ? undefined : rawKey.slice(eq + 1);
|
|
60
|
+
const isBoolFlag = ['json', 'terminal', 'no-json', 'plain', 'verify', 'compact', 'help', 'version', 'allow-plaintext', 'from-stdin', 'anonymous', 'revoke-environment-token'].includes(key);
|
|
61
|
+
if (isBoolFlag) {
|
|
62
|
+
rejectBooleanValue(key, inlineValue);
|
|
63
|
+
if (key === 'json') explicitJson = true;
|
|
64
|
+
else if (['terminal', 'no-json', 'plain'].includes(key)) explicitTerminal = true;
|
|
65
|
+
else if (key === 'from-stdin') flags[key] = true;
|
|
66
|
+
else if (key === 'allow-plaintext') options.allowPlaintext = true;
|
|
67
|
+
else if (key === 'revoke-environment-token') options.revokeEnvironmentToken = true;
|
|
68
|
+
else options[key] = true;
|
|
69
|
+
} else if (key === 'confirm') {
|
|
70
|
+
if (inlineValue !== undefined) {
|
|
71
|
+
flags.confirm = parseStrictBoolean(inlineValue, '--confirm');
|
|
72
|
+
} else {
|
|
73
|
+
const nextArg = args[i + 1];
|
|
74
|
+
if (nextArg === 'true' || nextArg === 'false') {
|
|
75
|
+
flags.confirm = nextArg === 'true';
|
|
76
|
+
i++;
|
|
77
|
+
} else {
|
|
78
|
+
flags.confirm = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} else if (key === 'base-url' || key === 'timeout-ms') {
|
|
82
|
+
const parsed = readOptionValue(args, i, key, inlineValue);
|
|
83
|
+
options[key === 'base-url' ? 'baseUrl' : 'timeoutMs'] = parsed.value;
|
|
84
|
+
i = parsed.index;
|
|
85
|
+
} else {
|
|
86
|
+
const parsed = readOptionValue(args, i, key, inlineValue);
|
|
87
|
+
if (flags[key] !== undefined && (key === 'content' || key === 'file')) {
|
|
88
|
+
throw new Error(`Cannot specify multiple --${key} options.`);
|
|
89
|
+
}
|
|
90
|
+
flags[key] = parsed.value;
|
|
91
|
+
i = parsed.index;
|
|
92
|
+
}
|
|
93
|
+
} else if (arg.startsWith('-')) {
|
|
94
|
+
const key = arg.slice(1);
|
|
95
|
+
if (key === 'j') explicitJson = true;
|
|
96
|
+
else if (key === 't') explicitTerminal = true;
|
|
97
|
+
else if (key === 'v') options.verify = true;
|
|
98
|
+
else if (key === 'h') options.help = true;
|
|
99
|
+
else throw new Error(`Unknown short option: -${key}`);
|
|
100
|
+
} else {
|
|
101
|
+
positionals.push(arg);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (explicitJson && explicitTerminal) throw new Error('Cannot specify both --json and --terminal.');
|
|
106
|
+
const isTty = isStdoutTty();
|
|
107
|
+
if (explicitJson) options.json = true;
|
|
108
|
+
else if (explicitTerminal) { options.json = false; options.terminal = true; }
|
|
109
|
+
else options.json = !isTty;
|
|
110
|
+
|
|
111
|
+
return { command: positionals[0], subcommand: positionals[1], positionals, options, flags };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function validateCommandInput(command, subcommand, positionals, options, flags) {
|
|
115
|
+
const expectedPositionals = command === 'auth' ? 2 : 1;
|
|
116
|
+
if (positionals.length > expectedPositionals) {
|
|
117
|
+
throw new Error(`Unexpected positional argument: ${positionals[expectedPositionals]}`);
|
|
118
|
+
}
|
|
119
|
+
if (options.anonymous && command !== 'doctor') throw new Error('--anonymous is supported only by doctor.');
|
|
120
|
+
if (options.revokeEnvironmentToken && command !== 'logout') throw new Error('--revoke-environment-token is supported only by logout.');
|
|
121
|
+
|
|
122
|
+
const allowedFlags = command === 'auth' ? AUTH_FLAGS[subcommand] || new Set() : COMMAND_FLAGS[command] || new Set();
|
|
123
|
+
for (const key of Object.keys(flags)) {
|
|
124
|
+
if (/^(token|api[-_]?key|bearer|authorization|cookie|secret|password|passwd|credential|client[-_]?secret|refresh[-_]?token|access[-_]?token|private[-_]?key|xmemo[-_]?key)$/i.test(key) && key !== 'from-stdin') {
|
|
125
|
+
throw new Error(`Refusing sensitive command-line option --${key}. Use XMEMO_KEY or --from-stdin where documented.`);
|
|
126
|
+
}
|
|
127
|
+
if (!allowedFlags.has(key)) throw new Error(`Unknown option for ${command}${subcommand ? ` ${subcommand}` : ''}: --${key}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const required = {
|
|
131
|
+
read: ['id'], update: ['id'], forget: ['id'], remember: ['content|file'],
|
|
132
|
+
recall: ['query'], search: ['query'], 'recall-context': ['query'],
|
|
133
|
+
'todo-add': ['content'], 'todo-done': ['id|todo_id'], 'expense-add': ['item', 'amount'],
|
|
134
|
+
};
|
|
135
|
+
for (const requirement of required[command] || []) {
|
|
136
|
+
const alternatives = requirement.split('|');
|
|
137
|
+
if (!alternatives.some((key) => flags[key] !== undefined && String(flags[key]).trim())) {
|
|
138
|
+
throw new Error(`${command} requires --${alternatives.join(' or --')}.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (command === 'remember' && flags.content !== undefined && flags.file !== undefined) {
|
|
143
|
+
throw new Error('Cannot specify both --content and --file.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (flags.limit !== undefined) flags.limit = parsePositiveInteger(flags.limit, '--limit', command === 'read' ? 1_000_000 : 100);
|
|
147
|
+
if (flags['top-n'] !== undefined || flags.top_n !== undefined) {
|
|
148
|
+
const rawVal = flags['top-n'] !== undefined ? flags['top-n'] : flags.top_n;
|
|
149
|
+
const parsed = parseIntegerInRange(rawVal, '--top-n', 1, 200);
|
|
150
|
+
flags['top-n'] = parsed;
|
|
151
|
+
flags.top_n = parsed;
|
|
152
|
+
}
|
|
153
|
+
if (flags.offset !== undefined) flags.offset = parseIntegerInRange(flags.offset, '--offset', 0, Number.MAX_SAFE_INTEGER);
|
|
154
|
+
for (const key of ['max_items', 'max_tokens']) {
|
|
155
|
+
if (flags[key] !== undefined) flags[key] = parsePositiveInteger(flags[key], `--${key}`, key === 'max_items' ? 100 : 50_000);
|
|
156
|
+
}
|
|
157
|
+
if (flags.ttl_seconds !== undefined) {
|
|
158
|
+
const parsedTtl = parseIntegerInRange(flags.ttl_seconds, '--ttl_seconds', 0, command.startsWith('restart-') ? MAX_STATE_TTL_SECONDS : 604_800);
|
|
159
|
+
if (command.startsWith('restart-')) flags.ttl_seconds = parsedTtl;
|
|
160
|
+
}
|
|
161
|
+
if (flags.metadata !== undefined) flags.metadata = parseJsonObject(flags.metadata, '--metadata');
|
|
162
|
+
for (const b of ['explain', 'prefer_working', 'include_knowledge', 'restore_state', 'record_restore_event']) {
|
|
163
|
+
if (flags[b] !== undefined) flags[b] = parseStrictBoolean(flags[b], `--${b}`);
|
|
164
|
+
}
|
|
165
|
+
for (const key of ['timeline_limit', 'reminder_limit', 'decision_limit']) {
|
|
166
|
+
if (flags[key] !== undefined) flags[key] = parseIntegerInRange(flags[key], `--${key}`, 0, 100);
|
|
167
|
+
}
|
|
168
|
+
if (flags.months !== undefined) flags.months = parsePositiveInteger(flags.months, '--months', 24);
|
|
169
|
+
if (flags.month !== undefined && !/^\d{4}-(0[1-9]|1[0-2])$/.test(String(flags.month))) {
|
|
170
|
+
throw new Error('--month must be formatted as YYYY-MM.');
|
|
171
|
+
}
|
|
172
|
+
for (const m of ['min-amount', 'max-amount']) {
|
|
173
|
+
if (flags[m] !== undefined && (Number.isNaN(Number(flags[m])) || Number(flags[m]) < 0)) {
|
|
174
|
+
throw new Error(`--${m} must be a non-negative number.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (flags.threshold !== undefined) {
|
|
178
|
+
const threshold = Number(flags.threshold);
|
|
179
|
+
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
|
|
180
|
+
throw new Error('--threshold must be a number between 0 and 1.');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (flags.amount !== undefined && !Number.isFinite(Number(flags.amount))) {
|
|
184
|
+
throw new Error('--amount must be numeric.');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Read stdin helper
|
|
189
|
+
export async function readStdin() {
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
let data = '';
|
|
192
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
193
|
+
process.stdin.on('end', () => { resolve(data.trim()); });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Read full stdin content helper (exact UTF-8 content without trimming) with byte limit
|
|
198
|
+
export function readStdinContent(options = {}) {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
let totalBytes = 0;
|
|
201
|
+
const chunks = [];
|
|
202
|
+
let done = false;
|
|
203
|
+
const cleanup = () => {
|
|
204
|
+
process.stdin.removeListener('data', onData);
|
|
205
|
+
process.stdin.removeListener('end', onEnd);
|
|
206
|
+
process.stdin.removeListener('error', onError);
|
|
207
|
+
};
|
|
208
|
+
const onData = (chunk) => {
|
|
209
|
+
if (done) return;
|
|
210
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
211
|
+
totalBytes += buf.length;
|
|
212
|
+
if (totalBytes > MAX_MEMORY_CONTENT_BYTES) {
|
|
213
|
+
done = true;
|
|
214
|
+
cleanup();
|
|
215
|
+
try { process.stdin.pause(); } catch {}
|
|
216
|
+
try { process.stdin.destroy(); } catch {}
|
|
217
|
+
outputContentTooLarge(`Memory content exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
chunks.push(buf);
|
|
221
|
+
};
|
|
222
|
+
const onEnd = () => {
|
|
223
|
+
if (done) return;
|
|
224
|
+
cleanup();
|
|
225
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
226
|
+
};
|
|
227
|
+
const onError = (err) => {
|
|
228
|
+
if (done) return;
|
|
229
|
+
cleanup();
|
|
230
|
+
reject(err);
|
|
231
|
+
};
|
|
232
|
+
process.stdin.on('data', onData).on('end', onEnd).on('error', onError).resume();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function resolveCommandInputs(command, flags, options = {}) {
|
|
237
|
+
if (command === 'remember') {
|
|
238
|
+
if (flags.file !== undefined) {
|
|
239
|
+
let stats;
|
|
240
|
+
try {
|
|
241
|
+
stats = await fs.stat(flags.file);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
throw new Error(`Failed to read file '${flags.file}': ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
if (!stats.isFile()) {
|
|
246
|
+
throw new Error(`Failed to read file '${flags.file}': --file must be a regular file.`);
|
|
247
|
+
}
|
|
248
|
+
if (stats.size > MAX_MEMORY_CONTENT_BYTES) {
|
|
249
|
+
outputContentTooLarge(`File '${flags.file}' exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let handle;
|
|
253
|
+
try {
|
|
254
|
+
handle = await fs.open(flags.file, 'r');
|
|
255
|
+
} catch (err) {
|
|
256
|
+
throw new Error(`Failed to read file '${flags.file}': ${err.message}`);
|
|
257
|
+
}
|
|
258
|
+
const chunks = [];
|
|
259
|
+
let totalBytes = 0;
|
|
260
|
+
const chunkBuf = Buffer.alloc(65536);
|
|
261
|
+
try {
|
|
262
|
+
while (true) {
|
|
263
|
+
const toRead = Math.min(65536, (MAX_MEMORY_CONTENT_BYTES + 1) - totalBytes);
|
|
264
|
+
const { bytesRead } = await handle.read(chunkBuf, 0, toRead, null);
|
|
265
|
+
if (bytesRead === 0) break;
|
|
266
|
+
totalBytes += bytesRead;
|
|
267
|
+
chunks.push(Buffer.from(chunkBuf.subarray(0, bytesRead)));
|
|
268
|
+
if (totalBytes > MAX_MEMORY_CONTENT_BYTES) break;
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
throw new Error(`Failed to read file '${flags.file}': ${err.message}`);
|
|
272
|
+
} finally {
|
|
273
|
+
await handle.close();
|
|
274
|
+
}
|
|
275
|
+
if (totalBytes > MAX_MEMORY_CONTENT_BYTES) {
|
|
276
|
+
outputContentTooLarge(`File '${flags.file}' exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
flags.content = Buffer.concat(chunks).toString('utf8');
|
|
280
|
+
delete flags.file;
|
|
281
|
+
} else if (flags.content === '-') {
|
|
282
|
+
flags.content = await readStdinContent(options);
|
|
283
|
+
}
|
|
284
|
+
if (flags.content === undefined || !String(flags.content).trim()) {
|
|
285
|
+
throw new Error('remember content must not be empty.');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|