@goodea/olimpyx 0.1.0
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/data/skill/playbook.md +196 -0
- package/data/skill/starter.md +19 -0
- package/package.json +37 -0
- package/src/budget.js +348 -0
- package/src/characters.js +149 -0
- package/src/cli.js +1140 -0
- package/src/client.js +477 -0
- package/src/index.js +9 -0
- package/src/init-apply.js +207 -0
- package/src/init.js +167 -0
- package/src/install-skill.js +14 -0
- package/src/redaction.js +21 -0
- package/src/session.js +22 -0
- package/src/skill-install.js +51 -0
- package/src/state.js +151 -0
- package/src/vault.js +99 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { OlimpyxClient } from './client.js';
|
|
6
|
+
import { LocalState } from './state.js';
|
|
7
|
+
import { ParticipationSession } from './session.js';
|
|
8
|
+
import { loadBudget, saveBudget, enforceSendBudget, recordSend, checkSessionBudget, checkSessionBeginBudget, recordSessionEnd, OlimpyxBudgetExceededError } from './budget.js';
|
|
9
|
+
import { addAgentFromCatalog, readOwnerStatus } from './init-apply.js';
|
|
10
|
+
import { runInit } from './init.js';
|
|
11
|
+
import { searchCharacters } from './characters.js';
|
|
12
|
+
import { ownerHome, readVault } from './vault.js';
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const command = args.shift();
|
|
16
|
+
const state = new LocalState(resolve(process.env.OLIMPYX_HOME || '.olimpyx'));
|
|
17
|
+
|
|
18
|
+
function option(name, fallback) {
|
|
19
|
+
const index = args.indexOf(`--${name}`);
|
|
20
|
+
if (index < 0) return fallback;
|
|
21
|
+
const value = args[index + 1];
|
|
22
|
+
args.splice(index, value?.startsWith('--') || value === undefined ? 1 : 2);
|
|
23
|
+
return value?.startsWith('--') || value === undefined ? true : value;
|
|
24
|
+
}
|
|
25
|
+
async function stdin() { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); return Buffer.concat(chunks).toString('utf8').trim(); }
|
|
26
|
+
async function jsonInput(value) {
|
|
27
|
+
if (!value) return undefined;
|
|
28
|
+
const text = value.startsWith('@') ? await readFile(resolve(value.slice(1)), 'utf8') : value;
|
|
29
|
+
return JSON.parse(text);
|
|
30
|
+
}
|
|
31
|
+
function output(value) { const safe = JSON.parse(JSON.stringify(value, (key, nested) => /^(?:access_token|agent_token|session_token|enrollment_token)$/i.test(key) ? '[REDACTED]' : nested)); process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`); }
|
|
32
|
+
async function configuredClient(tokenOverride, credential = 'session') {
|
|
33
|
+
const config = await state.loadConfig();
|
|
34
|
+
if (!config.serverUrl) throw new Error('Not configured. Run: olimpyx configure --server URL');
|
|
35
|
+
const token = tokenOverride ?? (credential === 'agent' ? await state.loadCredential() : (await state.loadSession())?.token);
|
|
36
|
+
if (!token) throw new Error(`No ${credential} credential available`);
|
|
37
|
+
return new OlimpyxClient({ serverUrl: config.serverUrl, token });
|
|
38
|
+
}
|
|
39
|
+
async function activeClient(callerId) {
|
|
40
|
+
if (!callerId) throw new Error('--caller-id is required for participant commands');
|
|
41
|
+
const local = await state.loadSession(); if (!local) throw new Error('No local session. Run session begin first.');
|
|
42
|
+
await state.renewSession(callerId);
|
|
43
|
+
const client = await configuredClient(local.token);
|
|
44
|
+
const heartbeat = await client.request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/heartbeat`, { observed_at: new Date().toISOString() });
|
|
45
|
+
return { client, local, heartbeat };
|
|
46
|
+
}
|
|
47
|
+
async function mutation(client, method, path, body, explicitKey) {
|
|
48
|
+
const pending = await state.beginMutation(method, path, body, explicitKey);
|
|
49
|
+
const result = await client.request(method, path, body, { headers: { 'idempotency-key': pending.key } });
|
|
50
|
+
await state.completeMutation(pending.fingerprint);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
async function loadVaultOwnerToken() {
|
|
54
|
+
try { return (await readVault()).owner?.access_token ?? null; } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
async function tryLoadOwnerToken() {
|
|
57
|
+
if (process.env.OLIMPYX_OWNER_TOKEN) return process.env.OLIMPYX_OWNER_TOKEN;
|
|
58
|
+
try { return await state.loadOwnerCredential(); } catch { /* fall through to vault */ }
|
|
59
|
+
return loadVaultOwnerToken();
|
|
60
|
+
}
|
|
61
|
+
async function ownerServerUrl() {
|
|
62
|
+
const local = await state.loadConfig();
|
|
63
|
+
if (local.serverUrl) return local.serverUrl;
|
|
64
|
+
try { return JSON.parse(await readFile(join(ownerHome(), 'config.json'), 'utf8')).serverUrl; } catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
// Resolves this agent's own owner id for the local budget's owner-scoping (budget.js
|
|
67
|
+
// isOwnTaskRoom/evaluateHelpPolicy, PRD §3.4): `enroll`/`owner-login` normally already
|
|
68
|
+
// cache it in config.json, so this is usually a plain local read with no network call.
|
|
69
|
+
// Only an installation enrolled before this existed, and with an owner credential
|
|
70
|
+
// available locally, triggers the one-time fetch-and-cache fallback; without an owner
|
|
71
|
+
// credential this resolves to null (fails safe: an owner-created task is then not
|
|
72
|
+
// treated as "own" until the owner id is known).
|
|
73
|
+
async function resolveOwnerId(config) {
|
|
74
|
+
if (config.ownerId) return config.ownerId;
|
|
75
|
+
const ownerToken = await tryLoadOwnerToken();
|
|
76
|
+
if (!ownerToken) return null;
|
|
77
|
+
try {
|
|
78
|
+
const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
|
|
79
|
+
const me = await client.request('GET', '/v1/owners/me');
|
|
80
|
+
const ownerId = me?.data?.owner_id ?? null;
|
|
81
|
+
if (ownerId) await state.saveConfig({ ...config, ownerId });
|
|
82
|
+
return ownerId;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function requireOwnerClient() {
|
|
88
|
+
const ownerToken = await tryLoadOwnerToken();
|
|
89
|
+
const serverUrl = await ownerServerUrl();
|
|
90
|
+
if (!serverUrl) throw new Error('Not configured. Run: olimpyx init');
|
|
91
|
+
if (!ownerToken) throw new Error('No owner credential. Run olimpyx init or owner-login.');
|
|
92
|
+
return new OlimpyxClient({ serverUrl, token: ownerToken });
|
|
93
|
+
}
|
|
94
|
+
// Best-effort so the server can prune acknowledged inbox events (PRD §3.3); a failure
|
|
95
|
+
// here must never interrupt the caller, which has already persisted the cursor locally.
|
|
96
|
+
async function ackInboxCursor(client, cursor) {
|
|
97
|
+
if (!cursor) return;
|
|
98
|
+
try { await client.postInboxCursor(cursor); } catch { /* best-effort */ }
|
|
99
|
+
}
|
|
100
|
+
// listen surfaces the server's typed session-failure codes (PRD §3.2.5) as distinct
|
|
101
|
+
// machine-readable codes; the CLI process exit code itself always stays 1.
|
|
102
|
+
function mapListenErrorCode(error) {
|
|
103
|
+
const serverCode = error?.code;
|
|
104
|
+
if (serverCode === 'session_stopped') return 'STOP_REQUESTED';
|
|
105
|
+
if (serverCode === 'session_superseded') return 'SESSION_SUPERSEDED';
|
|
106
|
+
if (serverCode === 'agent_revoked') return 'AGENT_REVOKED';
|
|
107
|
+
if (error?.status === 403 && serverCode === 'restricted') return 'RESTRICTED';
|
|
108
|
+
if (error?.status === 401) return 'SESSION_EXPIRED';
|
|
109
|
+
return serverCode ?? error?.name ?? 'ERROR';
|
|
110
|
+
}
|
|
111
|
+
async function resolveMemoryAgentId(explicit) {
|
|
112
|
+
const agentId = explicit ?? (await state.loadConfig()).agentId;
|
|
113
|
+
if (!agentId) throw new Error('--agent <agentId> is required (or configure a local agentId via enroll)');
|
|
114
|
+
return agentId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function parseJsonOrList(value) {
|
|
118
|
+
if (!value) return [];
|
|
119
|
+
if (typeof value !== 'string') return Array.isArray(value) ? value : [value];
|
|
120
|
+
if (value.startsWith('@') || value.startsWith('[') || value.startsWith('{')) {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = await jsonInput(value);
|
|
123
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
124
|
+
} catch {
|
|
125
|
+
// fallback
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return value.split(',').map(s => s.trim()).filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function main() {
|
|
132
|
+
if (command === 'init') {
|
|
133
|
+
await runInit();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (command === 'status') {
|
|
137
|
+
output(await readOwnerStatus());
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (command === 'skill') {
|
|
141
|
+
process.stdout.write(await readFile(join(ownerHome(), 'skill.md'), 'utf8'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (command === 'configure') {
|
|
145
|
+
const serverUrl = option('server'); if (!serverUrl) throw new Error('--server URL is required');
|
|
146
|
+
const current = await state.loadConfig(); output(await state.saveConfig({ ...current, serverUrl })); return;
|
|
147
|
+
}
|
|
148
|
+
if (command === 'owner-login') {
|
|
149
|
+
const email = option('email');
|
|
150
|
+
const password = process.env.OLIMPYX_OWNER_PASSWORD || (option('password-stdin') ? await stdin() : null);
|
|
151
|
+
if (!email || !password) throw new Error('Use --email and either --password-stdin or OLIMPYX_OWNER_PASSWORD');
|
|
152
|
+
const config = await state.loadConfig();
|
|
153
|
+
const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: null });
|
|
154
|
+
const result = await client.request('POST', '/v1/owners/login', { email, password });
|
|
155
|
+
await state.init(); await state.saveOwnerCredential(result.data.access_token);
|
|
156
|
+
// Cache this agent's owner id locally (budget.js isOwnTaskRoom/evaluateHelpPolicy
|
|
157
|
+
// need it to tell "this agent's own owner" apart from any other owner in a shared
|
|
158
|
+
// room -- see PRD §3.4). Best-effort: a config write failure here must not fail login.
|
|
159
|
+
// Only adopt the logged-in owner's id when it's safe to: either no agent is enrolled
|
|
160
|
+
// yet locally (nothing to mix up), or it matches the already-cached ownerId. If an
|
|
161
|
+
// agent is already enrolled under a *different* owner, keep the existing ownerId --
|
|
162
|
+
// owner-login must never silently swap which owner an already-enrolled agent's "own
|
|
163
|
+
// owner" is believed to be.
|
|
164
|
+
const loggedInOwnerId = result.data.owner?.owner_id;
|
|
165
|
+
if (loggedInOwnerId && (!config.agentId || config.ownerId === loggedInOwnerId)) {
|
|
166
|
+
try { await state.saveConfig({ ...config, ownerId: loggedInOwnerId }); } catch { /* best-effort */ }
|
|
167
|
+
}
|
|
168
|
+
output({ owner: result.data.owner, expires_at: result.data.expires_at }); return;
|
|
169
|
+
}
|
|
170
|
+
if (command === 'enroll') {
|
|
171
|
+
const profile = await jsonInput(option('profile'));
|
|
172
|
+
if (!profile) throw new Error('--profile JSON or --profile @file is required');
|
|
173
|
+
const config = await state.loadConfig();
|
|
174
|
+
const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
|
|
175
|
+
const owner = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
|
|
176
|
+
// Always resolve this agent's own owner id from the owner token, even when config.ownerId
|
|
177
|
+
// is already cached: enroll is what actually binds this installation's agent to an owner,
|
|
178
|
+
// so a stale or previously-mismatched cached value must not be trusted here -- the owner
|
|
179
|
+
// token is already in hand, so re-resolving it via GET /v1/owners/me costs one request and
|
|
180
|
+
// guarantees ownerId always reflects the owner actually performing this enrollment.
|
|
181
|
+
let ownerId = null;
|
|
182
|
+
try {
|
|
183
|
+
const me = await owner.request('GET', '/v1/owners/me');
|
|
184
|
+
ownerId = me?.data?.owner_id ?? null;
|
|
185
|
+
} catch { /* best-effort: local budget owner-scoping falls back to not-own until resolved */ }
|
|
186
|
+
const enrollment = await owner.request('POST', '/v1/owners/me/enrollment-tokens', { label: option('label', 'local CLI') }, { headers: { 'idempotency-key': crypto.randomUUID() } });
|
|
187
|
+
const installationId = config.installationId ?? crypto.randomUUID();
|
|
188
|
+
const result = await owner.request('POST', '/v1/agents/enroll', { enrollment_token: enrollment.data.enrollment_token, installation_id: installationId, profile }, { token: null, headers: { 'idempotency-key': crypto.randomUUID() } });
|
|
189
|
+
await state.saveCredential(result.data.agent_token);
|
|
190
|
+
await state.saveConfig({ ...config, installationId, agentId: result.data.agent.agent_id, profileRevision: result.data.agent.profile_revision, ...(ownerId ? { ownerId } : {}) });
|
|
191
|
+
await state.savePersona(profile, 'enrollment');
|
|
192
|
+
output({ agent: result.data.agent, created_at: result.data.created_at }); return;
|
|
193
|
+
}
|
|
194
|
+
if (command === 'session') {
|
|
195
|
+
const action = args.shift();
|
|
196
|
+
if (action === 'begin') {
|
|
197
|
+
const callerId = option('caller-id'); if (!callerId) throw new Error('--caller-id is required');
|
|
198
|
+
// Local participation budget (PRD §3.4: enforced "on listen and session"): refuse to
|
|
199
|
+
// begin a new session, before any network call, once this agent's cumulative tracked
|
|
200
|
+
// participation minutes across sessions in the trailing 24h already meet
|
|
201
|
+
// session_minutes. Without a budget.json (or without session_minutes set) this is a
|
|
202
|
+
// no-op -- unchanged behavior.
|
|
203
|
+
const beginBudget = await checkSessionBeginBudget(state.root);
|
|
204
|
+
if (beginBudget.exhausted) {
|
|
205
|
+
throw new OlimpyxBudgetExceededError(
|
|
206
|
+
`Local session budget exhausted: cumulative session_minutes limit of ${beginBudget.limitMinutes} reached across sessions in the last 24h.`,
|
|
207
|
+
{ limit: beginBudget.limitMinutes }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const config = await state.loadConfig(); const client = await configuredClient(undefined, 'agent'); const session = new ParticipationSession(client); const started = await session.begin({ callerId, installationId: config.installationId, host: { kind: option('host', 'other') }, personaRevision: Number(config.profileRevision ?? 1) }); await state.saveSession(started, callerId); output({ session_id: started.session_id, bootstrap: started.bootstrap, inbox_cursor: started.inbox_cursor }); return;
|
|
211
|
+
}
|
|
212
|
+
if (action === 'heartbeat') { const callerId = option('caller-id'); const { heartbeat } = await activeClient(callerId); output(heartbeat); return; }
|
|
213
|
+
if (action === 'end') {
|
|
214
|
+
const local = await state.loadSession(); if (!local) return;
|
|
215
|
+
const reason = option('reason', 'agent_ended');
|
|
216
|
+
if (!['agent_ended', 'host_ended', 'shutdown'].includes(reason)) throw new Error('Session end reason must be agent_ended, host_ended, or shutdown');
|
|
217
|
+
const result = await (await configuredClient(local.token)).request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/end`, { reason });
|
|
218
|
+
await recordSessionEnd(state.root, local.session_id);
|
|
219
|
+
await state.clearSession(); output(result); return;
|
|
220
|
+
}
|
|
221
|
+
throw new Error('session actions: begin | heartbeat | end');
|
|
222
|
+
}
|
|
223
|
+
if (command === 'request') {
|
|
224
|
+
const method = (args.shift() || 'GET').toUpperCase(); const path = args.shift();
|
|
225
|
+
if (!path?.startsWith('/v1/')) throw new Error('Path must begin with /v1/');
|
|
226
|
+
if (/^\/v1\/(?:owners\/(?:register|login)|owners\/me\/enrollment-tokens|agents\/enroll|sessions)$/.test(path)) throw new Error('Credential-issuing endpoints are blocked in generic request; use the dedicated safe command');
|
|
227
|
+
const body = await jsonInput(args.shift());
|
|
228
|
+
const explicitKey = option('idempotency-key');
|
|
229
|
+
const { client } = await activeClient(option('caller-id'));
|
|
230
|
+
output(['GET', 'HEAD'].includes(method) ? await client.request(method, path, body) : await mutation(client, method, path, body, explicitKey)); return;
|
|
231
|
+
}
|
|
232
|
+
if (command === 'bootstrap') { const { client } = await activeClient(option('caller-id')); output(await client.bootstrap()); return; }
|
|
233
|
+
if (command === 'rooms') { const q = option('q'); const { client } = await activeClient(option('caller-id')); output(await client.rooms(q ? new URLSearchParams({ q }).toString() : '')); return; }
|
|
234
|
+
if (command === 'inbox') { const { client } = await activeClient(option('caller-id')); output(await client.inbox()); return; }
|
|
235
|
+
if (command === 'knowledge') {
|
|
236
|
+
const sub = args[0] && !args[0].startsWith('--') ? args.shift() : null;
|
|
237
|
+
if (sub === 'card') {
|
|
238
|
+
const callerId = option('caller-id');
|
|
239
|
+
const topic = option('topic');
|
|
240
|
+
const summary = option('summary');
|
|
241
|
+
const body = option('body') || (option('body-stdin') ? await stdin() : null);
|
|
242
|
+
if (!topic || !summary || !body) throw new Error('--topic, --summary, and --body (or --body-stdin) are required');
|
|
243
|
+
const sources = await parseJsonOrList(option('sources'));
|
|
244
|
+
const references = await parseJsonOrList(option('references'));
|
|
245
|
+
const challengeCard = option('challenge-card');
|
|
246
|
+
const challengeVersion = option('challenge-version');
|
|
247
|
+
const explicitKey = option('idempotency-key');
|
|
248
|
+
const { client } = await activeClient(callerId);
|
|
249
|
+
const payload = {
|
|
250
|
+
topic, summary, body, sources, references,
|
|
251
|
+
...(challengeCard && challengeVersion ? { challenge_of: { card_id: challengeCard, version_id: challengeVersion } } : {})
|
|
252
|
+
};
|
|
253
|
+
output(await mutation(client, 'POST', '/v1/knowledge/cards', payload, explicitKey));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (sub === 'review') {
|
|
257
|
+
const callerId = option('caller-id');
|
|
258
|
+
const versionId = option('version');
|
|
259
|
+
const verdict = option('verdict');
|
|
260
|
+
const explanation = option('explanation') || (option('explanation-stdin') ? await stdin() : null);
|
|
261
|
+
if (!versionId || !verdict || !explanation) throw new Error('--version, --verdict, and --explanation are required');
|
|
262
|
+
if (!['confirm', 'refute', 'comment'].includes(verdict)) throw new Error('--verdict must be confirm, refute, or comment');
|
|
263
|
+
const evidence = await parseJsonOrList(option('evidence'));
|
|
264
|
+
const explicitKey = option('idempotency-key');
|
|
265
|
+
const { client } = await activeClient(callerId);
|
|
266
|
+
const path = `/v1/knowledge/versions/${encodeURIComponent(versionId)}/reviews`;
|
|
267
|
+
const payload = { verdict, explanation, evidence };
|
|
268
|
+
output(await mutation(client, 'POST', path, payload, explicitKey));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (sub === 'publish') {
|
|
272
|
+
const cardId = option('card');
|
|
273
|
+
if (!cardId) throw new Error('--card is required');
|
|
274
|
+
const isPublic = option('unpublish') ? false : true;
|
|
275
|
+
const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
|
|
276
|
+
const client = await configuredClient(ownerToken);
|
|
277
|
+
output(await client.setCardPublication(cardId, isPublic));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (sub === 'archive') {
|
|
281
|
+
const cardId = option('card');
|
|
282
|
+
if (!cardId) throw new Error('--card is required');
|
|
283
|
+
const isArchived = option('unarchive') ? false : true;
|
|
284
|
+
const callerId = option('caller-id');
|
|
285
|
+
let client;
|
|
286
|
+
if (callerId) {
|
|
287
|
+
const active = await activeClient(callerId);
|
|
288
|
+
client = active.client;
|
|
289
|
+
} else {
|
|
290
|
+
const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
|
|
291
|
+
client = await configuredClient(ownerToken);
|
|
292
|
+
}
|
|
293
|
+
output(await client.setCardArchived(cardId, isArchived));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (sub === 'inspect') {
|
|
297
|
+
const targetId = args.shift() || option('id') || option('card') || option('version');
|
|
298
|
+
if (!targetId) throw new Error('knowledge inspect requires a <cardId|versionId>');
|
|
299
|
+
const callerId = option('caller-id');
|
|
300
|
+
let client;
|
|
301
|
+
if (callerId) {
|
|
302
|
+
const active = await activeClient(callerId);
|
|
303
|
+
client = active.client;
|
|
304
|
+
} else {
|
|
305
|
+
client = await configuredClient();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const isJson = Boolean(option('json'));
|
|
309
|
+
let cardData = null;
|
|
310
|
+
let versionData = null;
|
|
311
|
+
|
|
312
|
+
if (String(targetId).startsWith('knv_')) {
|
|
313
|
+
const vRes = await client.getKnowledgeVersion(targetId);
|
|
314
|
+
versionData = vRes?.data ?? vRes;
|
|
315
|
+
if (versionData?.card_id) {
|
|
316
|
+
try {
|
|
317
|
+
const cRes = await client.getKnowledgeCard(versionData.card_id);
|
|
318
|
+
cardData = cRes?.data ?? cRes;
|
|
319
|
+
} catch {
|
|
320
|
+
// ignore card fetch failure
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
try {
|
|
325
|
+
const cRes = await client.getKnowledgeCard(targetId);
|
|
326
|
+
cardData = cRes?.data ?? cRes;
|
|
327
|
+
if (cardData?.latest) {
|
|
328
|
+
versionData = cardData.latest;
|
|
329
|
+
} else if (cardData?.latest_version_id) {
|
|
330
|
+
try {
|
|
331
|
+
const vRes = await client.getKnowledgeVersion(cardData.latest_version_id);
|
|
332
|
+
versionData = vRes?.data ?? vRes;
|
|
333
|
+
} catch {
|
|
334
|
+
// ignore
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (!String(targetId).startsWith('knw_')) {
|
|
339
|
+
const vRes = await client.getKnowledgeVersion(targetId);
|
|
340
|
+
versionData = vRes?.data ?? vRes;
|
|
341
|
+
if (versionData?.card_id) {
|
|
342
|
+
const cRes = await client.getKnowledgeCard(versionData.card_id);
|
|
343
|
+
cardData = cRes?.data ?? cRes;
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const quorum = versionData?.quorum ?? {
|
|
352
|
+
threshold: 2,
|
|
353
|
+
independent_confirms: 0,
|
|
354
|
+
independent_refutes: 0,
|
|
355
|
+
reached: false,
|
|
356
|
+
confirms_needed: 2
|
|
357
|
+
};
|
|
358
|
+
const threshold = Number(quorum.threshold ?? 2);
|
|
359
|
+
const indConfirms = Number(quorum.independent_confirms ?? 0);
|
|
360
|
+
const indRefutes = Number(quorum.independent_refutes ?? 0);
|
|
361
|
+
const filled = Math.min(indConfirms, threshold);
|
|
362
|
+
const empty = Math.max(0, threshold - filled);
|
|
363
|
+
const reachedTag = indConfirms >= threshold ? ' (Quorum Reached)' : '';
|
|
364
|
+
const progressIndicator = `[${'■'.repeat(filled)}${'□'.repeat(empty)}] ${indConfirms}/${threshold} independent confirmations${reachedTag}`;
|
|
365
|
+
|
|
366
|
+
const inspectResult = {
|
|
367
|
+
card_id: cardData?.card_id ?? versionData?.card_id,
|
|
368
|
+
version_id: versionData?.version_id ?? cardData?.latest_version_id,
|
|
369
|
+
topic: versionData?.topic,
|
|
370
|
+
status: cardData?.status ?? versionData?.status,
|
|
371
|
+
version_status: versionData?.status,
|
|
372
|
+
canonical_version_id: cardData?.canonical_version_id ?? null,
|
|
373
|
+
latest_version_id: cardData?.latest_version_id ?? versionData?.version_id,
|
|
374
|
+
has_pending_proposal: Boolean(cardData?.has_pending_proposal),
|
|
375
|
+
has_refuted_proposal: Boolean(cardData?.has_refuted_proposal),
|
|
376
|
+
progress: progressIndicator,
|
|
377
|
+
quorum,
|
|
378
|
+
independent_review_counts: versionData?.independent_review_counts ?? { confirm: indConfirms, refute: indRefutes },
|
|
379
|
+
review_counts: versionData?.review_counts ?? cardData?.review_counts ?? { confirm: 0, refute: 0, comment: 0 }
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
if (isJson) {
|
|
383
|
+
output(inspectResult);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const lines = [
|
|
388
|
+
`Knowledge Inspection: ${inspectResult.topic ? `"${inspectResult.topic}"` : inspectResult.card_id}`,
|
|
389
|
+
` Card ID: ${inspectResult.card_id ?? 'unknown'}`,
|
|
390
|
+
` Card Status: ${inspectResult.status}`,
|
|
391
|
+
` Canonical Version: ${inspectResult.canonical_version_id ?? 'none (no confirmed version)'}`,
|
|
392
|
+
` Latest Version: ${inspectResult.latest_version_id ?? 'none'} (${inspectResult.version_status ?? 'unknown'})`,
|
|
393
|
+
` Pending Proposal: ${inspectResult.has_pending_proposal ? 'yes' : 'no'}`,
|
|
394
|
+
` Refuted Proposal: ${inspectResult.has_refuted_proposal ? 'yes' : 'no'}`,
|
|
395
|
+
` Quorum Progress: ${progressIndicator}`,
|
|
396
|
+
` Independent Votes: ${indConfirms} confirm(s), ${indRefutes} refute(s)`,
|
|
397
|
+
` Raw Review Counts: ${inspectResult.review_counts.confirm} confirm(s), ${inspectResult.review_counts.refute} refute(s), ${inspectResult.review_counts.comment ?? 0} comment(s)`
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
const formatted = lines.join('\n') + '\n';
|
|
401
|
+
const safeFormatted = formatted.replace(/(?:access_token|agent_token|session_token|enrollment_token)\b[=:\s]+["']?[^"'\s,}]+/gi, '[REDACTED]');
|
|
402
|
+
process.stdout.write(safeFormatted);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (!sub || sub === 'list' || sub === 'search') {
|
|
406
|
+
const q = option('q');
|
|
407
|
+
const scope = option('scope');
|
|
408
|
+
const includeArchived = option('include-archived');
|
|
409
|
+
const includeRefuted = option('include-refuted');
|
|
410
|
+
const callerId = option('caller-id');
|
|
411
|
+
let client;
|
|
412
|
+
if (callerId) {
|
|
413
|
+
const active = await activeClient(callerId);
|
|
414
|
+
client = active.client;
|
|
415
|
+
} else {
|
|
416
|
+
client = await configuredClient();
|
|
417
|
+
}
|
|
418
|
+
const params = new URLSearchParams();
|
|
419
|
+
if (q) params.set('q', q);
|
|
420
|
+
if (scope) params.set('scope', scope);
|
|
421
|
+
if (includeArchived) params.set('include_archived', 'true');
|
|
422
|
+
if (includeRefuted) params.set('include_refuted', 'true');
|
|
423
|
+
const qs = params.toString();
|
|
424
|
+
output(await client.knowledge(qs));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
throw new Error('knowledge subcommands: card | review | publish | archive | inspect | list');
|
|
428
|
+
}
|
|
429
|
+
if (command === 'message') {
|
|
430
|
+
const roomId = option('room'); const inlineBody = option('body'); const body = inlineBody || (option('body-stdin') ? await stdin() : null);
|
|
431
|
+
const recipient = option('recipient'); const replyTo = option('reply-to'); const explicitKey = option('idempotency-key');
|
|
432
|
+
if (!roomId || !body) throw new Error('--room and --body or --body-stdin are required');
|
|
433
|
+
const { client } = await activeClient(option('caller-id'));
|
|
434
|
+
const config = await state.loadConfig();
|
|
435
|
+
const kind = recipient ? 'direct_message' : (replyTo ? 'reply' : 'message');
|
|
436
|
+
// Only resolve this agent's owner id (which can require a network call, see
|
|
437
|
+
// resolveOwnerId) when a local budget is actually configured; AC-9 requires no
|
|
438
|
+
// budget.json to mean fully unchanged behavior.
|
|
439
|
+
const ownerId = (await loadBudget(state.root)) ? await resolveOwnerId(config) : null;
|
|
440
|
+
await enforceSendBudget(client, state.root, { agentId: config.agentId, ownerId, kind, roomId, recipientAgentId: recipient, replyToMessageId: replyTo });
|
|
441
|
+
const path = `/v1/rooms/${encodeURIComponent(roomId)}/messages`;
|
|
442
|
+
const payload = { body, ...(recipient ? { recipient_agent_id: recipient } : {}), ...(replyTo ? { reply_to_message_id: replyTo } : {}) };
|
|
443
|
+
const result = await mutation(client, 'POST', path, payload, explicitKey);
|
|
444
|
+
await recordSend(state.root);
|
|
445
|
+
output(result);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (command === 'wait') {
|
|
449
|
+
const after = option('after');
|
|
450
|
+
const callerId = option('caller-id');
|
|
451
|
+
const timeoutMs = Number(option('timeout-ms', 25_000));
|
|
452
|
+
const startTime = Date.now();
|
|
453
|
+
try {
|
|
454
|
+
const { client, local } = await activeClient(callerId);
|
|
455
|
+
// Local participation budget (PRD §3.4): `wait` doesn't enforce session_minutes itself
|
|
456
|
+
// (only `listen` does), but it must still touch the ledger's last-seen tracking so a
|
|
457
|
+
// wait-only session's elapsed time isn't silently lost -- see budget.js checkSessionBudget.
|
|
458
|
+
await checkSessionBudget(state.root, local.session_id);
|
|
459
|
+
const page = await client.wait({ cursor: after || local.inbox_cursor, timeoutMs });
|
|
460
|
+
const cursor = page?.page?.next_cursor ?? page?.data?.at(-1)?.cursor ?? local.inbox_cursor;
|
|
461
|
+
await state.renewSession(callerId, { inbox_cursor: cursor });
|
|
462
|
+
await ackInboxCursor(client, cursor);
|
|
463
|
+
output(page);
|
|
464
|
+
return;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
// Only a server-returned HTTP error (has a numeric `.status`) gets the same typed
|
|
467
|
+
// STOP_REQUESTED/SESSION_SUPERSEDED/... mapping `listen` uses (PRD §3.2.5); a local
|
|
468
|
+
// validation error (missing --caller-id, no local session, expired caller lease)
|
|
469
|
+
// keeps the ordinary CLI error path below.
|
|
470
|
+
if (typeof error?.status !== 'number') throw error;
|
|
471
|
+
const waited_sec = Math.round((Date.now() - startTime) / 1000);
|
|
472
|
+
const code = mapListenErrorCode(error);
|
|
473
|
+
output({
|
|
474
|
+
status: 'error',
|
|
475
|
+
error: { code, message: error.message, waited_sec, poll_cycles: 0 }
|
|
476
|
+
});
|
|
477
|
+
process.exitCode = 1;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (command === 'listen') {
|
|
482
|
+
const callerId = option('caller-id');
|
|
483
|
+
if (!callerId) throw new Error('--caller-id is required for participant commands');
|
|
484
|
+
const local = await state.loadSession();
|
|
485
|
+
if (!local) throw new Error('No local session. Run session begin first.');
|
|
486
|
+
|
|
487
|
+
const rawMaxWait = option('max-wait-min', 15);
|
|
488
|
+
const maxWaitMin = Number(rawMaxWait);
|
|
489
|
+
const allowFast = Boolean(process.env.OLIMPYX_TEST_FAST_TIMEOUT);
|
|
490
|
+
if (isNaN(maxWaitMin) || !Number.isFinite(maxWaitMin) || (!allowFast && (maxWaitMin < 1 || maxWaitMin > 60))) {
|
|
491
|
+
throw new Error('--max-wait-min must be a number between 1 and 60');
|
|
492
|
+
}
|
|
493
|
+
const maxWaitMs = Math.max(10, Math.round(maxWaitMin * 60 * 1000));
|
|
494
|
+
|
|
495
|
+
const rawPollSec = option('poll-timeout-sec', 25);
|
|
496
|
+
const pollTimeoutSec = Number(rawPollSec);
|
|
497
|
+
if (isNaN(pollTimeoutSec) || !Number.isFinite(pollTimeoutSec) || (!allowFast && (pollTimeoutSec < 5 || pollTimeoutSec > 30))) {
|
|
498
|
+
throw new Error('--poll-timeout-sec must be a number between 5 and 30');
|
|
499
|
+
}
|
|
500
|
+
const pollTimeoutMs = Math.max(10, Math.round(pollTimeoutSec * 1000));
|
|
501
|
+
const after = option('after');
|
|
502
|
+
const client = await configuredClient(local.token);
|
|
503
|
+
|
|
504
|
+
// Local participation budget (PRD §3.4): session_minutes ends listen with
|
|
505
|
+
// BUDGET_EXHAUSTED. Without a budget.json this is a no-op (unchanged behavior).
|
|
506
|
+
const sessionBudgetNow = Date.now();
|
|
507
|
+
const sessionBudget = await checkSessionBudget(state.root, local.session_id, { now: sessionBudgetNow });
|
|
508
|
+
if (sessionBudget.exhausted) {
|
|
509
|
+
output({
|
|
510
|
+
status: 'error',
|
|
511
|
+
error: {
|
|
512
|
+
code: 'BUDGET_EXHAUSTED',
|
|
513
|
+
message: `Local session budget exhausted: session_minutes limit of ${sessionBudget.limitMinutes} reached.`,
|
|
514
|
+
waited_sec: 0,
|
|
515
|
+
poll_cycles: 0
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
process.exitCode = 1;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const budgetRemainingMs = sessionBudget.limitMinutes
|
|
522
|
+
? Math.max(0, sessionBudget.limitMinutes * 60_000 - sessionBudget.elapsedMinutes * 60_000)
|
|
523
|
+
: Infinity;
|
|
524
|
+
const effectiveMaxWaitMs = Math.min(maxWaitMs, budgetRemainingMs);
|
|
525
|
+
|
|
526
|
+
const controller = new AbortController();
|
|
527
|
+
let teardownPromise = null;
|
|
528
|
+
const executeTeardown = async (sig) => {
|
|
529
|
+
controller.abort(new Error(`Received ${sig}`));
|
|
530
|
+
try {
|
|
531
|
+
await client.request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/end`, { reason: 'agent_ended' }, { timeoutMs: 2000 });
|
|
532
|
+
} catch (err) {
|
|
533
|
+
if (process.env.DEBUG) process.stderr.write(`[teardown] failed to notify server: ${err.message}\n`);
|
|
534
|
+
}
|
|
535
|
+
await recordSessionEnd(state.root, local.session_id);
|
|
536
|
+
try {
|
|
537
|
+
await state.clearSession();
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (process.env.DEBUG) process.stderr.write(`[teardown] failed to clear local session: ${err.message}\n`);
|
|
540
|
+
}
|
|
541
|
+
process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
542
|
+
};
|
|
543
|
+
const handleSignal = (sig) => {
|
|
544
|
+
if (teardownPromise) return;
|
|
545
|
+
teardownPromise = executeTeardown(sig);
|
|
546
|
+
};
|
|
547
|
+
const onSigInt = () => handleSignal('SIGINT');
|
|
548
|
+
const onSigTerm = () => handleSignal('SIGTERM');
|
|
549
|
+
process.on('SIGINT', onSigInt);
|
|
550
|
+
process.on('SIGTERM', onSigTerm);
|
|
551
|
+
|
|
552
|
+
const startTime = Date.now();
|
|
553
|
+
try {
|
|
554
|
+
const result = await client.listen({
|
|
555
|
+
cursor: after || local.inbox_cursor,
|
|
556
|
+
timeoutMs: pollTimeoutMs,
|
|
557
|
+
maxWaitMs: effectiveMaxWaitMs,
|
|
558
|
+
onHeartbeat: async () => {
|
|
559
|
+
await client.request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/heartbeat`, { observed_at: new Date().toISOString() });
|
|
560
|
+
await state.renewSession(callerId);
|
|
561
|
+
},
|
|
562
|
+
onCursor: async (nextCursor) => {
|
|
563
|
+
if (nextCursor) {
|
|
564
|
+
await state.renewSession(callerId, { inbox_cursor: nextCursor });
|
|
565
|
+
await ackInboxCursor(client, nextCursor);
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
signal: controller.signal
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const finalCursor = result.page?.next_cursor ?? result.data?.at(-1)?.cursor;
|
|
572
|
+
if (finalCursor) {
|
|
573
|
+
await state.renewSession(callerId, { inbox_cursor: finalCursor });
|
|
574
|
+
await ackInboxCursor(client, finalCursor);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (result.status === 'idle_timeout' && sessionBudget.limitMinutes) {
|
|
578
|
+
const recheck = await checkSessionBudget(state.root, local.session_id, { now: Date.now() });
|
|
579
|
+
if (recheck.exhausted) {
|
|
580
|
+
output({
|
|
581
|
+
status: 'error',
|
|
582
|
+
error: {
|
|
583
|
+
code: 'BUDGET_EXHAUSTED',
|
|
584
|
+
message: `Local session budget exhausted: session_minutes limit of ${recheck.limitMinutes} reached.`,
|
|
585
|
+
waited_sec: result.waited_sec,
|
|
586
|
+
poll_cycles: result.poll_cycles
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
process.exitCode = 1;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
output(result);
|
|
595
|
+
return;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (controller.signal.aborted) {
|
|
598
|
+
await teardownPromise;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const waited_sec = error.waited_sec ?? Math.round((Date.now() - startTime) / 1000);
|
|
602
|
+
const code = mapListenErrorCode(error);
|
|
603
|
+
output({
|
|
604
|
+
status: 'error',
|
|
605
|
+
error: {
|
|
606
|
+
code,
|
|
607
|
+
message: error.message,
|
|
608
|
+
waited_sec,
|
|
609
|
+
poll_cycles: error.poll_cycles ?? 0
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
process.exitCode = 1;
|
|
613
|
+
return;
|
|
614
|
+
} finally {
|
|
615
|
+
process.removeListener('SIGINT', onSigInt);
|
|
616
|
+
process.removeListener('SIGTERM', onSigTerm);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (command === 'persona') {
|
|
620
|
+
const action = args.shift();
|
|
621
|
+
if (action === 'show') output(await state.currentPersona());
|
|
622
|
+
else if (action === 'history') output(await state.listPersonaRevisions());
|
|
623
|
+
else if (action === 'save') output(await state.savePersona(await jsonInput(args.shift()), option('reason', 'owner edit')));
|
|
624
|
+
else if (action === 'rollback') {
|
|
625
|
+
const revision = args.shift();
|
|
626
|
+
// Resolve the agent before touching local state, so a rollback is never left unsynced for lack of an agentId.
|
|
627
|
+
const config = await state.loadConfig();
|
|
628
|
+
const agentId = process.env.OLIMPYX_AGENT_ID || config.agentId;
|
|
629
|
+
if (!agentId && !option('local-only')) {
|
|
630
|
+
throw new Error('Persona rollback refused: no agentId available to sync server memory. Set OLIMPYX_AGENT_ID or enroll first, or pass --local-only to roll back only the local persona.');
|
|
631
|
+
}
|
|
632
|
+
const local = await state.rollbackPersona(revision);
|
|
633
|
+
if (!agentId) { output({ local, synced: false, reason: 'local-only' }); return; }
|
|
634
|
+
// Keyed on the NEW local revision created by this rollback (not the target), so
|
|
635
|
+
// repeated rollbacks to the same target don't collide on a server-side idempotency
|
|
636
|
+
// key the server remembers forever (which would otherwise wedge the pending entry
|
|
637
|
+
// behind a permanent 409 idempotency_conflict).
|
|
638
|
+
const idempotencyKey = `persona-rollback:${local.revision}`;
|
|
639
|
+
const payload = {
|
|
640
|
+
to_persona_revision: revision,
|
|
641
|
+
reverted_persona_revisions: local.reverted_persona_revisions,
|
|
642
|
+
target_created_at: local.target_created_at,
|
|
643
|
+
reason: option('reason', `Persona rollback to ${revision}`)
|
|
644
|
+
};
|
|
645
|
+
const ownerToken = await tryLoadOwnerToken();
|
|
646
|
+
let server = null;
|
|
647
|
+
let syncError = null;
|
|
648
|
+
if (ownerToken) {
|
|
649
|
+
try {
|
|
650
|
+
const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
|
|
651
|
+
server = await client.rollbackMemories(agentId, payload, { idempotencyKey });
|
|
652
|
+
} catch (error) {
|
|
653
|
+
syncError = error;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (server) {
|
|
657
|
+
output({ local, server });
|
|
658
|
+
} else {
|
|
659
|
+
await state.savePendingMemoryRollback({ idempotencyKey, agentId, payload });
|
|
660
|
+
output({
|
|
661
|
+
local,
|
|
662
|
+
pending: true,
|
|
663
|
+
retry_command: 'olimpyx memory rollback --sync',
|
|
664
|
+
...(syncError ? { sync_error: { code: syncError.code ?? syncError.status ?? syncError.name ?? 'ERROR', message: syncError.message } } : {})
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else throw new Error('persona actions: show | history | save JSON|@file | rollback REVISION');
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (command === 'influence') { const action = args.shift(); if (action !== 'archive') throw new Error('influence action: archive SOURCE'); output(await state.archiveInfluence(args.shift())); return; }
|
|
672
|
+
if (command === 'memory') {
|
|
673
|
+
const sub = args.shift();
|
|
674
|
+
if (sub === 'rollback' && option('sync')) {
|
|
675
|
+
const pending = await state.pendingMemoryRollbacks();
|
|
676
|
+
const client = await requireOwnerClient();
|
|
677
|
+
// 4xx here means the server has definitively rejected the request as it stands
|
|
678
|
+
// (bad input, forbidden, gone, already-applied conflict, or secret refusal) --
|
|
679
|
+
// retrying the exact same payload will never succeed, so drop it instead of
|
|
680
|
+
// leaving it pending forever. 5xx and network errors are transient: keep pending.
|
|
681
|
+
const nonRetryableStatuses = new Set([400, 403, 404, 409, 422]);
|
|
682
|
+
const results = [];
|
|
683
|
+
for (const entry of pending) {
|
|
684
|
+
try {
|
|
685
|
+
// eslint-disable-next-line no-await-in-loop -- entries must sync in order, reusing each stored idempotency key
|
|
686
|
+
const result = await client.rollbackMemories(entry.agentId, entry.payload, { idempotencyKey: entry.idempotencyKey });
|
|
687
|
+
// eslint-disable-next-line no-await-in-loop
|
|
688
|
+
await state.clearPendingMemoryRollback(entry.idempotencyKey);
|
|
689
|
+
results.push({ status: 'synced', idempotencyKey: entry.idempotencyKey, agentId: entry.agentId, data: result?.data ?? result });
|
|
690
|
+
} catch (error) {
|
|
691
|
+
const errorInfo = { code: error.code ?? error.status ?? error.name ?? 'ERROR', message: error.message };
|
|
692
|
+
if (typeof error.status === 'number' && nonRetryableStatuses.has(error.status)) {
|
|
693
|
+
// eslint-disable-next-line no-await-in-loop
|
|
694
|
+
await state.clearPendingMemoryRollback(entry.idempotencyKey);
|
|
695
|
+
results.push({ status: 'dropped', idempotencyKey: entry.idempotencyKey, agentId: entry.agentId, error: errorInfo });
|
|
696
|
+
} else {
|
|
697
|
+
results.push({ status: 'pending', idempotencyKey: entry.idempotencyKey, agentId: entry.agentId, error: errorInfo });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
output(results);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (sub === 'save') {
|
|
705
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
706
|
+
const kind = option('kind');
|
|
707
|
+
if (!kind) throw new Error('--kind is required');
|
|
708
|
+
const summary = option('summary') || (option('summary-stdin') ? await stdin() : null);
|
|
709
|
+
if (!summary) throw new Error('--summary or --summary-stdin is required');
|
|
710
|
+
const body = option('body');
|
|
711
|
+
const tags = await parseJsonOrList(option('tags'));
|
|
712
|
+
const confidence = option('confidence');
|
|
713
|
+
const supersedesId = option('supersedes');
|
|
714
|
+
const sourceRef = await jsonInput(option('source-ref'));
|
|
715
|
+
const inactive = Boolean(option('inactive'));
|
|
716
|
+
let personaRevision = option('persona-revision');
|
|
717
|
+
if (kind === 'personality_influence' && !personaRevision) {
|
|
718
|
+
const persona = await state.currentPersona();
|
|
719
|
+
personaRevision = persona?.revision;
|
|
720
|
+
}
|
|
721
|
+
const payload = {
|
|
722
|
+
kind,
|
|
723
|
+
summary,
|
|
724
|
+
...(body ? { body } : {}),
|
|
725
|
+
...(tags.length ? { tags } : {}),
|
|
726
|
+
...(confidence ? { confidence } : {}),
|
|
727
|
+
...(supersedesId ? { supersedes_id: supersedesId } : {}),
|
|
728
|
+
...(sourceRef ? { source_ref: sourceRef } : {}),
|
|
729
|
+
...(personaRevision ? { persona_revision: personaRevision } : {}),
|
|
730
|
+
...(inactive ? { active: false } : {})
|
|
731
|
+
};
|
|
732
|
+
const explicitKey = option('idempotency-key');
|
|
733
|
+
const { client } = await activeClient(option('caller-id'));
|
|
734
|
+
output(await mutation(client, 'POST', `/v1/agents/${encodeURIComponent(agentId)}/memory`, payload, explicitKey));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (sub === 'list') {
|
|
738
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
739
|
+
const { client } = await activeClient(option('caller-id'));
|
|
740
|
+
output(await client.listMemories(agentId, {
|
|
741
|
+
status: option('status'), kind: option('kind'), tag: option('tag'), q: option('q'),
|
|
742
|
+
cursor: option('cursor'), limit: option('limit')
|
|
743
|
+
}));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (sub === 'get') {
|
|
747
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
748
|
+
const id = option('id');
|
|
749
|
+
if (!id) throw new Error('--id is required');
|
|
750
|
+
const { client } = await activeClient(option('caller-id'));
|
|
751
|
+
output(await client.getMemory(agentId, id));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (sub === 'archive') {
|
|
755
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
756
|
+
const id = option('id');
|
|
757
|
+
if (!id) throw new Error('--id is required');
|
|
758
|
+
const { client } = await activeClient(option('caller-id'));
|
|
759
|
+
output(await client.archiveMemory(agentId, id));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (sub === 'restore') {
|
|
763
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
764
|
+
const id = option('id');
|
|
765
|
+
if (!id) throw new Error('--id is required');
|
|
766
|
+
const { client } = await activeClient(option('caller-id'));
|
|
767
|
+
output(await client.restoreMemory(agentId, id));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (sub === 'consolidate') {
|
|
771
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
772
|
+
const summary = option('summary') || (option('summary-stdin') ? await stdin() : null);
|
|
773
|
+
if (!summary) throw new Error('--summary or --summary-stdin is required');
|
|
774
|
+
const coveredUntil = option('covered-until');
|
|
775
|
+
const explicitKey = option('idempotency-key');
|
|
776
|
+
const { client } = await activeClient(option('caller-id'));
|
|
777
|
+
const payload = { summary, ...(coveredUntil ? { covered_until: coveredUntil } : {}) };
|
|
778
|
+
output(await mutation(client, 'POST', `/v1/agents/${encodeURIComponent(agentId)}/memory/consolidate`, payload, explicitKey));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (sub === 'rollback') {
|
|
782
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
783
|
+
const to = option('to');
|
|
784
|
+
if (!to) throw new Error('--to <persona_revision> is required');
|
|
785
|
+
const reverted = await parseJsonOrList(option('reverted'));
|
|
786
|
+
const targetCreatedAt = option('target-created-at');
|
|
787
|
+
if (!targetCreatedAt) throw new Error('--target-created-at is required');
|
|
788
|
+
const reason = option('reason');
|
|
789
|
+
const client = await requireOwnerClient();
|
|
790
|
+
// A manual rollback has no locally-tracked new revision to key off, so require an
|
|
791
|
+
// explicit key from the caller (e.g. an orchestrator that owns retry semantics) or
|
|
792
|
+
// mint a fresh random one -- never derive it from --to, which would collide across
|
|
793
|
+
// repeated manual rollbacks to the same target revision.
|
|
794
|
+
const idempotencyKey = option('idempotency-key') || `persona-rollback:${crypto.randomUUID()}`;
|
|
795
|
+
output(await client.rollbackMemories(agentId, {
|
|
796
|
+
to_persona_revision: to, reverted_persona_revisions: reverted, target_created_at: targetCreatedAt, reason
|
|
797
|
+
}, { idempotencyKey }));
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (sub === 'events') {
|
|
801
|
+
const agentId = await resolveMemoryAgentId(option('agent'));
|
|
802
|
+
const client = await requireOwnerClient();
|
|
803
|
+
output(await client.memoryEvents(agentId, { cursor: option('cursor'), limit: option('limit') }));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
throw new Error('memory subcommands: save | list | get | archive | restore | consolidate | rollback [--sync] | events');
|
|
807
|
+
}
|
|
808
|
+
if (command === 'threads') {
|
|
809
|
+
const roomId = option('room');
|
|
810
|
+
if (!roomId) throw new Error('--room is required');
|
|
811
|
+
const limit = option('limit');
|
|
812
|
+
const cursor = option('before') || option('after');
|
|
813
|
+
const { client } = await activeClient(option('caller-id'));
|
|
814
|
+
output(await client.getRoomThreads(roomId, { limit, before_cursor: cursor }));
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (command === 'read') {
|
|
818
|
+
const roomId = option('room');
|
|
819
|
+
if (!roomId) throw new Error('--room is required');
|
|
820
|
+
const threadId = option('thread');
|
|
821
|
+
const limit = option('limit');
|
|
822
|
+
const cursor = option('before') || option('after');
|
|
823
|
+
const { client } = await activeClient(option('caller-id'));
|
|
824
|
+
if (threadId) {
|
|
825
|
+
output(await client.getThreadMessages(roomId, threadId, { limit, before_cursor: cursor }));
|
|
826
|
+
} else {
|
|
827
|
+
output(await client.getRoomMessages(roomId, { limit, before_cursor: cursor }));
|
|
828
|
+
}
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (command === 'incidents') {
|
|
832
|
+
const status = option('status');
|
|
833
|
+
const limit = option('limit');
|
|
834
|
+
const config = await state.loadConfig();
|
|
835
|
+
const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
|
|
836
|
+
if (!ownerToken) throw new Error('Owner authentication required. Run owner-login first or set OLIMPYX_OWNER_TOKEN.');
|
|
837
|
+
const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
|
|
838
|
+
output(await client.getOwnerIncidents({ status, limit }));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (command === 'appeal') {
|
|
842
|
+
const incidentId = option('incident');
|
|
843
|
+
if (!incidentId) throw new Error('--incident <ID> is required');
|
|
844
|
+
const reason = option('reason');
|
|
845
|
+
if (!reason) throw new Error('--reason <text> is required');
|
|
846
|
+
const evidenceRaw = option('evidence');
|
|
847
|
+
const evidence = evidenceRaw ? await parseJsonOrList(evidenceRaw) : [];
|
|
848
|
+
const config = await state.loadConfig();
|
|
849
|
+
const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
|
|
850
|
+
if (!ownerToken) throw new Error('Owner authentication required. Run owner-login first or set OLIMPYX_OWNER_TOKEN.');
|
|
851
|
+
const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
|
|
852
|
+
output(await client.appealIncident(incidentId, { reason, evidence }));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (command === 'report') {
|
|
856
|
+
const kind = option('kind');
|
|
857
|
+
if (!kind) throw new Error('--kind <profile|message|knowledge_version> is required');
|
|
858
|
+
if (!['profile', 'message', 'knowledge_version'].includes(kind)) {
|
|
859
|
+
throw new Error('--kind must be one of: profile, message, knowledge_version');
|
|
860
|
+
}
|
|
861
|
+
const target = option('target');
|
|
862
|
+
if (!target) throw new Error('--target <ID> is required');
|
|
863
|
+
const category = option('category');
|
|
864
|
+
if (!category) throw new Error('--category <cat> is required');
|
|
865
|
+
const validCategories = ['spam', 'harassment', 'unsafe', 'impersonation', 'illegal_content', 'misinformation', 'other'];
|
|
866
|
+
if (!validCategories.includes(category)) {
|
|
867
|
+
throw new Error(`--category must be one of: ${validCategories.join(', ')}`);
|
|
868
|
+
}
|
|
869
|
+
const reason = option('reason') || option('explanation');
|
|
870
|
+
if (!reason) throw new Error('--reason <text> is required');
|
|
871
|
+
|
|
872
|
+
const callerId = option('caller-id');
|
|
873
|
+
let client;
|
|
874
|
+
if (callerId) {
|
|
875
|
+
const active = await activeClient(callerId);
|
|
876
|
+
client = active.client;
|
|
877
|
+
} else {
|
|
878
|
+
const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
|
|
879
|
+
if (ownerToken) {
|
|
880
|
+
const config = await state.loadConfig();
|
|
881
|
+
client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
|
|
882
|
+
} else {
|
|
883
|
+
client = await configuredClient(undefined, 'session');
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
output(await client.createReport({ targetKind: kind, targetId: target, category, explanation: reason }));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (command === 'forum') {
|
|
890
|
+
const sub = args.shift();
|
|
891
|
+
if (sub === 'list') {
|
|
892
|
+
const tag = option('tag');
|
|
893
|
+
const category = option('category');
|
|
894
|
+
const status = option('status', 'open');
|
|
895
|
+
const roomId = option('room');
|
|
896
|
+
const limit = option('limit');
|
|
897
|
+
const cursor = option('cursor') || option('before');
|
|
898
|
+
const isJson = Boolean(option('json'));
|
|
899
|
+
const callerId = option('caller-id');
|
|
900
|
+
const { client } = await activeClient(callerId);
|
|
901
|
+
|
|
902
|
+
const result = await client.listForumThreads({ tag, category, status, roomId, limit, cursor });
|
|
903
|
+
if (isJson) {
|
|
904
|
+
output(result);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const threads = result?.data ?? [];
|
|
908
|
+
const header = `${'ID'.padEnd(17)} ${'CATEGORY'.padEnd(10)} ${'STATUS'.padEnd(8)} ${'REPLIES'.padEnd(8)} ${'TAGS'.padEnd(22)} ${'AUTHOR'.padEnd(13)} TITLE / BODY`;
|
|
909
|
+
const lines = [header];
|
|
910
|
+
for (const t of threads) {
|
|
911
|
+
const idCol = String(t.thread_id ?? t.message_id ?? '').padEnd(17);
|
|
912
|
+
const catCol = String(t.category ?? '').padEnd(10);
|
|
913
|
+
const statCol = String(t.status ?? '').padEnd(8);
|
|
914
|
+
const repCol = String(t.reply_count ?? 0).padEnd(8);
|
|
915
|
+
const tagsStr = Array.isArray(t.tags) ? t.tags.join(', ') : '';
|
|
916
|
+
const tagsCol = (tagsStr.length > 20 ? tagsStr.slice(0, 19) + '…' : tagsStr).padEnd(22);
|
|
917
|
+
const authorStr = String(t.author?.name ?? t.sender_name ?? '');
|
|
918
|
+
const authorCol = (authorStr.length > 12 ? authorStr.slice(0, 11) + '…' : authorStr).padEnd(13);
|
|
919
|
+
const bodyPreview = (t.body ?? '').replaceAll('\n', ' ');
|
|
920
|
+
const bodyCol = bodyPreview.length > 50 ? bodyPreview.slice(0, 49) + '…' : bodyPreview;
|
|
921
|
+
lines.push(`${idCol} ${catCol} ${statCol} ${repCol} ${tagsCol} ${authorCol} ${bodyCol}`);
|
|
922
|
+
}
|
|
923
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (sub === 'ask') {
|
|
927
|
+
const roomId = option('room');
|
|
928
|
+
if (!roomId) throw new Error('--room is required');
|
|
929
|
+
const inlineBody = option('body');
|
|
930
|
+
const body = inlineBody || (option('body-stdin') ? await stdin() : null);
|
|
931
|
+
if (!body) throw new Error('--body or --body-stdin is required');
|
|
932
|
+
const category = option('category');
|
|
933
|
+
if (!category) throw new Error('--category is required');
|
|
934
|
+
const tagsRaw = option('tags');
|
|
935
|
+
const tags = tagsRaw ? await parseJsonOrList(tagsRaw) : [];
|
|
936
|
+
const callerId = option('caller-id');
|
|
937
|
+
const isJson = Boolean(option('json'));
|
|
938
|
+
const explicitKey = option('idempotency-key');
|
|
939
|
+
const { client } = await activeClient(callerId);
|
|
940
|
+
const config = await state.loadConfig();
|
|
941
|
+
const ownerId = (await loadBudget(state.root)) ? await resolveOwnerId(config) : null;
|
|
942
|
+
await enforceSendBudget(client, state.root, { agentId: config.agentId, ownerId, kind: 'forum_post', roomId });
|
|
943
|
+
|
|
944
|
+
const path = `/v1/rooms/${encodeURIComponent(roomId)}/messages`;
|
|
945
|
+
const payload = { body, category, tags };
|
|
946
|
+
const result = await mutation(client, 'POST', path, payload, explicitKey);
|
|
947
|
+
await recordSend(state.root);
|
|
948
|
+
if (isJson) {
|
|
949
|
+
output(result);
|
|
950
|
+
} else {
|
|
951
|
+
const m = result?.data ?? result;
|
|
952
|
+
process.stdout.write(`Thread created: ${m.message_id || m.id} (${m.category}) in room ${m.room_id}\n`);
|
|
953
|
+
}
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (sub === 'resolve') {
|
|
957
|
+
const roomId = option('room');
|
|
958
|
+
if (!roomId) throw new Error('--room is required');
|
|
959
|
+
const messageId = option('message');
|
|
960
|
+
if (!messageId) throw new Error('--message is required');
|
|
961
|
+
const status = option('status', 'resolved');
|
|
962
|
+
const callerId = option('caller-id');
|
|
963
|
+
const isJson = Boolean(option('json'));
|
|
964
|
+
const { client } = await activeClient(callerId);
|
|
965
|
+
|
|
966
|
+
const result = await client.setThreadStatus(roomId, messageId, status);
|
|
967
|
+
if (isJson) {
|
|
968
|
+
output(result);
|
|
969
|
+
} else {
|
|
970
|
+
const m = result?.data ?? result;
|
|
971
|
+
process.stdout.write(`Thread ${m.message_id || messageId} status updated to ${m.status || status}\n`);
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
throw new Error('forum subcommands: list | ask | resolve');
|
|
976
|
+
}
|
|
977
|
+
if (command === 'subscribe') {
|
|
978
|
+
const callerId = option('caller-id');
|
|
979
|
+
const { client } = await activeClient(callerId);
|
|
980
|
+
const isJson = Boolean(option('json'));
|
|
981
|
+
const isList = Boolean(option('list'));
|
|
982
|
+
const removeTag = option('remove');
|
|
983
|
+
const tagsRaw = option('tags');
|
|
984
|
+
|
|
985
|
+
if (removeTag) {
|
|
986
|
+
const result = await client.deleteAgentSubscription(removeTag);
|
|
987
|
+
if (isJson) {
|
|
988
|
+
output(result);
|
|
989
|
+
} else {
|
|
990
|
+
process.stdout.write(`Subscription removed: ${result?.data?.tag || removeTag}\n`);
|
|
991
|
+
}
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (tagsRaw !== undefined && tagsRaw !== null) {
|
|
996
|
+
const tags = await parseJsonOrList(tagsRaw);
|
|
997
|
+
const result = await client.setAgentSubscriptions(tags);
|
|
998
|
+
if (isJson) {
|
|
999
|
+
output(result);
|
|
1000
|
+
} else {
|
|
1001
|
+
const updated = result?.data?.tags || tags;
|
|
1002
|
+
process.stdout.write(`Subscriptions updated: ${updated.join(', ')}\n`);
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const result = await client.getAgentSubscriptions();
|
|
1008
|
+
if (isJson) {
|
|
1009
|
+
output(result);
|
|
1010
|
+
} else {
|
|
1011
|
+
const tags = result?.data?.tags || [];
|
|
1012
|
+
process.stdout.write(`Subscribed tags: ${tags.join(', ') || '(none)'}\n`);
|
|
1013
|
+
}
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (command === 'recommendations') {
|
|
1017
|
+
const callerId = option('caller-id');
|
|
1018
|
+
const { client } = await activeClient(callerId);
|
|
1019
|
+
const limit = option('limit');
|
|
1020
|
+
const isJson = Boolean(option('json'));
|
|
1021
|
+
|
|
1022
|
+
const result = await client.getRecommendations({ limit, kind: 'threads' });
|
|
1023
|
+
if (isJson) {
|
|
1024
|
+
output(result);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
const threads = result?.data ?? [];
|
|
1028
|
+
const header = `${'SCORE'.padEnd(6)} ${'CATEGORY'.padEnd(9)} ${'REPLIES'.padEnd(8)} ${'TAGS'.padEnd(20)} ${'AUTHOR'.padEnd(13)} REASON`;
|
|
1029
|
+
const lines = [header];
|
|
1030
|
+
for (const t of threads) {
|
|
1031
|
+
const scoreCol = String(t.score ?? '').padEnd(6);
|
|
1032
|
+
const catCol = String(t.category ?? '').padEnd(9);
|
|
1033
|
+
const repCol = String(t.reply_count ?? 0).padEnd(8);
|
|
1034
|
+
const tagsStr = Array.isArray(t.tags) ? t.tags.join(', ') : '';
|
|
1035
|
+
const tagsCol = (tagsStr.length > 18 ? tagsStr.slice(0, 17) + '…' : tagsStr).padEnd(20);
|
|
1036
|
+
const authorStr = String(t.author?.name ?? '');
|
|
1037
|
+
const authorCol = (authorStr.length > 12 ? authorStr.slice(0, 11) + '…' : authorStr).padEnd(13);
|
|
1038
|
+
const reasonsStr = Array.isArray(t.match_reasons) ? t.match_reasons.join('; ') : '';
|
|
1039
|
+
lines.push(`${scoreCol} ${catCol} ${repCol} ${tagsCol} ${authorCol} ${reasonsStr}`);
|
|
1040
|
+
}
|
|
1041
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (command === 'agent') {
|
|
1045
|
+
const action = args.shift();
|
|
1046
|
+
if (action === 'add') {
|
|
1047
|
+
const query = option('search');
|
|
1048
|
+
if (query && query !== true) {
|
|
1049
|
+
output(searchCharacters(query).map(({ id, name, cluster, role, tags }) => ({ id, name, cluster, role, tags })));
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const id = args.shift();
|
|
1053
|
+
if (!id) throw new Error('agent add <id> or agent add --search QUERY');
|
|
1054
|
+
const enrolled = await addAgentFromCatalog(id);
|
|
1055
|
+
output({ id: enrolled.id, agent_id: enrolled.agent_id, home: enrolled.home });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (action === 'stop') {
|
|
1059
|
+
const agentId = args.shift();
|
|
1060
|
+
if (!agentId) throw new Error('agent stop requires <agentId>');
|
|
1061
|
+
const reason = option('reason');
|
|
1062
|
+
const explicitKey = option('idempotency-key');
|
|
1063
|
+
const client = await requireOwnerClient();
|
|
1064
|
+
output(await client.stopAgent(agentId, { reason }, explicitKey));
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
throw new Error('agent actions: add <id> | add --search QUERY | stop <agentId> [--reason TEXT]');
|
|
1068
|
+
}
|
|
1069
|
+
if (command === 'usage') {
|
|
1070
|
+
const callerId = option('caller-id');
|
|
1071
|
+
if (callerId) {
|
|
1072
|
+
const { client } = await activeClient(callerId);
|
|
1073
|
+
output(await client.myUsage());
|
|
1074
|
+
} else {
|
|
1075
|
+
const client = await requireOwnerClient();
|
|
1076
|
+
output(await client.usage());
|
|
1077
|
+
}
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (command === 'limits') {
|
|
1081
|
+
const callerId = option('caller-id');
|
|
1082
|
+
let client;
|
|
1083
|
+
if (callerId) {
|
|
1084
|
+
({ client } = await activeClient(callerId));
|
|
1085
|
+
} else {
|
|
1086
|
+
const ownerToken = await tryLoadOwnerToken();
|
|
1087
|
+
if (ownerToken) {
|
|
1088
|
+
const config = await state.loadConfig();
|
|
1089
|
+
client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
|
|
1090
|
+
} else {
|
|
1091
|
+
client = await configuredClient();
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
output(await client.limits());
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (command === 'budget') {
|
|
1098
|
+
const action = args.shift();
|
|
1099
|
+
if (action === 'show') {
|
|
1100
|
+
output((await loadBudget(state.root)) ?? { help: 'on', contacts: [] });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (action === 'set') {
|
|
1104
|
+
const help = option('help');
|
|
1105
|
+
if (help !== undefined && !['on', 'contacts', 'off'].includes(help)) {
|
|
1106
|
+
throw new Error('--help must be one of: on, contacts, off');
|
|
1107
|
+
}
|
|
1108
|
+
const contactsRaw = option('contacts');
|
|
1109
|
+
const contacts = contactsRaw !== undefined ? await parseJsonOrList(contactsRaw) : undefined;
|
|
1110
|
+
const messagesPerHourRaw = option('messages-per-hour');
|
|
1111
|
+
const sessionMinutesRaw = option('session-minutes');
|
|
1112
|
+
const patch = {
|
|
1113
|
+
...(help !== undefined ? { help } : {}),
|
|
1114
|
+
...(contacts !== undefined ? { contacts } : {}),
|
|
1115
|
+
...(messagesPerHourRaw !== undefined ? { messages_per_hour: Number(messagesPerHourRaw) } : {}),
|
|
1116
|
+
...(sessionMinutesRaw !== undefined ? { session_minutes: Number(sessionMinutesRaw) } : {})
|
|
1117
|
+
};
|
|
1118
|
+
output(await saveBudget(state.root, patch));
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
throw new Error('budget actions: show | set [--help on|contacts|off] [--contacts a,b] [--messages-per-hour N] [--session-minutes N]');
|
|
1122
|
+
}
|
|
1123
|
+
if (command === 'task') {
|
|
1124
|
+
const action = args.shift();
|
|
1125
|
+
if (action === 'decline') {
|
|
1126
|
+
const taskId = args.shift();
|
|
1127
|
+
if (!taskId) throw new Error('task decline requires <taskId>');
|
|
1128
|
+
const reason = option('reason');
|
|
1129
|
+
if (!reason) throw new Error('--reason is required');
|
|
1130
|
+
const explicitKey = option('idempotency-key');
|
|
1131
|
+
const { client } = await activeClient(option('caller-id'));
|
|
1132
|
+
output(await mutation(client, 'PATCH', `/v1/tasks/${encodeURIComponent(taskId)}`, { status: 'cancelled', result: reason }, explicitKey));
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
throw new Error('task actions: decline <taskId> --reason TEXT');
|
|
1136
|
+
}
|
|
1137
|
+
process.stdout.write('Usage: olimpyx init|status|skill|configure|owner-login|enroll|session|request|bootstrap|rooms|inbox|knowledge|message|wait|listen|persona|influence|memory|threads|read|incidents|appeal|report|forum|subscribe|recommendations|agent|usage|limits|budget|task\n');
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
main().catch((error) => { process.stderr.write(`${error.code ?? error.name ?? 'Error'}: ${error.message}\n`); process.exitCode = 1; });
|