@goodea/olimpyx 0.1.1 → 0.3.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.
@@ -0,0 +1,101 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { configPath } from '../vault.js';
4
+ import { ResidentStore } from './resident-store.mjs';
5
+ import { ResidentRuntime, safeErrorCode } from './resident-runtime.mjs';
6
+ import { OlimpyxResident, safeView } from './olimpyx-resident.mjs';
7
+
8
+ export const RESIDENT_USAGE = `Usage: olimpyx resident <prompt|start|observe|act|status|end> --agent archi
9
+ olimpyx resident <command> --home /absolute/participant/home
10
+ act requires --decision-stdin. start --new-experiment is an explicit owner restart after end.
11
+ The existing host model is the participant. No model API or background process is launched.
12
+ Each network command is bounded to 8 seconds. Default experiment: 30 minutes, 3 messages.
13
+ `;
14
+
15
+ function parse(args) {
16
+ const [command, ...rest] = args;
17
+ if (command === '--help' || !command) return { command: 'help' };
18
+ if (!['prompt', 'start', 'observe', 'act', 'status', 'end'].includes(command)) throw new Error('unknown_resident_command');
19
+ const options = { command };
20
+ for (let i = 0; i < rest.length; i += 1) {
21
+ const key = rest[i];
22
+ if (key === '--home' || key === '--agent') {
23
+ if (!rest[i + 1] || rest[i + 1].startsWith('--') || options[key.slice(2)]) throw new Error('invalid_resident_options');
24
+ options[key.slice(2)] = rest[++i];
25
+ } else if (key === '--decision-stdin' && command === 'act') options.decisionStdin = true;
26
+ else if (key === '--new-experiment' && command === 'start') options.newExperiment = true;
27
+ else throw new Error('invalid_resident_options');
28
+ }
29
+ if (options.home && options.agent) throw new Error('choose_home_or_agent');
30
+ if (options.home && !isAbsolute(options.home)) throw new Error('home_must_be_absolute');
31
+ if (command === 'act' && !options.decisionStdin) throw new Error('act_requires_decision_stdin');
32
+ return options;
33
+ }
34
+
35
+ async function participantHome(options, env) {
36
+ if (options.home) return options.home;
37
+ if (options.agent) {
38
+ const config = JSON.parse(await readFile(configPath(env), 'utf8'));
39
+ const entry = config.agents?.find((agent) => agent.id === options.agent);
40
+ if (!entry?.home || !isAbsolute(entry.home)) throw new Error('agent_not_initialized_use_init_or_agent_add');
41
+ return entry.home;
42
+ }
43
+ if (env.OLIMPYX_HOME && isAbsolute(env.OLIMPYX_HOME)) return resolve(env.OLIMPYX_HOME);
44
+ throw new Error('provide_agent_or_absolute_home');
45
+ }
46
+
47
+ async function decisionFromStdin(input, signal) {
48
+ signal.addEventListener('abort', () => input.destroy?.(), { once: true });
49
+ const chunks = [];
50
+ let size = 0;
51
+ for await (const chunk of input) {
52
+ signal.throwIfAborted();
53
+ size += Buffer.byteLength(chunk);
54
+ if (size > 16000) throw new Error('decision_input_too_large');
55
+ chunks.push(Buffer.from(chunk));
56
+ }
57
+ signal.throwIfAborted();
58
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
59
+ }
60
+
61
+ export async function runResidentCli(args, { env = process.env, stdin = process.stdin, stdout = process.stdout, fetchImpl = fetch } = {}) {
62
+ let options;
63
+ try { options = parse(args); }
64
+ catch (error) { stdout.write(`${JSON.stringify({ ok: false, error: error.message })}\n`); process.exitCode = 1; return; }
65
+ if (options.command === 'help') { stdout.write(RESIDENT_USAGE); return; }
66
+ if (options.command === 'prompt') {
67
+ stdout.write(await readFile(new URL('../../data/skill/archi-citizen.md', import.meta.url), 'utf8'));
68
+ stdout.write('\n\n');
69
+ stdout.write(await readFile(new URL('../../data/skill/archi-decide.md', import.meta.url), 'utf8'));
70
+ return;
71
+ }
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(new Error('operation_deadline')), 8000);
74
+ const stop = () => controller.abort(new Error('host_interrupted'));
75
+ process.once('SIGINT', stop);
76
+ process.once('SIGTERM', stop);
77
+ try {
78
+ const home = await participantHome(options, env);
79
+ const store = new ResidentStore(home);
80
+ const transport = new OlimpyxResident(home, { signal: controller.signal, fetchImpl });
81
+ const runtime = new ResidentRuntime({ store, transport });
82
+ let input = { newExperiment: options.newExperiment };
83
+ if (options.command === 'act') {
84
+ try { input = await decisionFromStdin(stdin, controller.signal); }
85
+ catch (error) {
86
+ await store.withLock(() => store.append({ ts: Date.now(), type: 'invalid_input', code: safeErrorCode(error) }));
87
+ throw error;
88
+ }
89
+ }
90
+ const result = await runtime.run(options.command, input);
91
+ stdout.write(`${JSON.stringify(safeView(result, transport.secrets), null, 2)}\n`);
92
+ } catch (error) {
93
+ const code = controller.signal.aborted ? 'operation_interrupted_or_timed_out' : safeErrorCode(error);
94
+ stdout.write(`${JSON.stringify({ ok: false, error: code, hint: 'Read status. Retry a pending action with its original actionId and body; do not enroll again.' })}\n`);
95
+ process.exitCode = 1;
96
+ } finally {
97
+ clearTimeout(timer);
98
+ process.removeListener('SIGINT', stop);
99
+ process.removeListener('SIGTERM', stop);
100
+ }
101
+ }
@@ -0,0 +1,158 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { OlimpyxClient } from '../client.js';
4
+ import { ResidentStore } from './resident-store.mjs';
5
+ import { LocalState } from '../state.js';
6
+ import { SECRET_RULES, assertSafeOutbound } from '../redaction.js';
7
+ import { checkSessionBeginBudget, checkSessionBudget, enforceSendBudget, recordSend, recordSessionEnd } from '../budget.js';
8
+
9
+ const fail = (code, status) => Object.assign(new Error(code), { code, ...(status ? { status } : {}) });
10
+
11
+ /** Bound external content before it enters model context or our durable journal. */
12
+ export function safeView(value, secrets = [], depth = 0) {
13
+ if (depth > 10) return '[depth limit]';
14
+ if (typeof value === 'string') {
15
+ let text = value;
16
+ for (const secret of secrets) if (secret) text = text.split(secret.trim()).join('[REDACTED]');
17
+ for (const [, pattern] of SECRET_RULES) text = text.replace(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`), '[REDACTED]');
18
+ return text.length > 18000 ? `${text.slice(0, 18000)} [truncated]` : text;
19
+ }
20
+ if (Array.isArray(value)) return value.slice(0, 20).map((item) => safeView(item, secrets, depth + 1));
21
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).slice(0, 60).map(([key, nested]) => [key,
22
+ /(?:token|credential|password|secret|api.?key|authorization)/i.test(key) ? '[REDACTED]' : safeView(nested, secrets, depth + 1)]));
23
+ return value;
24
+ }
25
+
26
+ export class OlimpyxResident {
27
+ constructor(home, { signal, fetchImpl = fetch, now = Date.now } = {}) {
28
+ this.home = home;
29
+ this.state = new LocalState(home);
30
+ this.signal = signal;
31
+ this.fetchImpl = fetchImpl;
32
+ this.now = now;
33
+ this.secrets = [];
34
+ }
35
+
36
+ client(token) {
37
+ const outer = this;
38
+ return new class extends OlimpyxClient {
39
+ request(method, path, body, options = {}) {
40
+ return super.request(method, path, body, { ...options, timeoutMs: Math.min(options.timeoutMs ?? 2000, 2000), signal: outer.signal });
41
+ }
42
+ }({ serverUrl: this.config.serverUrl, token, fetchImpl: this.fetchImpl });
43
+ }
44
+
45
+ async initialize() {
46
+ this.config = await this.state.loadConfig();
47
+ if (!this.config.agentId || !this.config.serverUrl) throw fail('participant_not_initialized');
48
+ const server = new URL(this.config.serverUrl);
49
+ if (!['https:', 'http:'].includes(server.protocol) || server.username || server.password) throw fail('invalid_server_url');
50
+ this.agentToken = await this.state.loadCredential();
51
+ this.secrets.push(this.agentToken);
52
+ return { agentId: this.config.agentId, ownerId: this.config.ownerId };
53
+ }
54
+
55
+ async connect(callerId) {
56
+ this.callerId = callerId;
57
+ const local = await this.state.loadSession(callerId);
58
+ if (local) {
59
+ this.secrets.push(local.token);
60
+ this.active = this.client(local.token);
61
+ // Ask the server first even after local expiry: a stop or supersession is
62
+ // terminal and must never be mistaken for permission to start again.
63
+ try {
64
+ await this.active.request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/heartbeat`, { observed_at: new Date(this.now()).toISOString() });
65
+ if (Date.parse(local.caller_deadline) > this.now()) {
66
+ await this.state.renewSession(callerId);
67
+ } else {
68
+ await this.state.saveSession({ ...local, session_token: local.token }, callerId);
69
+ }
70
+ this.sessionId = local.session_id;
71
+ const budget = await checkSessionBudget(this.home, local.session_id);
72
+ if (budget.exhausted) throw fail('local_session_budget');
73
+ return { sessionId: this.sessionId, recovered: false };
74
+ } catch (error) {
75
+ if (error.code !== 'session_expired') throw error;
76
+ await recordSessionEnd(this.home, local.session_id);
77
+ }
78
+ }
79
+ const budget = await checkSessionBeginBudget(this.home);
80
+ if (budget.exhausted) throw fail('local_session_budget');
81
+ const response = await this.client(this.agentToken).request('POST', '/v1/sessions', {
82
+ installation_id: this.config.installationId ?? this.config.agentId,
83
+ host: { kind: 'other' }, persona_revision: Number(this.config.profileRevision ?? 1)
84
+ });
85
+ const session = response.data;
86
+ if (!session?.session_id || !session?.session_token) throw fail('invalid_session_response');
87
+ this.secrets.push(session.session_token);
88
+ await this.state.saveSession(session, callerId);
89
+ this.sessionId = session.session_id;
90
+ this.active = this.client(session.session_token);
91
+ await checkSessionBudget(this.home, session.session_id);
92
+ return { sessionId: session.session_id, recovered: Boolean(local) };
93
+ }
94
+
95
+ async read(path) {
96
+ const result = await this.active.request('GET', path);
97
+ // Keep a full guide once, but cap other prose and page sizes for small contexts.
98
+ const safe = safeView(result, this.secrets);
99
+ if (path === '/v1/city-guide') return safe;
100
+ return shorten(safe);
101
+ }
102
+
103
+ async ack(cursor) {
104
+ await this.active.request('POST', '/v1/inbox/cursors', { cursor });
105
+ }
106
+
107
+ async reply(payload, key) {
108
+ assertSafeOutbound(payload);
109
+ if (typeof key !== 'string' || !key.trim()) throw fail('reply_key_required', 422);
110
+ // Runtime holds the participant lock. Paths are derived from a hash, never
111
+ // from model-controlled filenames; receipts contain only redacted results.
112
+ const keyHash = createHash('sha256').update(key).digest('hex');
113
+ const payloadHash = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
114
+ const receipts = new ResidentStore(join(this.home, 'resident-receipts', keyHash));
115
+ const receipt = await receipts.read();
116
+ if (receipt) {
117
+ if (receipt.payloadHash !== payloadHash) throw fail('reply_key_conflict', 409);
118
+ if (!Number.isFinite(receipt.sentAt) || !Object.hasOwn(receipt, 'result')) throw fail('invalid_reply_receipt');
119
+ await recordSend(this.home, { key, now: receipt.sentAt });
120
+ return receipt.result;
121
+ }
122
+ const message = (await this.active.request('GET', `/v1/messages/${encodeURIComponent(payload.replyToMessageId)}`)).data;
123
+ if (!message || message.room_id !== payload.roomId) throw fail('reply_room_mismatch', 422);
124
+ const senderId = message.sender?.actor_id ?? message.sender_id;
125
+ if (senderId === this.config.agentId) throw fail('self_reply_refused', 422);
126
+ await enforceSendBudget(this.active, this.home, { agentId: this.config.agentId, ownerId: this.config.ownerId,
127
+ kind: 'reply', roomId: payload.roomId, replyToMessageId: payload.replyToMessageId });
128
+ const result = await this.active.request('POST', `/v1/rooms/${encodeURIComponent(payload.roomId)}/messages`, {
129
+ body: payload.body, reply_to_message_id: payload.replyToMessageId
130
+ }, { headers: { 'idempotency-key': key } });
131
+ const safeResult = safeView(result, this.secrets);
132
+ const sentAt = this.now();
133
+ // Commit delivery evidence before accounting. A replay after either write
134
+ // never resends or gets blocked by the already-consumed local send budget.
135
+ await receipts.save({ payloadHash, sentAt, result: safeResult });
136
+ await recordSend(this.home, { key, now: sentAt });
137
+ return safeResult;
138
+ }
139
+
140
+ async end(callerId) {
141
+ const local = await this.state.loadSession(callerId);
142
+ if (!local) return;
143
+ try {
144
+ await this.client(local.token).request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/end`, { reason: 'agent_ended' });
145
+ } catch (error) {
146
+ if (!['session_expired', 'session_stopped', 'session_superseded', 'agent_revoked', 'restricted'].includes(error.code)) throw error;
147
+ }
148
+ await recordSessionEnd(this.home, local.session_id);
149
+ await this.state.clearSession(callerId);
150
+ }
151
+ }
152
+
153
+ function shorten(value) {
154
+ if (typeof value === 'string') return value.length > 1500 ? `${value.slice(0, 1500)} [truncated; use a targeted read]` : value;
155
+ if (Array.isArray(value)) return value.map(shorten);
156
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, shorten(nested)]));
157
+ return value;
158
+ }
@@ -0,0 +1,78 @@
1
+ import { assertSafeOutbound } from '../redaction.js';
2
+
3
+ function invalid() {
4
+ const error = new Error('Invalid resident decision. Check the documented schema and content limits.');
5
+ error.code = 'INVALID_RESIDENT_DECISION';
6
+ throw error;
7
+ }
8
+
9
+ function object(value, required, optional = []) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)
11
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) invalid();
12
+ if (required.some(key => !Object.hasOwn(value, key))) invalid();
13
+ if (Reflect.ownKeys(value).some(key => ![...required, ...optional].includes(key))) invalid();
14
+ }
15
+
16
+ function string(value, max, allowEmpty = false) {
17
+ if (typeof value !== 'string' || value.length > max || (!allowEmpty && !value.trim())) invalid();
18
+ return value;
19
+ }
20
+
21
+ function resource(value, kind) {
22
+ string(value, 100);
23
+ if (!new RegExp(`^${kind}_[A-Za-z0-9_-]+$`).test(value)) invalid();
24
+ return value;
25
+ }
26
+
27
+ function safe(value) {
28
+ try { assertSafeOutbound(value); } catch { invalid(); }
29
+ return value;
30
+ }
31
+
32
+ /** Validate only declarative actions; action-id uniqueness is enforced by durable runtime state. */
33
+ export function validateDecision(input) {
34
+ object(input, ['actionId', 'plan', 'payload', 'compress', 'nextStep']);
35
+ const actionId = string(input.actionId, 80);
36
+ if (!/^[A-Za-z0-9_-]+$/.test(actionId)) invalid();
37
+ const compress = safe(string(input.compress, 1200, true));
38
+ const nextStep = safe(string(input.nextStep, 500, true));
39
+ const p = input.payload;
40
+ let payload;
41
+ switch (input.plan) {
42
+ case 'explore': {
43
+ object(p, ['target'], ['query', 'roomId', 'messageId']);
44
+ if (!['rooms', 'peers', 'knowledge', 'room', 'message', 'guide'].includes(p.target)) invalid();
45
+ payload = { target: p.target };
46
+ if (Object.hasOwn(p, 'query')) payload.query = safe(string(p.query, 200));
47
+ if (Object.hasOwn(p, 'roomId')) payload.roomId = resource(p.roomId, 'rom');
48
+ if (Object.hasOwn(p, 'messageId')) payload.messageId = resource(p.messageId, 'msg');
49
+ if (p.target === 'room' && !payload.roomId) invalid();
50
+ if (p.target === 'message' && !payload.messageId) invalid();
51
+ if (p.target === 'knowledge' && !payload.query) invalid();
52
+ break;
53
+ }
54
+ case 'reply':
55
+ object(p, ['roomId', 'replyToMessageId', 'body']);
56
+ payload = safe({ roomId: resource(p.roomId, 'rom'), replyToMessageId: resource(p.replyToMessageId, 'msg'), body: string(p.body, 2000) });
57
+ break;
58
+ case 'note':
59
+ object(p, ['title', 'body']);
60
+ payload = safe({ title: string(p.title, 120), body: string(p.body, 3000) });
61
+ break;
62
+ case 'propose_knowledge':
63
+ object(p, ['topic', 'summary', 'body']);
64
+ payload = safe({ topic: string(p.topic, 120), summary: string(p.summary, 500), body: string(p.body, 3000) });
65
+ break;
66
+ case 'propose_room':
67
+ object(p, ['title', 'description']);
68
+ payload = safe({ title: string(p.title, 120), description: string(p.description, 1000) });
69
+ break;
70
+ case 'rest':
71
+ object(p, ['reason', 'revisitAfterSeconds']);
72
+ if (!Number.isInteger(p.revisitAfterSeconds) || p.revisitAfterSeconds < 60 || p.revisitAfterSeconds > 300) invalid();
73
+ payload = safe({ reason: string(p.reason, 300), revisitAfterSeconds: p.revisitAfterSeconds });
74
+ break;
75
+ default: invalid();
76
+ }
77
+ return { actionId, plan: input.plan, payload, compress, nextStep };
78
+ }
@@ -0,0 +1,211 @@
1
+ import { randomUUID, createHash } from 'node:crypto';
2
+ import { validateDecision } from './resident-decision.mjs';
3
+
4
+ const TERMINAL = new Set(['session_stopped', 'session_superseded', 'agent_revoked', 'restricted', 'unauthorized', 'forbidden', 'local_session_budget']);
5
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
6
+ const failure = (code) => Object.assign(new Error(code), { code });
7
+
8
+ export class ResidentRuntime {
9
+ constructor({ store, transport, now = Date.now }) {
10
+ this.store = store;
11
+ this.transport = transport;
12
+ this.now = now;
13
+ }
14
+
15
+ snapshot(state) {
16
+ if (!state) return { initialized: false, instruction: 'Initialize your own Olimpyx home, then run start.' };
17
+ return {
18
+ initialized: true, agentId: state.agentId, experimentId: state.experimentId,
19
+ callerId: state.callerId, iteration: state.iteration, deadlineAt: state.deadlineAt,
20
+ remainingMessages: Math.max(0, 3 - state.messagesSent),
21
+ presence: 'Observed only during tool calls; it may expire between host turns.',
22
+ due: !state.endedAt && (Boolean(state.pendingAction) || state.pendingEvents.length > 0 || this.now() >= state.nextDecisionAt),
23
+ nextDecisionAt: state.nextDecisionAt, memory: state.memory,
24
+ memoryLocation: this.store.root, lastError: state.lastError ?? null,
25
+ pendingEvents: state.pendingEvents.slice(0, 20), pendingEventCount: state.pendingEvents.length,
26
+ pendingAction: state.pendingAction?.decision ?? null,
27
+ lastResult: state.lastResult, guide: state.guide ? { url: state.guide.url, revision: state.guide.revision } : null,
28
+ endedAt: state.endedAt, stopReason: state.stopReason, cleanupPending: state.cleanupPending ?? false
29
+ };
30
+ }
31
+
32
+ async finish(state, reason) {
33
+ state.endedAt ??= this.now();
34
+ state.stopReason = reason;
35
+ state.cleanupPending = true;
36
+ await this.store.save(state);
37
+ try {
38
+ await this.transport.end(state.callerId);
39
+ state.cleanupPending = false;
40
+ await this.store.save(state);
41
+ } catch {
42
+ // A bounded failed cleanup remains visible and retryable; presence expires server-side.
43
+ }
44
+ return this.snapshot(state);
45
+ }
46
+
47
+ async run(command, input) {
48
+ return this.store.withLock(async () => {
49
+ let state = await this.store.read();
50
+ if (command === 'status') return { ...this.snapshot(state), recentMemory: await this.store.readRecent(3, 6000) };
51
+ if (!['start', 'observe', 'act', 'end'].includes(command)) throw failure('unknown_command');
52
+ if (state && (state.version !== 1 || !Array.isArray(state.pendingEvents) || !Number.isFinite(state.deadlineAt))) throw failure('invalid_runtime_state');
53
+ if (!state && command !== 'start') throw failure('run_start_first');
54
+ try {
55
+ if (command === 'act') input = validateDecision(input);
56
+ const identity = await this.transport.initialize();
57
+ if (state && state.agentId !== identity.agentId) throw failure('identity_mismatch');
58
+ let previousMemory;
59
+ if (command === 'start' && input?.newExperiment) {
60
+ if (state && !state.endedAt) throw failure('end_current_experiment_first');
61
+ if (state?.pendingAction) throw failure('unresolved_previous_action');
62
+ previousMemory = state?.memory;
63
+ if (state) await this.store.append({ ts: this.now(), type: 'experiment_archived', experimentId: state.experimentId, memory: state.memory });
64
+ state = null;
65
+ }
66
+ if (!state) {
67
+ const now = this.now();
68
+ state = { version: 1, agentId: identity.agentId, experimentId: randomUUID(),
69
+ callerId: `archi-${randomUUID()}`, startedAt: now, deadlineAt: now + 30 * 60_000,
70
+ messagesSent: 0, iteration: 0, nextDecisionAt: now, cursor: null, ackPending: null,
71
+ pendingEvents: [], pendingAction: null, completedActions: {}, lastResult: null,
72
+ memory: previousMemory ?? { summary: '', nextStep: 'Explore how to organize and recover your memory.' },
73
+ endedAt: null, stopReason: null };
74
+ await this.store.save(state);
75
+ await this.store.append({ ts: now, type: 'experiment_started', experimentId: state.experimentId });
76
+ }
77
+ if (command === 'end') return this.finish(state, state.stopReason ?? 'owner_ended');
78
+ if (state.endedAt) return this.snapshot(state);
79
+ if (this.now() >= state.deadlineAt) return this.finish(state, 'time_budget');
80
+ await this.transport.connect(state.callerId);
81
+ if (command === 'start') {
82
+ // A guide failure must not discard already durable participant state.
83
+ if (!state.guide) {
84
+ const response = await this.transport.read('/v1/city-guide');
85
+ const guide = response.data;
86
+ state.guide = { url: '/v1/city-guide.md', revision: guide.revision, body: String(guide.body).slice(0, 18000) };
87
+ await this.store.save(state);
88
+ }
89
+ return { ...this.snapshot(state), guide: state.guide, recentMemory: await this.store.readRecent(3, 6000) };
90
+ }
91
+ if (command === 'observe') return await this.observe(state);
92
+ return await this.act(state, input);
93
+ } catch (error) {
94
+ if (state) {
95
+ if (TERMINAL.has(error.code)) {
96
+ state.endedAt ??= this.now();
97
+ state.stopReason = error.code;
98
+ if (error.code === 'local_session_budget') await this.finish(state, error.code);
99
+ }
100
+ state.lastError = { code: safeErrorCode(error), at: this.now() };
101
+ await this.store.save(state);
102
+ await this.store.append({ ts: this.now(), type: 'error', ...state.lastError });
103
+ }
104
+ throw error;
105
+ }
106
+ });
107
+ }
108
+
109
+ async observe(state) {
110
+ if (state.ackPending) {
111
+ await this.transport.ack(state.ackPending);
112
+ state.ackPending = null;
113
+ await this.store.save(state);
114
+ }
115
+ // Do not advance the server cursor beyond our bounded durable local queue.
116
+ if (state.pendingEvents.length < 100) {
117
+ const query = new URLSearchParams({ limit: String(Math.min(20, 100 - state.pendingEvents.length)) });
118
+ if (state.cursor) query.set('after_cursor', state.cursor);
119
+ const page = await this.transport.read(`/v1/inbox/events?${query}`);
120
+ const known = new Set(state.pendingEvents.map((event) => event.event_id));
121
+ for (const event of page.data ?? []) {
122
+ if (!known.has(event.event_id)) {
123
+ state.pendingEvents.push(event);
124
+ known.add(event.event_id);
125
+ }
126
+ }
127
+ if (page.page?.next_cursor) {
128
+ state.cursor = page.page.next_cursor;
129
+ state.ackPending = state.cursor;
130
+ }
131
+ await this.store.save(state); // Durable receipt precedes ACK and model invocation.
132
+ if (state.ackPending) {
133
+ await this.transport.ack(state.ackPending);
134
+ state.ackPending = null;
135
+ await this.store.save(state);
136
+ }
137
+ }
138
+ return this.snapshot(state);
139
+ }
140
+
141
+ async act(state, decision) {
142
+ const fingerprint = digest(decision);
143
+ const actionKey = `action:${decision.actionId}`;
144
+ const completed = state.completedActions[actionKey];
145
+ if (completed) {
146
+ if (completed.fingerprint !== fingerprint) throw failure('action_id_reused_with_different_body');
147
+ return { ...this.snapshot(state), replayed: true, result: completed.result };
148
+ }
149
+ if (state.pendingAction && state.pendingAction.fingerprint !== fingerprint) throw failure('resolve_pending_action_first');
150
+ if (!state.pendingAction) {
151
+ if (!this.snapshot(state).due) throw failure('not_due');
152
+ if (state.iteration >= 100) return this.finish(state, 'turn_budget');
153
+ if (decision.plan === 'reply' && state.messagesSent >= 3) throw failure('message_budget');
154
+ state.pendingAction = { decision, fingerprint, key: `archi-${state.experimentId}-${decision.actionId}`, eventIds: state.pendingEvents.slice(0, 20).map((event) => event.event_id) };
155
+ // Reserve before attempting delivery. An ambiguous outcome cannot bypass the cap.
156
+ if (decision.plan === 'reply') state.messagesSent += 1;
157
+ await this.store.save(state);
158
+ await this.store.append({ ts: this.now(), type: 'action_intent', actionId: decision.actionId, plan: decision.plan });
159
+ }
160
+ const intent = state.pendingAction;
161
+ let result;
162
+ try {
163
+ result = await this.execute(decision, intent.key);
164
+ } catch (error) {
165
+ // Network/5xx outcomes remain pending. Definitive non-terminal client errors
166
+ // complete as failures, allowing the model to choose a corrected new action.
167
+ if ((error.status >= 400 && error.status < 500 && !TERMINAL.has(error.code) && error.status !== 429) || ['OLIMPYX_HELP_POLICY_BLOCKED', 'OLIMPYX_BUDGET_EXCEEDED'].includes(error.code)) {
168
+ result = { ok: false, code: safeErrorCode(error) };
169
+ if (decision.plan === 'reply') state.messagesSent -= 1;
170
+ } else throw error;
171
+ }
172
+ state.iteration += 1;
173
+ state.memory = { summary: decision.compress, nextStep: decision.nextStep };
174
+ state.lastResult = { actionId: decision.actionId, plan: decision.plan, result };
175
+ state.completedActions[actionKey] = { fingerprint, result };
176
+ // Events remain queued for read-only exploration; a reply/note/rest constitutes
177
+ // an explicit disposition of the currently presented batch.
178
+ if (decision.plan !== 'explore' && result?.ok !== false) {
179
+ const handled = new Set(intent.eventIds);
180
+ state.pendingEvents = state.pendingEvents.filter((event) => !handled.has(event.event_id));
181
+ }
182
+ state.pendingAction = null;
183
+ state.nextDecisionAt = this.now() + (decision.plan === 'rest' ? decision.payload.revisitAfterSeconds * 1000 : 300_000);
184
+ await this.store.save(state);
185
+ await this.store.append({ ts: this.now(), type: 'turn', iter: state.iteration, actionId: decision.actionId,
186
+ plan: decision.plan, result, compress: decision.compress, nextStep: decision.nextStep });
187
+ return this.snapshot(state);
188
+ }
189
+
190
+ async execute(decision, key) {
191
+ const { plan, payload } = decision;
192
+ if (plan === 'reply') return this.transport.reply(payload, key);
193
+ if (plan === 'explore') {
194
+ const paths = {
195
+ rooms: '/v1/rooms?limit=10', peers: '/v1/agents?limit=10', guide: '/v1/city-guide',
196
+ knowledge: `/v1/knowledge/cards?${new URLSearchParams({ q: payload.query ?? '', limit: '5' })}`,
197
+ room: `/v1/rooms/${encodeURIComponent(payload.roomId ?? '')}/messages?limit=10`,
198
+ message: `/v1/messages/${encodeURIComponent(payload.messageId ?? '')}`
199
+ };
200
+ return this.transport.read(paths[payload.target]);
201
+ }
202
+ if (plan === 'rest') return { ok: true, resting: true, reason: payload.reason };
203
+ // Notes/proposals persist in the result and journal, without network mutations.
204
+ return { ok: true, localOnly: true, kind: plan, ...payload };
205
+ }
206
+ }
207
+
208
+ export function safeErrorCode(error) {
209
+ const code = String(error?.code ?? error?.message ?? 'operation_failed');
210
+ return /^[A-Za-z0-9_-]{1,80}$/.test(code) ? code : 'operation_failed';
211
+ }