memhouse 0.18.12 → 0.20.1

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 (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. package/public/assets/index-CnWzrjF3.js +0 -78
package/editors/base.js CHANGED
@@ -6,14 +6,6 @@ const HOME = os.homedir();
6
6
 
7
7
  // --- Permission check ---
8
8
 
9
- function isSubscriptionAccessAllowed() {
10
- try {
11
- const configPath = path.join(HOME, '.agentlytics', 'config.json');
12
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
13
- return config.allowSubscriptionAccess === true;
14
- } catch { return false; }
15
- }
16
-
17
9
  // --- Platform utilities ---
18
10
 
19
11
  /**
@@ -318,10 +310,472 @@ function queryMcpServerToolsStdio(server, timeout) {
318
310
  });
319
311
  }
320
312
 
313
+ // <capture> ===================================================================================
314
+ // The capture capability — docs/design/source-identity.md (draft 5) §2 and §5, and the build
315
+ // contract in docs/design/source-identity-build.md. An adapter that captures exports
316
+ //
317
+ // capture: {
318
+ // harness 'claude-code' | 'codex' — the `harness` column
319
+ // deriveVersion integer, the `adapter_ver` column. Bumped on ANY change to what derive
320
+ // emits; misc/capture-test.js holds a fingerprint of the derive code (this
321
+ // region plus the adapter's own <derive> region) and fails until it is.
322
+ // sessions(root) -> [session] discovery under one source root {dir, name}
323
+ // sourceFiles(session) -> [{ file_key, path, agent_id, role, agent_type, agent_desc, workflow }]
324
+ // derive({ file, records, state }) -> { turns, calls, contrib, state, reparse_from }
325
+ // }
326
+ //
327
+ // derive is a REPLAY: `records` are every complete record from the file's `reparse_from` (the
328
+ // start of the earliest open unit) to its new `upto`, and `state` is the snapshot taken at
329
+ // reparse_from. It returns every row the replay produces — including rows an earlier pass
330
+ // already wrote — each carrying two hidden (non-enumerable) positions: `_src`, the record that
331
+ // created it, and `_touch`, the last record that changed it. The engine writes only the rows
332
+ // touched by bytes it has not derived before, and tombstones keys of the previous replay that
333
+ // this one no longer produces (state.keys). Because the replay always starts from the same
334
+ // snapshot, a file derived in one pass and the same file derived across many passes, cut at any
335
+ // byte, produce the same live rows with the same keys (§2.6, checked in misc/capture-test.js).
336
+ //
337
+ // Identity (§2): a row's key is the whole ORDER BY — (toDate(ts), harness, session_id, ts,
338
+ // message_id, part) for turns, (…, ts, call_id) for calls. `ts` is the source's own and never
339
+ // moves: where a record has none, the first-seen time is stored once in state.fb and reused.
340
+ // `rev` = fgen·2^40 + upto of the pass that wrote the row, so every later write of a key —
341
+ // a revision, a tombstone, a re-derive after a lost cache — carries the same or a higher rev.
342
+
343
+ const crypto = require('crypto');
344
+
345
+ const CAPTURE_STATE_V = 2; // 2: keys carry part_id (layout 3.2)
346
+ const REV_SHIFT = 1n << 40n;
347
+ const TEXT_CAP = Infinity; // layout 3.2: full text in every row, tool output included (decided 2026-10-22)
348
+ const TOOL_RESULT_CAP = Infinity;
349
+ const ARGS_CAP = 20000; // calls.args (as legacy tool_calls.args)
350
+ const OPEN_CALLS_CAP = 64; // calls awaiting a result kept in state; the oldest is closed as is
351
+ const SEEN_CAP = 2048; // recent keys: a verbatim re-append is counted once in contrib
352
+ const FALLBACK_CAP = 10000; // first-seen timestamps for records that carry none
353
+
354
+ const TURN_COLUMNS = ['harness', 'harness_version', 'harness_path', 'session_id', 'incarnation', 'agent_id', 'message_id', 'part_id', 'part_ord',
355
+ 'parent_id', 'response_id', 'ts', 'ord', 'rev', 'is_deleted', 'removed', 'state', 'role', 'kind', 'model', 'effort',
356
+ 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens',
357
+ 'text', 'text_len', 'folder', 'project', 'host', 'origin', 'extra', 'adapter_ver'];
358
+ const CALL_COLUMNS = ['harness', 'harness_path', 'session_id', 'incarnation', 'agent_id', 'call_id', 'message_id', 'ts', 'rev', 'is_deleted', 'removed',
359
+ 'tool_name', 'args', 'result_ts', 'is_error', 'result_len', 'model', 'folder', 'project', 'host', 'adapter_ver'];
360
+
361
+ function revOf(fgen, upto) { return BigInt(fgen || 0) * REV_SHIFT + BigInt(upto || 0); }
362
+
363
+ // DateTime64(3) wire format, UTC: 'YYYY-MM-DD HH:MM:SS.mmm' (the shipper's convention).
364
+ function chTs(ms) {
365
+ const d = new Date(ms);
366
+ const p = (n, w = 2) => String(n).padStart(w, '0');
367
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} `
368
+ + `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
369
+ }
370
+ function parseTs(v) {
371
+ if (typeof v === 'number' && Number.isFinite(v)) return v < 1e11 ? v * 1000 : v;
372
+ if (typeof v !== 'string' || !v) return NaN;
373
+ return Date.parse(v);
374
+ }
375
+
376
+ // Lone surrogates make ClickHouse refuse a whole JSONEachRow insert (see ship.js scrub()).
377
+ // Removed, not replaced, so text stays comparable with the legacy rows during reconciliation.
378
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
379
+ function cleanText(s) { return (s == null ? '' : String(s)).replace(LONE_SURROGATE, ''); }
380
+ function capText(s, n) {
381
+ if (s.length <= n) return s;
382
+ const c = s.slice(0, n);
383
+ const last = c.charCodeAt(c.length - 1);
384
+ return (last >= 0xD800 && last <= 0xDBFF) ? c.slice(0, -1) : c;
385
+ }
386
+ function num(v) { const n = Math.trunc(Number(v)); return Number.isFinite(n) && n > 0 ? n : 0; }
387
+
388
+ function turnKey(r) { return `${r.ts}\u0000${r.message_id}\u0000${r.part_id}`; }
389
+ function callKey(r) { return `${r.ts}\u0000${r.call_id}`; }
390
+ function shortHash(s) { return crypto.createHash('sha1').update(s).digest('base64').slice(0, 12); }
391
+ function hidden(o, name, value) { Object.defineProperty(o, name, { value, enumerable: false, writable: true, configurable: true }); }
392
+
393
+ // ── contributions: a file's exact share of its session's totals ─────────────────────────────
394
+ // Kept additive (with counts per day) so a revision subtracts its old row before adding the new.
395
+ function newContrib() {
396
+ return { n: {}, usage: {}, tools: {}, days: {}, models: {}, agents: {}, first_ts: null, last_ts: null, meta: {} };
397
+ }
398
+ const N_FIELDS = ['messages', 'user_messages', 'assistant_messages', 'agent_messages', 'tool_calls',
399
+ 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens',
400
+ 'user_chars', 'assistant_chars'];
401
+ function bump(obj, k, by) { const v = (obj[k] || 0) + by; if (v) obj[k] = v; else delete obj[k]; }
402
+ function seeTs(c, ms) {
403
+ if (!Number.isFinite(ms)) return;
404
+ if (c.first_ts === null || ms < c.first_ts) c.first_ts = ms;
405
+ if (c.last_ts === null || ms > c.last_ts) c.last_ts = ms;
406
+ }
407
+ // A row dated by first sighting (§2.1) lives on that day — it is in `days` — but it does not
408
+ // move the session's first/last activity: a backfill would otherwise make every old session end today.
409
+ function applyTurn(c, r, sign) {
410
+ const text = r.kind === 'text';
411
+ if (text) {
412
+ bump(c.n, 'messages', sign);
413
+ if (r.role === 'user') { bump(c.n, 'user_messages', sign); bump(c.n, 'user_chars', sign * r.text_len); }
414
+ if (r.role === 'assistant') {
415
+ bump(c.n, 'assistant_messages', sign); bump(c.n, 'assistant_chars', sign * r.text_len);
416
+ if (r.model) bump(c.models, r.model, sign);
417
+ }
418
+ if (r.agent_id) bump(c.n, 'agent_messages', sign);
419
+ }
420
+ const tok = [r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_write_tokens, r.reasoning_tokens];
421
+ bump(c.n, 'input_tokens', sign * tok[0]); bump(c.n, 'output_tokens', sign * tok[1]);
422
+ bump(c.n, 'cache_read_tokens', sign * tok[2]); bump(c.n, 'cache_write_tokens', sign * tok[3]);
423
+ bump(c.n, 'reasoning_tokens', sign * tok[4]);
424
+ if (text || tok.some((x) => x)) {
425
+ const k = `${r.ts.slice(0, 13)}:00:00\u0000${r.model || ''}`;
426
+ const u = c.usage[k] || (c.usage[k] = [0, 0, 0, 0, 0, 0]);
427
+ u[0] += text ? sign : 0;
428
+ for (let i = 0; i < 5; i++) u[i + 1] += sign * tok[i];
429
+ if (u.every((x) => x === 0)) delete c.usage[k];
430
+ }
431
+ bump(c.days, r.ts.slice(0, 10), sign);
432
+ if (r.agent_id) {
433
+ const a = c.agents[r.agent_id] || (c.agents[r.agent_id] = [0, null]);
434
+ if (text) a[0] += sign;
435
+ const ms = Date.parse(r.ts.replace(' ', 'T') + 'Z');
436
+ if (sign > 0 && !r._fb && (a[1] === null || ms < a[1])) a[1] = ms;
437
+ }
438
+ if (sign > 0 && !r._fb) seeTs(c, Date.parse(r.ts.replace(' ', 'T') + 'Z'));
439
+ }
440
+ function applyCall(c, r, sign) {
441
+ bump(c.n, 'tool_calls', sign);
442
+ const t = c.tools[r.tool_name] || (c.tools[r.tool_name] = [0, 0]);
443
+ t[0] += sign; t[1] += sign * (r.is_error ? 1 : 0);
444
+ if (!t[0] && !t[1]) delete c.tools[r.tool_name];
445
+ bump(c.days, r.ts.slice(0, 10), sign);
446
+ if (sign > 0) seeTs(c, Date.parse(r.ts.replace(' ', 'T') + 'Z'));
447
+ }
448
+
449
+ // The contract form (source-identity-build.md "Session totals"): the totals columns of
450
+ // `sessions` for one file. `meta` (folder, branch, version, names, first prompt) and `models`
451
+ // (assistant text rows per model, for dominant_model) ride along for the session builder.
452
+ function serializeContrib(c, file = {}) {
453
+ const out = {};
454
+ for (const f of N_FIELDS) out[f] = c.n[f] || 0;
455
+ out.usage = Object.keys(c.usage).sort().map((k) => {
456
+ const [hour, model] = k.split('\u0000'); const u = c.usage[k];
457
+ return { hour, model, messages: u[0], input: u[1], output: u[2], cache_read: u[3], cache_write: u[4], reasoning: u[5] };
458
+ });
459
+ out.tools = Object.keys(c.tools).sort().map((name) => ({ name, calls: c.tools[name][0], errors: c.tools[name][1] }));
460
+ out.agents = Object.keys(c.agents).sort().map((id) => ({
461
+ agent_id: id,
462
+ type: id === file.agent_id ? String(file.agent_type || '') : '',
463
+ description: id === file.agent_id ? String(file.agent_desc || '') : '',
464
+ workflow: id === file.agent_id ? String(file.workflow || '') : '',
465
+ messages: c.agents[id][0], first_ts: c.agents[id][1] === null ? null : chTs(c.agents[id][1]),
466
+ }));
467
+ out.days = Object.keys(c.days).filter((d) => c.days[d] > 0).sort();
468
+ out.first_ts = c.first_ts === null ? null : chTs(c.first_ts);
469
+ out.last_ts = c.last_ts === null ? null : chTs(c.last_ts);
470
+ out.models = { ...c.models };
471
+ out.meta = { ...c.meta };
472
+ return out;
473
+ }
474
+
475
+ function initialState(adapter) {
476
+ return { v: CAPTURE_STATE_V, a: adapter.initState(), c: newContrib(), open: {}, seen: [], fb: {}, keys: { t: [], c: [] } };
477
+ }
478
+
479
+ /**
480
+ * The replay engine every capture adapter's derive() runs on. The adapter supplies the record
481
+ * mapping (process), the unit boundaries (unitStart/track, a pure function of the record and a
482
+ * small tracking state so the boundary scan and the replay agree), and what closes a unit.
483
+ */
484
+ class Deriver {
485
+ constructor(adapter, file, state) {
486
+ this.adapter = adapter;
487
+ this.file = file;
488
+ const st = state && state.v === CAPTURE_STATE_V ? state : initialState(adapter);
489
+ this.inState = st;
490
+ this.s = structuredClone({ a: st.a, c: st.c, open: st.open, seen: st.seen });
491
+ this.seenSet = new Set(this.s.seen);
492
+ this.fb = { ...(st.fb || {}) };
493
+ this.fbAt = new Map(); // record pos -> the fb key its rows were dated by
494
+ this.rows = new Map();
495
+ this.calls = new Map();
496
+ this.callIndex = new Map(); // call_id -> key in this.calls
497
+ this.from = Number(file.reparse_from) || 0;
498
+ this.snap = null;
499
+ this.snapPos = null;
500
+ this.rev = String(revOf(file.fgen, file.upto));
501
+ this.project = '';
502
+ }
503
+
504
+ get a() { return this.s.a; }
505
+ get c() { return this.s.c; }
506
+
507
+ /**
508
+ * The source's own timestamp, else the first-seen time stored once (§2.1). Returns the wire string.
509
+ * `id`: a stable identity for the record (a normalised log's message_id). The first-seen time is
510
+ * then kept under it, and survives a rewrite that moves the record; a byte position cannot (ts is
511
+ * in the row key, so a re-dated record would be a new row and its old one a removed version).
512
+ */
513
+ tsOf(rec, ms, id) {
514
+ if (Number.isFinite(ms) && ms > 0) return chTs(ms);
515
+ const k = id != null && id !== '' ? `id:${id}` : String(rec.pos);
516
+ if (this.fb[k] === undefined) this.fb[k] = Number(rec.capturedAt) || Date.now();
517
+ this.fbAt.set(rec.pos, k);
518
+ return chTs(this.fb[k]);
519
+ }
520
+
521
+ seenAdd(k) {
522
+ const h = shortHash(k);
523
+ if (this.seenSet.has(h)) return;
524
+ this.seenSet.add(h); this.s.seen.push(h);
525
+ if (this.s.seen.length > SEEN_CAP) this.seenSet.delete(this.s.seen.shift());
526
+ }
527
+
528
+ /** A new turn row from one record (or one block of it). */
529
+ turn(rec, f) {
530
+ const folder = String(f.folder ?? this.a.folder ?? '');
531
+ const full = cleanText(f.text);
532
+ const cap = (f.kind === 'tool_result' || f.kind === 'tool_call') ? TOOL_RESULT_CAP : TEXT_CAP;
533
+ const row = {
534
+ harness: this.adapter.harness,
535
+ harness_version: String(f.harness_version ?? this.a.version ?? ''),
536
+ harness_path: String(this.file.harness_path || ''),
537
+ session_id: String(this.file.session_id),
538
+ incarnation: String(this.file.incarnation || ''),
539
+ agent_id: String(f.agent_id ?? this.file.agent_id ?? ''),
540
+ message_id: String(f.message_id),
541
+ part_id: String(f.part_id ?? num(f.part)),
542
+ part_ord: num(f.part_ord ?? f.part),
543
+ parent_id: String(f.parent_id || ''),
544
+ response_id: String(f.response_id || ''),
545
+ ts: f.ts,
546
+ ord: rec.pos,
547
+ rev: this.rev,
548
+ is_deleted: 0,
549
+ removed: 0,
550
+ state: String(f.state || ''),
551
+ role: String(f.role),
552
+ kind: String(f.kind),
553
+ model: String(f.model || ''),
554
+ effort: String(f.effort || ''),
555
+ input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0,
556
+ text: capText(full, cap),
557
+ text_len: full.length,
558
+ folder,
559
+ project: folder ? folder.replace(/\/+$/, '').split('/').pop() : '',
560
+ host: String(this.file.host || ''),
561
+ origin: 'ship',
562
+ extra: JSON.stringify(f.extra || {}),
563
+ adapter_ver: this.adapter.deriveVersion,
564
+ };
565
+ hidden(row, '_src', rec.pos); hidden(row, '_touch', rec.pos);
566
+ const fk = this.fbAt.get(rec.pos);
567
+ hidden(row, '_fb', fk !== undefined && chTs(this.fb[fk]) === row.ts);
568
+ const k = turnKey(row);
569
+ const prev = this.rows.get(k);
570
+ if (prev) {
571
+ // The same key twice in one replay (a verbatim re-append): it replaces, it does not add.
572
+ applyTurn(this.c, prev, -1); applyTurn(this.c, row, +1);
573
+ row._src = Math.min(prev._src, row._src);
574
+ } else if (!this.seenSet.has(shortHash(k))) {
575
+ applyTurn(this.c, row, +1);
576
+ }
577
+ this.rows.set(k, row);
578
+ this.seenAdd(k);
579
+ return row;
580
+ }
581
+
582
+ /** Re-emit a row with changed fields (tokens) — the exact old key, a higher rev. */
583
+ revise(old, changes, rec) {
584
+ const k = turnKey(old);
585
+ const prev = this.rows.get(k) || old;
586
+ const next = { ...prev, ...changes, rev: this.rev };
587
+ hidden(next, '_src', prev._src === undefined ? -1 : prev._src);
588
+ hidden(next, '_touch', Math.max(prev._touch === undefined ? -1 : prev._touch, rec.pos));
589
+ hidden(next, '_fb', !!prev._fb);
590
+ applyTurn(this.c, prev, -1); applyTurn(this.c, next, +1);
591
+ this.rows.set(k, next);
592
+ return next;
593
+ }
594
+
595
+ /** A tool call. Kept open in state until its result arrives (capped). */
596
+ call(rec, f) {
597
+ const folder = String(f.folder ?? this.a.folder ?? '');
598
+ const row = {
599
+ harness: this.adapter.harness,
600
+ harness_path: String(this.file.harness_path || ''),
601
+ session_id: String(this.file.session_id),
602
+ incarnation: String(this.file.incarnation || ''),
603
+ agent_id: String(f.agent_id ?? this.file.agent_id ?? ''),
604
+ call_id: String(f.call_id),
605
+ message_id: String(f.message_id),
606
+ ts: f.ts,
607
+ rev: this.rev,
608
+ is_deleted: 0,
609
+ removed: 0,
610
+ tool_name: String(f.tool_name || 'unknown'),
611
+ args: capText(cleanText(f.args == null ? '{}' : f.args), ARGS_CAP),
612
+ result_ts: null,
613
+ is_error: false,
614
+ result_len: 0,
615
+ model: String(f.model || ''),
616
+ folder,
617
+ project: folder ? folder.replace(/\/+$/, '').split('/').pop() : '',
618
+ host: String(this.file.host || ''),
619
+ adapter_ver: this.adapter.deriveVersion,
620
+ };
621
+ hidden(row, '_src', rec.pos); hidden(row, '_touch', rec.pos);
622
+ const k = callKey(row);
623
+ const prev = this.calls.get(k);
624
+ if (prev && prev.result_ts !== null) {
625
+ // A re-appended tool_use after its result: keep the result.
626
+ Object.assign(row, { result_ts: prev.result_ts, is_error: prev.is_error, result_len: prev.result_len });
627
+ }
628
+ if (prev) { applyCall(this.c, prev, -1); applyCall(this.c, row, +1); row._src = Math.min(prev._src, row._src); }
629
+ else if (!this.seenSet.has(shortHash(`call\u0000${k}`))) applyCall(this.c, row, +1);
630
+ this.calls.set(k, row);
631
+ this.callIndex.set(row.call_id, k);
632
+ this.seenAdd(`call\u0000${k}`);
633
+ if (!prev || prev.result_ts === null) {
634
+ this.s.open[row.call_id] = { ...row };
635
+ const ids = Object.keys(this.s.open);
636
+ if (ids.length > OPEN_CALLS_CAP) delete this.s.open[ids[0]];
637
+ }
638
+ return row;
639
+ }
640
+
641
+ /** A tool result: the call row is re-emitted with its ORIGINAL ts, tool_name and args (§3). */
642
+ result(rec, callId, f) {
643
+ const id = String(callId || '');
644
+ let prev = this.callIndex.has(id) ? this.calls.get(this.callIndex.get(id)) : null;
645
+ if (!prev && this.s.open[id]) prev = this.s.open[id];
646
+ if (!prev) return false;
647
+ const next = { ...prev, rev: this.rev, result_ts: f.ts, is_error: !!f.is_error, result_len: num(f.result_len) };
648
+ hidden(next, '_src', prev._src === undefined ? -1 : prev._src);
649
+ hidden(next, '_touch', Math.max(prev._touch === undefined ? -1 : prev._touch, rec.pos));
650
+ applyCall(this.c, prev, -1); applyCall(this.c, next, +1);
651
+ this.calls.set(callKey(next), next);
652
+ this.callIndex.set(id, callKey(next));
653
+ delete this.s.open[id];
654
+ return true;
655
+ }
656
+
657
+ snapshot(pos) {
658
+ this.snap = structuredClone(this.s);
659
+ this.snapPos = pos;
660
+ }
661
+
662
+ run(records) {
663
+ const ad = this.adapter;
664
+ // The boundary scan: where the LAST unit of this replay starts — the next reparse_from.
665
+ const u = structuredClone(this.a.u || {});
666
+ let last = -1;
667
+ for (let i = 0; i < records.length; i++) {
668
+ if (ad.unitStart(records[i], u)) last = i;
669
+ ad.track(records[i], u);
670
+ }
671
+ for (let i = 0; i < records.length; i++) {
672
+ const rec = records[i];
673
+ if (ad.unitStart(rec, this.a.u)) {
674
+ ad.closeUnit(this, rec);
675
+ if (i === last) this.snapshot(rec.pos);
676
+ ad.openUnit(this, rec);
677
+ }
678
+ ad.track(rec, this.a.u);
679
+ if (rec.obj) ad.process(this, rec);
680
+ else if (!rec.empty) this.c.meta.malformed = (this.c.meta.malformed || 0) + 1;
681
+ }
682
+ ad.endReplay(this);
683
+ return this.finish();
684
+ }
685
+
686
+ finish() {
687
+ const reparseFrom = this.snap ? this.snapPos : this.from;
688
+ const kept = this.snap || structuredClone({ a: this.inState.a, c: this.inState.c, open: this.inState.open, seen: this.inState.seen });
689
+ const turns = [...this.rows.values()];
690
+ const calls = [...this.calls.values()];
691
+ const fbKeys = Object.keys(this.fb);
692
+ if (fbKeys.length > FALLBACK_CAP) for (const k of fbKeys.slice(0, fbKeys.length - FALLBACK_CAP)) delete this.fb[k];
693
+ const state = {
694
+ v: CAPTURE_STATE_V, a: kept.a, c: kept.c, open: kept.open, seen: kept.seen, fb: this.fb,
695
+ keys: {
696
+ t: turns.filter((r) => r._src >= reparseFrom).map((r) => [r.message_id, r.part_id, r.ts, r.role, r.kind]),
697
+ c: calls.filter((r) => r._src >= reparseFrom).map((r) => [r.call_id, r.ts, r.message_id]),
698
+ },
699
+ };
700
+ return { turns, calls, contrib: serializeContrib(this.c, this.file), state, reparse_from: reparseFrom };
701
+ }
702
+ }
703
+
704
+ /** derive() for any adapter built on Deriver. */
705
+ function runDerive(adapter, { file, records, state }) {
706
+ return new Deriver(adapter, file, state).run(records || []);
707
+ }
708
+
709
+ /** Keys the previous replay produced that this one does not — to be tombstoned (§2.2). */
710
+ function vanishedKeys(oldState, result) {
711
+ const out = { turns: [], calls: [] };
712
+ const old = oldState && oldState.keys ? oldState.keys : { t: [], c: [] };
713
+ const haveT = new Set(result.turns.map(turnKey));
714
+ const haveC = new Set(result.calls.map(callKey));
715
+ for (const [message_id, part_id, ts, role, kind] of old.t || []) {
716
+ if (!haveT.has(turnKey({ ts, message_id, part_id }))) out.turns.push({ message_id, part_id: String(part_id), ts, role, kind });
717
+ }
718
+ for (const [call_id, ts, message_id] of old.c || []) {
719
+ if (!haveC.has(callKey({ ts, call_id }))) out.calls.push({ call_id, ts, message_id });
720
+ }
721
+ return out;
722
+ }
723
+
724
+ /**
725
+ * A tombstone is a FULL copy of the row it replaces — every column, the old row's own values —
726
+ * with only `rev` raised and is_deleted = 1 (§2.2). A key-only tombstone is not enough: under
727
+ * FINAL, ClickHouse 26.7 applies skip indexes (text, tool_name set, ts minmax), so a tombstone
728
+ * whose indexed columns differ from the old row's can be skipped while the old row is read,
729
+ * and FINAL resurrects it (measured: calls FINAL WHERE tool_name = 'Bash' returned 9,357 rows
730
+ * instead of 7,404). `old` must be the row as the house holds it.
731
+ */
732
+ function tombstoneOf(kind, old, rev) {
733
+ const cols = kind === 'turns' ? TURN_COLUMNS : CALL_COLUMNS;
734
+ const out = {};
735
+ for (const c of cols) {
736
+ if (old[c] === undefined) throw new Error(`tombstone of ${kind} needs the full old row; ${c} is missing`);
737
+ out[c] = old[c];
738
+ }
739
+ const floor = BigInt(old.rev || 0) + 1n;
740
+ const r = BigInt(rev);
741
+ out.rev = String(r > floor ? r : floor);
742
+ out.is_deleted = 1;
743
+ return out;
744
+ }
745
+
746
+ /**
747
+ * Layout 3.2: content that left the source is kept, not deleted. The same full row copy as a
748
+ * tombstone, at a higher rev, but live and marked `removed = 1`. Physical tombstones (tombstoneOf)
749
+ * are only for duplicates: dedupe of converted 0.18 rows, and keys an upgraded adapter no longer
750
+ * produces.
751
+ */
752
+ function removedOf(kind, old, rev) {
753
+ const out = tombstoneOf(kind, old, rev);
754
+ out.is_deleted = 0;
755
+ out.removed = 1;
756
+ return out;
757
+ }
758
+
759
+ /** Does this adapter carry the capture capability? */
760
+ function canCapture(adapter) {
761
+ const c = adapter && adapter.capture;
762
+ return !!(c && c.harness && Number.isInteger(c.deriveVersion) && typeof c.sessions === 'function'
763
+ && typeof c.sourceFiles === 'function' && typeof c.derive === 'function');
764
+ }
765
+
766
+ const capture = {
767
+ CAPTURE_STATE_V, REV_SHIFT, TEXT_CAP, TOOL_RESULT_CAP, ARGS_CAP, OPEN_CALLS_CAP, SEEN_CAP,
768
+ TURN_COLUMNS, CALL_COLUMNS,
769
+ revOf, chTs, parseTs, cleanText, capText, num, turnKey, callKey, removedOf,
770
+ newContrib, applyTurn, applyCall, serializeContrib, initialState,
771
+ Deriver, runDerive, vanishedKeys, tombstoneOf, canCapture,
772
+ };
773
+ // </capture> ==================================================================================
774
+
321
775
  module.exports = {
322
776
  getAppDataPath,
323
- isSubscriptionAccessAllowed,
324
777
  scanArtifacts,
325
778
  parseMcpConfigFile,
326
779
  queryMcpServerTools,
780
+ capture,
327
781
  };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+ // The bridge: schema-3 capture for every editor whose sessions are not append-only transcript
3
+ // files (SQLite stores, JSON documents, …). Claude Code and Codex capture their own files
4
+ // (editors/claude.js, editors/codex.js); the other adapters keep their 0.18 readers
5
+ // (getChats/getMessages), and memhouse/shipper/snapshots.js turns each session they read into a
6
+ // per-session snapshot log under $MEMHOUSE_HOME/snapshots/<harness>/<session>.jsonl. The capture
7
+ // engine treats that log as a source file like any other: appended when the session grows,
8
+ // rewritten (fgen bump, vanished keys tombstoned) when the editor's history changed.
9
+ //
10
+ // A snapshot line is one of
11
+ // {"k":"meta","name":…,"mode":…,"folder":…,"created":ms,"updated":ms,"branch":…}
12
+ // {"k":"msg","i":<position in the parse>,"role":…,"text":…,"ts":ms,"tsk":"own"|"interp",
13
+ // "model":…,"tok":{"in","out","cr","cw"},"tools":[{"name","args"}]}
14
+ // `text` is exactly the text 0.18 stored for the message (content, or JSON of it), so dedupe can
15
+ // match the converted 0.18 rows of the same session line for line. `ts` is written once and never
16
+ // moves: the editor's own time where it has one, else 0.18's interpolation over the session's
17
+ // span at the moment the line was first written.
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ // Version 2: layout 3.2 (part_id/part_ord, harness_path, full text). Version 3: no derive change: comment wording in the shared capture region.
23
+ const BRIDGE_DERIVE_VERSION = 3;
24
+
25
+ // <derive> ====================================================================================
26
+ function bTok(t) {
27
+ const n = (v) => { const x = Math.trunc(Number(v)); return Number.isFinite(x) && x > 0 ? x : 0; };
28
+ return { input_tokens: n(t && t.in), output_tokens: n(t && t.out), cache_read_tokens: n(t && t.cr), cache_write_tokens: n(t && t.cw), reasoning_tokens: 0 };
29
+ }
30
+ function bKind(role, text) {
31
+ if (role === 'tool') return 'tool_result';
32
+ if (role === 'system') return 'context';
33
+ return 'text';
34
+ }
35
+ function bRole(role) {
36
+ if (role === 'user' || role === 'assistant' || role === 'system') return role;
37
+ if (role === 'tool') return 'user';
38
+ return 'assistant';
39
+ }
40
+
41
+ function bridgeDeriveAdapter(harness) {
42
+ const ad = {
43
+ harness,
44
+ deriveVersion: BRIDGE_DERIVE_VERSION,
45
+ initState() { return { u: {}, folder: '', model: '' }; },
46
+ // Every message line is its own unit: a later pass re-reads only the last one.
47
+ unitStart(rec) { return !!(rec.obj && rec.obj.k === 'msg'); },
48
+ track() {},
49
+ closeUnit() {},
50
+ openUnit() {},
51
+ endReplay() {},
52
+ process(d, rec) {
53
+ const o = rec.obj;
54
+ if (o.k === 'meta') {
55
+ if (typeof o.folder === 'string' && o.folder) { d.a.folder = o.folder; if (!d.c.meta.folder) d.c.meta.folder = o.folder; }
56
+ if (typeof o.name === 'string' && o.name) d.c.meta.title = o.name;
57
+ if (typeof o.branch === 'string' && o.branch) d.c.meta.git_branch = o.branch;
58
+ if (typeof o.mode === 'string' && o.mode) d.c.meta.mode = o.mode;
59
+ if (typeof o.version === 'string' && o.version) { d.a.version = o.version; d.c.meta.version = o.version; }
60
+ return;
61
+ }
62
+ if (o.k !== 'msg') return;
63
+ const role = String(o.role || '');
64
+ const text = typeof o.text === 'string' ? o.text : '';
65
+ const ts = d.tsOf(rec, Number(o.ts));
66
+ const mid = typeof o.id === 'string' && o.id ? o.id : `m${Number(o.i) || 0}`;
67
+ if (typeof o.model === 'string' && o.model) d.a.model = o.model;
68
+ const row = d.turn(rec, {
69
+ message_id: mid, part: 0, role: bRole(role), kind: bKind(role, text), text, ts,
70
+ model: role === 'assistant' ? (o.model || '') : '', extra: role === 'tool' || (role !== bRole(role)) ? { source_role: role } : {},
71
+ });
72
+ const tok = bTok(o.tok);
73
+ if (tok.input_tokens || tok.output_tokens || tok.cache_read_tokens || tok.cache_write_tokens) d.revise(row, tok, rec);
74
+ if (role === 'user' && !d.c.meta.first_prompt && text.trim()) d.c.meta.first_prompt = text.slice(0, 2000);
75
+ (Array.isArray(o.tools) ? o.tools : []).forEach((t, n) => {
76
+ if (!t || n > 255) return;
77
+ d.call(rec, { call_id: `${mid}:${n}`, message_id: mid, ts, tool_name: String(t.name || 'unknown'), args: JSON.stringify(t.args ?? {}), model: o.model || '' });
78
+ });
79
+ },
80
+ };
81
+ return ad;
82
+ }
83
+ // </derive> ===================================================================================
84
+
85
+ const SNAP_EXT = '.jsonl';
86
+ /** Session id <-> snapshot file name: reversible, filesystem-safe. */
87
+ function fileNameFor(sessionId) { return encodeURIComponent(String(sessionId)).replace(/\./g, '%2E') + SNAP_EXT; }
88
+ function sessionIdOf(fileName) { try { return decodeURIComponent(path.basename(fileName, SNAP_EXT)); } catch { return null; } }
89
+
90
+ const cache = new Map();
91
+ /** The capture capability for one bridged harness; `root.dir` is its snapshot directory. */
92
+ function bridgeCapture(harness) {
93
+ if (cache.has(harness)) return cache.get(harness);
94
+ const adapter = bridgeDeriveAdapter(harness);
95
+ const cap = {
96
+ harness,
97
+ deriveVersion: BRIDGE_DERIVE_VERSION,
98
+ bridged: true,
99
+ sessions(root) {
100
+ let names = [];
101
+ try { names = fs.readdirSync(root.dir).filter((n) => n.endsWith(SNAP_EXT)); } catch { return []; }
102
+ const out = [];
103
+ for (const n of names) {
104
+ const id = sessionIdOf(n);
105
+ if (id) out.push({ harness, session_id: id, root: root.name, paths: [path.join(root.dir, n)] });
106
+ }
107
+ return out;
108
+ },
109
+ sourceFiles(session) { return session.paths.map((p) => ({ file_key: path.basename(p), path: p, agent_id: '', role: 'main' })); },
110
+ attribution: () => ({ agent_type: '', agent_desc: '' }),
111
+ tsless: (o) => !o || !(Number(o.ts) > 0),
112
+ derive: (input) => require('./base').capture.runDerive(adapter, input),
113
+ adapter,
114
+ };
115
+ cache.set(harness, cap);
116
+ return cap;
117
+ }
118
+
119
+ module.exports = { bridgeCapture, bridgeDeriveAdapter, fileNameFor, sessionIdOf, BRIDGE_DERIVE_VERSION, SNAP_EXT };