@pleri/olam-cli 0.1.170 → 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.
Files changed (37) hide show
  1. package/dist/agent-stream/driver-runner.js +13 -0
  2. package/dist/commands/auth.d.ts +22 -7
  3. package/dist/commands/auth.d.ts.map +1 -1
  4. package/dist/commands/auth.js +414 -46
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/create.d.ts.map +1 -1
  7. package/dist/commands/create.js +45 -1
  8. package/dist/commands/create.js.map +1 -1
  9. package/dist/commands/services.d.ts +39 -0
  10. package/dist/commands/services.d.ts.map +1 -1
  11. package/dist/commands/services.js +64 -9
  12. package/dist/commands/services.js.map +1 -1
  13. package/dist/from-manifest.d.ts +53 -0
  14. package/dist/from-manifest.d.ts.map +1 -0
  15. package/dist/from-manifest.js +95 -0
  16. package/dist/from-manifest.js.map +1 -0
  17. package/dist/image-digests.json +8 -8
  18. package/dist/index.js +911 -137
  19. package/dist/lib/auth-remote.d.ts +130 -0
  20. package/dist/lib/auth-remote.d.ts.map +1 -0
  21. package/dist/lib/auth-remote.js +307 -0
  22. package/dist/lib/auth-remote.js.map +1 -0
  23. package/dist/mcp-server.js +1487 -435
  24. package/hermes-bundle/version.json +1 -1
  25. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  26. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  27. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  28. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  29. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  30. package/host-cp/observability/ndjson-span-sink.mjs +52 -0
  31. package/host-cp/src/boot-reconciler.mjs +238 -0
  32. package/host-cp/src/linear-sync.mjs +43 -0
  33. package/host-cp/src/plan-chat-service.mjs +129 -1
  34. package/host-cp/src/port-bridge-manager.mjs +116 -10
  35. package/host-cp/src/server.mjs +121 -1
  36. package/host-cp/src/world-activity-tracker.mjs +392 -0
  37. 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.170",
3
+ "version": "0.1.174",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"