@goodea/olimpyx 0.1.0 → 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.
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/data/skill/archi-citizen.md +39 -0
- package/data/skill/archi-decide.md +28 -0
- package/data/skill/playbook.md +2 -0
- package/data/skill/starter.md +1 -0
- package/package.json +13 -2
- package/src/budget.js +13 -4
- package/src/characters.js +10 -0
- package/src/cli.js +119 -38
- package/src/i18n.js +250 -0
- package/src/init-apply.js +39 -24
- package/src/init.js +48 -47
- package/src/resident/cli.mjs +101 -0
- package/src/resident/olimpyx-resident.mjs +158 -0
- package/src/resident/resident-decision.mjs +78 -0
- package/src/resident/resident-runtime.mjs +211 -0
- package/src/resident/resident-store.mjs +157 -0
- package/src/state.js +41 -15
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rmdir, unlink } from 'node:fs/promises';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const noFollow = constants.O_NOFOLLOW;
|
|
8
|
+
const missing = (error) => error.code === 'ENOENT';
|
|
9
|
+
|
|
10
|
+
/** Private permanent storage. Filenames are fixed, never selected by model input.
|
|
11
|
+
* Callers serialize read/modify/write operations with withLock().
|
|
12
|
+
*/
|
|
13
|
+
export class ResidentStore {
|
|
14
|
+
constructor(home, { now = Date.now } = {}) {
|
|
15
|
+
this.root = join(home, 'resident');
|
|
16
|
+
this.now = now;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async prepare() {
|
|
20
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
21
|
+
if (!(await lstat(this.root)).isDirectory()) throw new Error('Resident storage must be a real directory');
|
|
22
|
+
await chmod(this.root, 0o700);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async syncDirectory() {
|
|
26
|
+
const directory = await open(this.root, constants.O_RDONLY | noFollow);
|
|
27
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async read() {
|
|
31
|
+
let file;
|
|
32
|
+
try {
|
|
33
|
+
file = await open(join(this.root, 'state.json'), constants.O_RDONLY | noFollow);
|
|
34
|
+
return JSON.parse(await file.readFile('utf8'));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (missing(error)) return null;
|
|
37
|
+
throw error;
|
|
38
|
+
} finally { await file?.close(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async save(state) {
|
|
42
|
+
const content = JSON.stringify(state);
|
|
43
|
+
if (content === undefined) throw new Error('State must be JSON serializable');
|
|
44
|
+
await this.prepare();
|
|
45
|
+
const temporary = join(this.root, `.state-${randomUUID()}.tmp`);
|
|
46
|
+
let file;
|
|
47
|
+
try {
|
|
48
|
+
file = await open(temporary, 'wx', 0o600);
|
|
49
|
+
await file.writeFile(`${content}\n`);
|
|
50
|
+
await file.sync();
|
|
51
|
+
await file.close();
|
|
52
|
+
file = null;
|
|
53
|
+
await rename(temporary, join(this.root, 'state.json'));
|
|
54
|
+
await this.syncDirectory();
|
|
55
|
+
} finally {
|
|
56
|
+
await file?.close();
|
|
57
|
+
await unlink(temporary).catch((error) => { if (!missing(error)) throw error; });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async append(record) {
|
|
62
|
+
const content = JSON.stringify(record);
|
|
63
|
+
if (content === undefined) throw new Error('Journal record must be JSON serializable');
|
|
64
|
+
await this.prepare();
|
|
65
|
+
const file = await open(join(this.root, 'memory.jsonl'), constants.O_CREAT | constants.O_RDWR | noFollow, 0o600);
|
|
66
|
+
try {
|
|
67
|
+
await file.chmod(0o600);
|
|
68
|
+
let end = (await file.stat()).size;
|
|
69
|
+
// A newline commits a record. Remove only a torn final record before appending.
|
|
70
|
+
const buffer = Buffer.alloc(8192);
|
|
71
|
+
let position = end;
|
|
72
|
+
while (position > 0) {
|
|
73
|
+
const start = Math.max(0, position - buffer.length);
|
|
74
|
+
const { bytesRead } = await file.read(buffer, 0, position - start, start);
|
|
75
|
+
const newline = buffer.subarray(0, bytesRead).lastIndexOf(10);
|
|
76
|
+
if (newline >= 0) { end = start + newline + 1; break; }
|
|
77
|
+
position = start;
|
|
78
|
+
end = start;
|
|
79
|
+
}
|
|
80
|
+
await file.truncate(end);
|
|
81
|
+
const bytes = Buffer.from(`${content}\n`);
|
|
82
|
+
let written = 0;
|
|
83
|
+
while (written < bytes.length) {
|
|
84
|
+
const result = await file.write(bytes, written, bytes.length - written, end + written);
|
|
85
|
+
if (!result.bytesWritten) throw new Error('Journal write made no progress');
|
|
86
|
+
written += result.bytesWritten;
|
|
87
|
+
}
|
|
88
|
+
await file.sync();
|
|
89
|
+
} finally { await file.close(); }
|
|
90
|
+
await this.syncDirectory();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async readRecent(limit = 5, maxChars = 6000) {
|
|
94
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || !Number.isSafeInteger(maxChars) || maxChars < 0 || maxChars > 1_000_000) {
|
|
95
|
+
throw new Error('Invalid recent-memory bounds');
|
|
96
|
+
}
|
|
97
|
+
if (!limit || !maxChars) return [];
|
|
98
|
+
let file;
|
|
99
|
+
try {
|
|
100
|
+
file = await open(join(this.root, 'memory.jsonl'), constants.O_RDONLY | noFollow);
|
|
101
|
+
const size = (await file.stat()).size;
|
|
102
|
+
const start = Math.max(0, size - (maxChars * 4 + 8));
|
|
103
|
+
const buffer = Buffer.alloc(size - start);
|
|
104
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
|
|
105
|
+
let text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
106
|
+
if (start) text = text.slice(text.indexOf('\n') + 1);
|
|
107
|
+
const lines = text.split('\n');
|
|
108
|
+
lines.pop(); // Ignore an incomplete final record, including a partial JSON token.
|
|
109
|
+
const records = [];
|
|
110
|
+
let used = 0;
|
|
111
|
+
for (let index = lines.length - 1; index >= 0 && records.length < limit; index -= 1) {
|
|
112
|
+
const line = lines[index];
|
|
113
|
+
if (used + line.length + 1 > maxChars) break;
|
|
114
|
+
records.unshift(JSON.parse(line));
|
|
115
|
+
used += line.length + 1;
|
|
116
|
+
}
|
|
117
|
+
return records;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (missing(error)) return [];
|
|
120
|
+
throw error;
|
|
121
|
+
} finally { await file?.close(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async withLock(callback) {
|
|
125
|
+
await this.prepare();
|
|
126
|
+
const guard = join(this.root, '.lock-acquisition');
|
|
127
|
+
const path = join(this.root, 'lock.json');
|
|
128
|
+
const owner = { token: randomUUID(), pid: process.pid, hostname: hostname(), createdAt: this.now() };
|
|
129
|
+
try { await mkdir(guard, { mode: 0o700 }); } catch (error) {
|
|
130
|
+
if (error.code === 'EEXIST') throw new Error('Resident lock acquisition is busy; stale acquisition guards require manual inspection');
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
let existing;
|
|
135
|
+
try { existing = JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (!missing(error)) throw error; }
|
|
136
|
+
if (existing) {
|
|
137
|
+
if (existing.hostname !== hostname() || !Number.isSafeInteger(existing.pid) || existing.pid <= 0 || typeof existing.token !== 'string') {
|
|
138
|
+
throw new Error('Resident lock owner cannot be safely verified');
|
|
139
|
+
}
|
|
140
|
+
let dead = false;
|
|
141
|
+
try { process.kill(existing.pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
|
|
142
|
+
if (!dead) throw new Error('Resident operation is already active');
|
|
143
|
+
await unlink(path);
|
|
144
|
+
}
|
|
145
|
+
const lock = await open(path, 'wx', 0o600);
|
|
146
|
+
try { await lock.writeFile(JSON.stringify(owner)); await lock.sync(); } finally { await lock.close(); }
|
|
147
|
+
await this.syncDirectory();
|
|
148
|
+
} finally { await rmdir(guard); }
|
|
149
|
+
try { return await callback(); } finally {
|
|
150
|
+
const current = JSON.parse(await readFile(path, 'utf8'));
|
|
151
|
+
if (current.token === owner.token) {
|
|
152
|
+
await unlink(path);
|
|
153
|
+
await this.syncDirectory();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/state.js
CHANGED
|
@@ -13,12 +13,32 @@ async function atomicJson(path, value, mode = 0o600) {
|
|
|
13
13
|
await rename(temp, path);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Sanitise caller-id for use as a directory segment. A caller-id is generated
|
|
17
|
+
// by the participant as e.g. `caller-helios-<pid>-<unix>`, so it normally
|
|
18
|
+
// contains only [a-z0-9-]. We still strip anything else defensively so a
|
|
19
|
+
// hostile caller-id cannot escape the per-caller state directory.
|
|
20
|
+
function safeCallerId(callerId) {
|
|
21
|
+
if (typeof callerId !== 'string' || !callerId.trim()) return null;
|
|
22
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(callerId)) return null;
|
|
23
|
+
return callerId;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export class LocalState {
|
|
17
27
|
constructor(root) { this.root = root; }
|
|
18
28
|
async init() {
|
|
19
29
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
20
30
|
await mkdir(join(this.root, 'persona-revisions'), { recursive: true, mode: 0o700 });
|
|
21
31
|
}
|
|
32
|
+
// Per-caller session directory (F-04): when several participants share one
|
|
33
|
+
// OLIMPYX_HOME, each caller's session.json / session-credential / pending
|
|
34
|
+
// mutations live under their own subdir so they no longer clobber each other.
|
|
35
|
+
async callerDir(callerId) {
|
|
36
|
+
const id = safeCallerId(callerId);
|
|
37
|
+
if (!id) return this.root;
|
|
38
|
+
const dir = join(this.root, 'calls', id);
|
|
39
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
22
42
|
async loadConfig() { return readJson(join(this.root, 'config.json'), {}); }
|
|
23
43
|
async saveConfig(config) { await this.init(); await atomicJson(join(this.root, 'config.json'), config); return config; }
|
|
24
44
|
async loadCredential() { return (await readFile(join(this.root, 'credential'), 'utf8')).trim(); }
|
|
@@ -34,46 +54,52 @@ export class LocalState {
|
|
|
34
54
|
await this.init(); const path = join(this.root, 'owner-credential');
|
|
35
55
|
await writeFile(path, token, { mode: 0o600 }); await chmod(path, 0o600);
|
|
36
56
|
}
|
|
37
|
-
async loadSession() {
|
|
38
|
-
const
|
|
57
|
+
async loadSession(callerId) {
|
|
58
|
+
const dir = await this.callerDir(callerId);
|
|
59
|
+
const metadata = await readJson(join(dir, 'session.json'), null);
|
|
39
60
|
if (!metadata) return null;
|
|
40
|
-
return { ...metadata, token: (await readFile(join(
|
|
61
|
+
return { ...metadata, token: (await readFile(join(dir, 'session-credential'), 'utf8')).trim() };
|
|
41
62
|
}
|
|
42
63
|
async saveSession(session, callerId, callerLeaseMs = 75_000) {
|
|
43
64
|
if (!session?.session_id || !session?.session_token || !callerId) throw new Error('Complete session and callerId are required');
|
|
44
|
-
await this.
|
|
45
|
-
const credentialPath = join(
|
|
65
|
+
const dir = await this.callerDir(callerId);
|
|
66
|
+
const credentialPath = join(dir, 'session-credential');
|
|
46
67
|
await writeFile(credentialPath, session.session_token, { mode: 0o600 }); await chmod(credentialPath, 0o600);
|
|
47
68
|
const metadata = { session_id: session.session_id, expires_at: session.expires_at, caller_id: callerId, caller_deadline: new Date(Date.now() + Math.min(callerLeaseMs, 85_000)).toISOString(), inbox_cursor: session.inbox_cursor ?? null };
|
|
48
|
-
await atomicJson(join(
|
|
69
|
+
await atomicJson(join(dir, 'session.json'), metadata); return metadata;
|
|
49
70
|
}
|
|
50
71
|
async renewSession(callerId, updates = {}, callerLeaseMs = 75_000) {
|
|
51
|
-
const session = await this.loadSession();
|
|
72
|
+
const session = await this.loadSession(callerId);
|
|
52
73
|
if (!session) throw new Error('No local session. Run session begin first.');
|
|
53
74
|
if (session.caller_id !== callerId) throw new Error('callerId does not own this participant session');
|
|
54
75
|
if (Date.parse(session.caller_deadline) <= Date.now()) throw new Error('Local caller lease expired; begin a new session');
|
|
55
76
|
const { token: _token, ...metadata } = session;
|
|
56
77
|
const next = { ...metadata, ...updates, caller_deadline: new Date(Date.now() + Math.min(callerLeaseMs, 85_000)).toISOString() };
|
|
57
|
-
await
|
|
78
|
+
const dir = await this.callerDir(callerId);
|
|
79
|
+
await atomicJson(join(dir, 'session.json'), next); return next;
|
|
80
|
+
}
|
|
81
|
+
async clearSession(callerId) {
|
|
82
|
+
const dir = await this.callerDir(callerId);
|
|
83
|
+
await Promise.all([rm(join(dir, 'session.json'), { force: true }), rm(join(dir, 'session-credential'), { force: true })]);
|
|
58
84
|
}
|
|
59
|
-
async
|
|
60
|
-
async beginMutation(method, path, body, explicitKey) {
|
|
85
|
+
async beginMutation(method, path, body, explicitKey, callerId) {
|
|
61
86
|
if (explicitKey !== undefined) {
|
|
62
87
|
if (typeof explicitKey !== 'string' || !explicitKey.trim()) throw new Error('--idempotency-key must be a non-empty value');
|
|
63
88
|
return { fingerprint: null, key: explicitKey };
|
|
64
89
|
}
|
|
65
|
-
await this.
|
|
90
|
+
const dir = await this.callerDir(callerId);
|
|
66
91
|
const fingerprint = createHash('sha256').update(JSON.stringify({ method: method.toUpperCase(), path, body: body ?? null })).digest('hex');
|
|
67
|
-
const pending = await readJson(join(
|
|
92
|
+
const pending = await readJson(join(dir, 'pending-mutations.json'), {});
|
|
68
93
|
if (!pending[fingerprint]) {
|
|
69
94
|
pending[fingerprint] = { key: randomUUID(), method: method.toUpperCase(), path, created_at: new Date().toISOString() };
|
|
70
|
-
await atomicJson(join(
|
|
95
|
+
await atomicJson(join(dir, 'pending-mutations.json'), pending);
|
|
71
96
|
}
|
|
72
97
|
return { fingerprint, key: pending[fingerprint].key };
|
|
73
98
|
}
|
|
74
|
-
async completeMutation(fingerprint) {
|
|
99
|
+
async completeMutation(fingerprint, callerId) {
|
|
75
100
|
if (!fingerprint) return;
|
|
76
|
-
const
|
|
101
|
+
const dir = await this.callerDir(callerId);
|
|
102
|
+
const path = join(dir, 'pending-mutations.json');
|
|
77
103
|
const pending = await readJson(path, {});
|
|
78
104
|
delete pending[fingerprint];
|
|
79
105
|
if (Object.keys(pending).length === 0) await rm(path, { force: true });
|