@pleri/olam-cli 0.1.173 → 0.1.174
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/dist/commands/auth.d.ts +22 -7
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +414 -46
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +45 -1
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/services.d.ts +39 -0
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +64 -9
- package/dist/commands/services.js.map +1 -1
- package/dist/from-manifest.d.ts +53 -0
- package/dist/from-manifest.d.ts.map +1 -0
- package/dist/from-manifest.js +95 -0
- package/dist/from-manifest.js.map +1 -0
- package/dist/image-digests.json +8 -8
- package/dist/index.js +907 -136
- package/dist/lib/auth-remote.d.ts +130 -0
- package/dist/lib/auth-remote.d.ts.map +1 -0
- package/dist/lib/auth-remote.js +307 -0
- package/dist/lib/auth-remote.js.map +1 -0
- package/dist/mcp-server.js +254 -57
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
- package/host-cp/src/boot-reconciler.mjs +238 -0
- package/host-cp/src/port-bridge-manager.mjs +116 -10
- package/host-cp/src/server.mjs +32 -0
- package/host-cp/src/world-activity-tracker.mjs +392 -0
- package/package.json +1 -1
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorldActivityTracker — periodic scanner that turns each active world's
|
|
3
|
+
* Claude session JSONL into `thought_count` + `total_cost_usd` updates on
|
|
4
|
+
* the `worlds` table (~/.olam/worlds.db), plus a `world.activity.tick`
|
|
5
|
+
* event on the host-stream broadcaster.
|
|
6
|
+
*
|
|
7
|
+
* Closes #965. Pre-fix, `olam_status <world>` always reported
|
|
8
|
+
* `Cost $0.0000 / Thoughts 0` because nothing wrote those columns after
|
|
9
|
+
* world creation. Rico (the orchestrator) reads those fields to decide
|
|
10
|
+
* whether a world is progressing or stalled, so as far as it was
|
|
11
|
+
* concerned every world was frozen.
|
|
12
|
+
*
|
|
13
|
+
* Design notes:
|
|
14
|
+
* - **JSONL path is operator-configurable.** Default contract per #965
|
|
15
|
+
* is `~/.olam/worlds/<id>/state/claude-main.jsonl`; override the
|
|
16
|
+
* template via `OLAM_WORLD_JSONL_PATH_TEMPLATE`. On this host the
|
|
17
|
+
* producer for the default path is not yet shipped (Claude Code
|
|
18
|
+
* writes to `~/.claude/projects/<sanitized>/<uuid>.jsonl` by
|
|
19
|
+
* default), so values stay at 0 until either the producer lands or
|
|
20
|
+
* the env override repoints the scanner.
|
|
21
|
+
* - **Dedupe by `message.id`.** Claude SDK JSONL emits multiple lines
|
|
22
|
+
* per assistant API turn (one per content block), each carrying the
|
|
23
|
+
* SAME `message.id` + the SAME `usage` block. Naive sum-by-line
|
|
24
|
+
* double-counts. We dedupe by `message.id` for usage totals and
|
|
25
|
+
* count unique-message-id as `thoughtCount`.
|
|
26
|
+
* - **Idempotent.** Re-scanning the same JSONL produces the same
|
|
27
|
+
* numbers; safe to run at any cadence.
|
|
28
|
+
* - **Fail-soft per world.** A bad JSONL line, missing file, or
|
|
29
|
+
* unreadable handle never crashes the loop — the failing world is
|
|
30
|
+
* skipped with a debug log and the next world proceeds.
|
|
31
|
+
*
|
|
32
|
+
* Cadence: `OLAM_WORLD_ACTIVITY_TICK_MS` (default 60_000).
|
|
33
|
+
*
|
|
34
|
+
* Wire-in: `server.mjs` constructs once with `{ db, broadcaster }` after
|
|
35
|
+
* both are ready and calls `.stop()` from the SIGTERM/SIGINT handler.
|
|
36
|
+
*
|
|
37
|
+
* @see ../host-stream.mjs broadcaster API
|
|
38
|
+
* @see ../worlds-db-source.mjs read-only DB open pattern (model for
|
|
39
|
+
* `tryOpenDb` here, though tracker WRITES not reads).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import os from 'node:os';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import readline from 'node:readline';
|
|
46
|
+
import { createRequire } from 'node:module';
|
|
47
|
+
|
|
48
|
+
const require = createRequire(import.meta.url);
|
|
49
|
+
|
|
50
|
+
// TODO(rates): source live model rates from auth-service or a config
|
|
51
|
+
// file. For now we anchor on Claude Opus per-million baseline ($3 input
|
|
52
|
+
// / $15 output) — the issue surface is "value advances post-creation",
|
|
53
|
+
// not "is dollar-accurate to 4 decimals". When per-model rates land,
|
|
54
|
+
// pluck the model id from the assistant message and dispatch.
|
|
55
|
+
const INPUT_USD_PER_M_TOKENS = 3.0;
|
|
56
|
+
const OUTPUT_USD_PER_M_TOKENS = 15.0;
|
|
57
|
+
|
|
58
|
+
const DEFAULT_TICK_MS = 60_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a per-world JSONL path from an operator-supplied template
|
|
62
|
+
* string. The template supports a single `{worldId}` placeholder, and a
|
|
63
|
+
* leading `~/` is expanded to `os.homedir()`.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} template
|
|
66
|
+
* @param {string} worldId
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function resolveJsonlPath(template, worldId) {
|
|
70
|
+
const swapped = template.replace(/\{worldId\}/g, worldId);
|
|
71
|
+
if (swapped.startsWith('~/')) {
|
|
72
|
+
return path.join(os.homedir(), swapped.slice(2));
|
|
73
|
+
}
|
|
74
|
+
return swapped;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Scan a single JSONL file and return aggregate counts.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} jsonlPath
|
|
81
|
+
* @returns {Promise<{thoughtCount:number, inputTokens:number, outputTokens:number, costUsd:number, lastActivityAt:string|null}>}
|
|
82
|
+
*/
|
|
83
|
+
export async function scanWorldJsonl(jsonlPath) {
|
|
84
|
+
const seenMessageIds = new Set();
|
|
85
|
+
let inputTokens = 0;
|
|
86
|
+
let outputTokens = 0;
|
|
87
|
+
let lastTimestamp = null;
|
|
88
|
+
|
|
89
|
+
let stream;
|
|
90
|
+
try {
|
|
91
|
+
stream = fs.createReadStream(jsonlPath, { encoding: 'utf8' });
|
|
92
|
+
} catch {
|
|
93
|
+
// ENOENT or permission error — return zeros.
|
|
94
|
+
return zeroStats();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// createReadStream defers ENOENT to the 'error' event; convert to a
|
|
98
|
+
// rejected promise so the caller's try/catch sees it uniformly.
|
|
99
|
+
const errorPromise = new Promise((_, reject) => {
|
|
100
|
+
stream.on('error', reject);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
104
|
+
|
|
105
|
+
const linesPromise = (async () => {
|
|
106
|
+
for await (const line of rl) {
|
|
107
|
+
if (!line) continue;
|
|
108
|
+
let row;
|
|
109
|
+
try {
|
|
110
|
+
row = JSON.parse(line);
|
|
111
|
+
} catch {
|
|
112
|
+
// Skip malformed lines silently — the JSONL has been observed
|
|
113
|
+
// to contain partial writes during active sessions.
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!row || row.type !== 'assistant') continue;
|
|
117
|
+
const msg = row.message;
|
|
118
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
119
|
+
|
|
120
|
+
const messageId = typeof msg.id === 'string' ? msg.id : null;
|
|
121
|
+
if (messageId === null) continue;
|
|
122
|
+
if (seenMessageIds.has(messageId)) continue;
|
|
123
|
+
seenMessageIds.add(messageId);
|
|
124
|
+
|
|
125
|
+
const usage = msg.usage;
|
|
126
|
+
if (usage && typeof usage === 'object') {
|
|
127
|
+
if (Number.isFinite(usage.input_tokens)) {
|
|
128
|
+
inputTokens += Number(usage.input_tokens);
|
|
129
|
+
}
|
|
130
|
+
if (Number.isFinite(usage.output_tokens)) {
|
|
131
|
+
outputTokens += Number(usage.output_tokens);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof row.timestamp === 'string') {
|
|
136
|
+
// Lexicographic comparison is correct on ISO-8601 with consistent zone.
|
|
137
|
+
if (lastTimestamp === null || row.timestamp > lastTimestamp) {
|
|
138
|
+
lastTimestamp = row.timestamp;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await Promise.race([linesPromise, errorPromise]);
|
|
146
|
+
} catch {
|
|
147
|
+
return zeroStats();
|
|
148
|
+
} finally {
|
|
149
|
+
try { stream.destroy(); } catch { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const costUsd =
|
|
153
|
+
(inputTokens / 1_000_000) * INPUT_USD_PER_M_TOKENS +
|
|
154
|
+
(outputTokens / 1_000_000) * OUTPUT_USD_PER_M_TOKENS;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
thoughtCount: seenMessageIds.size,
|
|
158
|
+
inputTokens,
|
|
159
|
+
outputTokens,
|
|
160
|
+
costUsd,
|
|
161
|
+
lastActivityAt: lastTimestamp,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function zeroStats() {
|
|
166
|
+
return {
|
|
167
|
+
thoughtCount: 0,
|
|
168
|
+
inputTokens: 0,
|
|
169
|
+
outputTokens: 0,
|
|
170
|
+
costUsd: 0,
|
|
171
|
+
lastActivityAt: null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @typedef {object} WorldActivityTrackerDeps
|
|
177
|
+
* @property {string} [dbPath] Path to worlds.db; defaults to
|
|
178
|
+
* `OLAM_WORLDS_DB` env var or `~/.olam/worlds.db`.
|
|
179
|
+
* @property {object} [broadcaster] Object with `.broadcast(type, payload)`
|
|
180
|
+
* (e.g. the return of `createHostStream`). Optional — when absent
|
|
181
|
+
* events are skipped but DB writes still happen.
|
|
182
|
+
* @property {number} [intervalMs] Tick cadence. Defaults to
|
|
183
|
+
* `OLAM_WORLD_ACTIVITY_TICK_MS` env or 60000.
|
|
184
|
+
* @property {string} [jsonlPathTemplate] JSONL path template.
|
|
185
|
+
* `{worldId}` is replaced per world. Defaults to
|
|
186
|
+
* `OLAM_WORLD_JSONL_PATH_TEMPLATE` env or
|
|
187
|
+
* `~/.olam/worlds/{worldId}/state/claude-main.jsonl`.
|
|
188
|
+
* @property {(msg: string) => void} [log] Defaults to `console.log`.
|
|
189
|
+
* @property {(msg: string) => void} [debug] Optional verbose log; defaults
|
|
190
|
+
* to no-op (debug-level skips on missing JSONL would be noisy).
|
|
191
|
+
* @property {(cb: () => void, ms: number) => any} [setTimer] Injectable
|
|
192
|
+
* `setInterval` for tests.
|
|
193
|
+
* @property {(handle: any) => void} [clearTimer] Injectable
|
|
194
|
+
* `clearInterval` for tests.
|
|
195
|
+
* @property {() => Date} [now] Clock injection for tests.
|
|
196
|
+
*/
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @typedef {object} WorldActivityTrackerHandle
|
|
200
|
+
* @property {() => void} stop
|
|
201
|
+
* @property {() => Promise<number>} tickNow Run one tick synchronously
|
|
202
|
+
* (returns the count of worlds processed). Exposed for tests.
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Start the world activity tracker. Returns a `{ stop, tickNow }`
|
|
207
|
+
* handle. Safe to call before the worlds.db file exists — the tracker
|
|
208
|
+
* skip-with-log until the file appears.
|
|
209
|
+
*
|
|
210
|
+
* @param {WorldActivityTrackerDeps} [deps]
|
|
211
|
+
* @returns {WorldActivityTrackerHandle}
|
|
212
|
+
*/
|
|
213
|
+
export function startWorldActivityTracker(deps = {}) {
|
|
214
|
+
const log = deps.log ?? ((m) => console.log(`[world-activity] ${m}`));
|
|
215
|
+
const debug = deps.debug ?? (() => {});
|
|
216
|
+
const setTimer = deps.setTimer ?? ((cb, ms) => setInterval(cb, ms));
|
|
217
|
+
const clearTimer = deps.clearTimer ?? ((h) => clearInterval(h));
|
|
218
|
+
const now = deps.now ?? (() => new Date());
|
|
219
|
+
|
|
220
|
+
const intervalMs =
|
|
221
|
+
deps.intervalMs ??
|
|
222
|
+
parseInt(process.env.OLAM_WORLD_ACTIVITY_TICK_MS ?? `${DEFAULT_TICK_MS}`, 10);
|
|
223
|
+
|
|
224
|
+
const dbPath =
|
|
225
|
+
deps.dbPath ??
|
|
226
|
+
process.env.OLAM_WORLDS_DB ??
|
|
227
|
+
path.join(os.homedir(), '.olam', 'worlds.db');
|
|
228
|
+
|
|
229
|
+
const jsonlPathTemplate =
|
|
230
|
+
deps.jsonlPathTemplate ??
|
|
231
|
+
process.env.OLAM_WORLD_JSONL_PATH_TEMPLATE ??
|
|
232
|
+
'~/.olam/worlds/{worldId}/state/claude-main.jsonl';
|
|
233
|
+
|
|
234
|
+
const broadcaster = deps.broadcaster ?? null;
|
|
235
|
+
|
|
236
|
+
let stopped = false;
|
|
237
|
+
let inFlight = false;
|
|
238
|
+
let intervalHandle = null;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* One tick: open DB, read active worlds, scan each JSONL, write back,
|
|
242
|
+
* emit event. Returns the count of worlds processed.
|
|
243
|
+
*
|
|
244
|
+
* @returns {Promise<number>}
|
|
245
|
+
*/
|
|
246
|
+
async function tick() {
|
|
247
|
+
if (stopped) return 0;
|
|
248
|
+
if (inFlight) {
|
|
249
|
+
// Skip overlap — slow filesystem must not pile up ticks.
|
|
250
|
+
debug('tick skipped: previous tick still in flight');
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
inFlight = true;
|
|
254
|
+
|
|
255
|
+
let db = null;
|
|
256
|
+
let processed = 0;
|
|
257
|
+
try {
|
|
258
|
+
let Database;
|
|
259
|
+
try {
|
|
260
|
+
Database = require('better-sqlite3');
|
|
261
|
+
} catch (err) {
|
|
262
|
+
// better-sqlite3 unavailable (e.g. container without native
|
|
263
|
+
// build) — degrade silently.
|
|
264
|
+
log(`better-sqlite3 unavailable; skipping tick: ${err.message}`);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
db = new Database(dbPath, { fileMustExist: true });
|
|
270
|
+
} catch (err) {
|
|
271
|
+
// SQLITE_CANTOPEN (file absent) is the expected first-boot
|
|
272
|
+
// case; everything else is worth surfacing.
|
|
273
|
+
if (err.code !== 'SQLITE_CANTOPEN') {
|
|
274
|
+
log(`open ${dbPath} failed: ${err.message}`);
|
|
275
|
+
} else {
|
|
276
|
+
debug(`${dbPath} not present yet; skipping tick`);
|
|
277
|
+
}
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let activeWorlds;
|
|
282
|
+
try {
|
|
283
|
+
activeWorlds = db
|
|
284
|
+
.prepare(
|
|
285
|
+
"SELECT id FROM worlds WHERE status NOT IN ('destroyed', 'failed')",
|
|
286
|
+
)
|
|
287
|
+
.all();
|
|
288
|
+
} catch (err) {
|
|
289
|
+
log(`query active worlds failed: ${err.message}`);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const updateStmt = db.prepare(
|
|
294
|
+
`UPDATE worlds
|
|
295
|
+
SET thought_count = ?,
|
|
296
|
+
total_cost_usd = ?,
|
|
297
|
+
updated_at = ?
|
|
298
|
+
WHERE id = ?`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
for (const row of activeWorlds) {
|
|
302
|
+
if (stopped) break;
|
|
303
|
+
const worldId = row.id;
|
|
304
|
+
if (typeof worldId !== 'string') continue;
|
|
305
|
+
const jsonlPath = resolveJsonlPath(jsonlPathTemplate, worldId);
|
|
306
|
+
|
|
307
|
+
let stats;
|
|
308
|
+
try {
|
|
309
|
+
stats = await scanWorldJsonl(jsonlPath);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
// Defence in depth — scanWorldJsonl is already fail-soft, but
|
|
312
|
+
// this catches anything unforeseen at the call seam.
|
|
313
|
+
debug(`scan ${worldId} failed: ${err.message}`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const updatedAt = now().toISOString();
|
|
318
|
+
try {
|
|
319
|
+
updateStmt.run(
|
|
320
|
+
stats.thoughtCount,
|
|
321
|
+
stats.costUsd,
|
|
322
|
+
updatedAt,
|
|
323
|
+
worldId,
|
|
324
|
+
);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
log(`update ${worldId} failed: ${err.message}`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (broadcaster && typeof broadcaster.broadcast === 'function') {
|
|
331
|
+
try {
|
|
332
|
+
broadcaster.broadcast('world.activity.tick', {
|
|
333
|
+
worldId,
|
|
334
|
+
thoughtCount: stats.thoughtCount,
|
|
335
|
+
costUsd: stats.costUsd,
|
|
336
|
+
inputTokens: stats.inputTokens,
|
|
337
|
+
outputTokens: stats.outputTokens,
|
|
338
|
+
lastActivityAt: stats.lastActivityAt,
|
|
339
|
+
updatedAt,
|
|
340
|
+
});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
log(`broadcast ${worldId} failed: ${err.message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
processed += 1;
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
if (db) {
|
|
350
|
+
try { db.close(); } catch { /* ignore */ }
|
|
351
|
+
}
|
|
352
|
+
inFlight = false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return processed;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Kick off an initial tick on next event-loop turn so callers can
|
|
359
|
+
// attach test spies before any DB work happens.
|
|
360
|
+
setImmediate(() => {
|
|
361
|
+
if (stopped) return;
|
|
362
|
+
void tick().catch((err) => {
|
|
363
|
+
log(`initial tick crashed: ${err?.message ?? err}`);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
intervalHandle = setTimer(() => {
|
|
368
|
+
void tick().catch((err) => {
|
|
369
|
+
log(`tick crashed: ${err?.message ?? err}`);
|
|
370
|
+
});
|
|
371
|
+
}, intervalMs);
|
|
372
|
+
// Don't pin the event loop on shutdown.
|
|
373
|
+
if (intervalHandle && typeof intervalHandle.unref === 'function') {
|
|
374
|
+
intervalHandle.unref();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
log(
|
|
378
|
+
`started: db=${dbPath} template=${jsonlPathTemplate} interval=${intervalMs}ms`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
stop() {
|
|
383
|
+
if (stopped) return;
|
|
384
|
+
stopped = true;
|
|
385
|
+
if (intervalHandle !== null) {
|
|
386
|
+
try { clearTimer(intervalHandle); } catch { /* ignore */ }
|
|
387
|
+
intervalHandle = null;
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
tickNow: tick,
|
|
391
|
+
};
|
|
392
|
+
}
|