memhouse 0.20.1 → 0.20.2

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.
@@ -445,6 +445,18 @@ async function tombstonesFor(ctx, fc, vanished, rev, { removed = false } = {}) {
445
445
  }
446
446
  return out;
447
447
  }
448
+ /**
449
+ * An adapter upgrade's rows, at a rev above every row the house holds for the file. An unchanged
450
+ * file re-derives at revOf(fgen, upto), the rev its last pass wrote, and at an equal rev the new
451
+ * version's row would win by insertion order alone. The raise is a rev or two; the next append's
452
+ * revOf moves on by the length of what it read.
453
+ */
454
+ async function aboveHouse(ctx, ref, agentId, agentFileIds, rev, res) {
455
+ const floor = BigInt(await ctx.house.maxRev(ref, agentId, agentFileIds)) + 1n;
456
+ if (BigInt(rev) >= floor) return { rev, turns: res.turns, calls: res.calls };
457
+ const up = String(floor);
458
+ return { rev: up, turns: res.turns.map((r) => ({ ...r, rev: up })), calls: res.calls.map((r) => ({ ...r, rev: up })) };
459
+ }
448
460
  /** Keys ({ t: [[message_id, part_id, ts]], c: [[call_id, ts]] }) the new derivation no longer produces. */
449
461
  function vanishedFrom(old, res) {
450
462
  const have = { t: new Set(res.turns.map(base.turnKey)), c: new Set(res.calls.map(base.callKey)) };
@@ -571,7 +583,10 @@ async function captureFile(ctx, f, H, C, budget) {
571
583
  const fbIds = (fb) => Object.fromEntries(Object.entries(fb || {}).filter(([k]) => k.startsWith('id:')));
572
584
  const freshState = { ...base.initialState(ad.adapter), fb: rewrite ? fbIds(state && state.fb) : ((state && state.fb) || {}) };
573
585
  const res = ad.derive({ file: fc, records, state: freshState });
574
- const rev = String(base.revOf(newFgen, newUpto));
586
+ const at = reAdapter
587
+ ? await aboveHouse(ctx, refOf(f, ctx.host), f.agent_id || '', ctx.agentFileIds(f), String(base.revOf(newFgen, newUpto)), res)
588
+ : { rev: String(base.revOf(newFgen, newUpto)), turns: res.turns, calls: res.calls };
589
+ const rev = at.rev;
575
590
  const tomb = rewrite && reAdapter ? await retireBoth(ctx, f, H, state, fc, oldKeys, res, rev)
576
591
  : await tombstonesFor(ctx, fc, vanishedFrom(oldKeys, res), rev, { removed: !!rewrite });
577
592
  // A new generation is archived whole; an upgrade archives only what lies past `upto`.
@@ -580,8 +595,8 @@ async function captureFile(ctx, f, H, C, budget) {
580
595
  // an upgrade that read past the cursor appended to the one it had.
581
596
  if (rewrite) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'reset', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
582
597
  else if (newUpto > upto) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'appended', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
583
- out.turns = [...res.turns, ...tomb.turns];
584
- out.calls = [...res.calls, ...tomb.calls];
598
+ out.turns = [...at.turns, ...tomb.turns];
599
+ out.calls = [...at.calls, ...tomb.calls];
585
600
  const digest = prefixDigest(fd, newUpto);
586
601
  out.files = filesRow(f, ctx, {
587
602
  attr, fgen: newFgen, upto: newUpto, reparse_from: res.reparse_from, state: res.state, adapter_ver: ad.deriveVersion,
@@ -1238,9 +1253,10 @@ async function upgradeSweep(ctx, sessions, stats) {
1238
1253
  const res = ad.derive({ file: fc, records: recs, state: { ...base.initialState(ad.adapter), fb: (state && state.fb) || {} } });
1239
1254
  const S = sessions.get(skey(harness, H.session_id, H.harness_path, H.incarnation));
1240
1255
  const agentIds = S ? [...S.files.values()].filter((x) => x.role === 'agent').map((x) => x.agent_id) : [];
1241
- const tomb = await tombstonesFor(ctx, fc, vanishedFrom(await ctx.house.fileKeys(ref, f.agent_id || '', agentIds), res), String(base.revOf(H.fgen, H.upto)));
1242
- await ctx.house.insert('turns', [...res.turns, ...tomb.turns]);
1243
- await ctx.house.insert('calls', [...res.calls, ...tomb.calls]);
1256
+ const at = await aboveHouse(ctx, ref, f.agent_id || '', agentIds, String(base.revOf(H.fgen, H.upto)), res);
1257
+ const tomb = await tombstonesFor(ctx, fc, vanishedFrom(await ctx.house.fileKeys(ref, f.agent_id || '', agentIds), res), at.rev);
1258
+ await ctx.house.insert('turns', [...at.turns, ...tomb.turns]);
1259
+ await ctx.house.insert('calls', [...at.calls, ...tomb.calls]);
1244
1260
  const row = { ...H, reparse_from: res.reparse_from, derive_state: JSON.stringify(res.state), adapter_ver: ad.deriveVersion, contrib: JSON.stringify(res.contrib), host: ctx.host };
1245
1261
  delete row.ver;
1246
1262
  await ctx.house.insert('files', [row]);
@@ -1553,6 +1569,11 @@ function storeWhere(ref) {
1553
1569
  return `harness = ${sqlStr(ref.harness)} AND host = ${sqlStr(ref.host)} AND harness_path = ${sqlStr(ref.harness_path)}`
1554
1570
  + ` AND session_id = ${sqlStr(ref.session_id)} AND incarnation = ${sqlStr(ref.incarnation || '')}`;
1555
1571
  }
1572
+ /** WHERE clause for one file of a session: a sub-agent's by its id, the main file as every other id. */
1573
+ function fileWho(agentId, agentFileIds) {
1574
+ return agentId ? `agent_id = ${sqlStr(agentId)}`
1575
+ : (agentFileIds.length ? `agent_id NOT IN (${agentFileIds.map(sqlStr).join(', ')})` : '1');
1576
+ }
1556
1577
  const STORE_TUPLE = '(harness, host, harness_path, session_id, incarnation)';
1557
1578
  const storeTuple = (ref) => `(${[ref.harness, ref.host, ref.harness_path, ref.session_id, ref.incarnation || ''].map(sqlStr).join(', ')})`;
1558
1579
  /** Split a column list at its top-level commas. */
@@ -1774,17 +1795,25 @@ class HttpHouse {
1774
1795
  }
1775
1796
  /** The keys this file's shipped rows hold in the conversation layer (adapter upgrade only). */
1776
1797
  async fileKeys(ref, agentId, agentFileIds) {
1777
- const who = agentId ? `agent_id = ${sqlStr(agentId)}`
1778
- : (agentFileIds.length ? `agent_id NOT IN (${agentFileIds.map(sqlStr).join(', ')})` : '1');
1779
1798
  // Removed history (removed = 1) is never a candidate: it is what the source no longer has, not a
1780
1799
  // duplicate of the new derivation, and an upgrade must not erase it.
1781
- const where = `${storeWhere(ref)} AND ${who} AND is_deleted = 0 AND removed = 0`;
1800
+ const where = `${storeWhere(ref)} AND ${fileWho(agentId, agentFileIds)} AND is_deleted = 0 AND removed = 0`;
1782
1801
  const t = await this.query(`SELECT message_id, part_id, toString(ts) AS ts FROM ${this.t.turns} FINAL
1783
1802
  WHERE ${where} AND origin = 'ship' AND NOT startsWith(message_id, 'legacy:')`);
1784
1803
  const c = await this.query(`SELECT call_id, toString(ts) AS ts FROM ${this.t.calls} FINAL
1785
1804
  WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
1786
1805
  return { t: t.map((r) => [r.message_id, r.part_id, r.ts]), c: c.map((r) => [r.call_id, r.ts]) };
1787
1806
  }
1807
+ /** The highest rev this file's shipped rows hold, tombstones and removed versions included (adapter upgrade only). */
1808
+ async maxRev(ref, agentId, agentFileIds) {
1809
+ const where = `${storeWhere(ref)} AND ${fileWho(agentId, agentFileIds)}`;
1810
+ const [t] = await this.query(`SELECT toString(max(rev)) AS rev FROM ${this.t.turns}
1811
+ WHERE ${where} AND origin = 'ship' AND NOT startsWith(message_id, 'legacy:')`);
1812
+ const [c] = await this.query(`SELECT toString(max(rev)) AS rev FROM ${this.t.calls}
1813
+ WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
1814
+ const a = BigInt((t && t.rev) || 0); const b = BigInt((c && c.rev) || 0);
1815
+ return String(a > b ? a : b);
1816
+ }
1788
1817
  /**
1789
1818
  * Every `legacy:` row of one session in EVERY version — no FINAL, so tombstoned rows come back
1790
1819
  * too (dedupe-v3 takes the newest per key: a tombstone is a full copy, it still has its text).
@@ -1957,6 +1986,7 @@ class DryHouse {
1957
1986
  async firstCaptured() { return new Map(); }
1958
1987
  async rowsByKey() { return []; }
1959
1988
  async fileKeys() { return { t: [], c: [] }; }
1989
+ async maxRev() { return '0'; }
1960
1990
  async hasRemoved() { return false; }
1961
1991
  async legacyRowsAll() { return { turns: [], calls: [] }; }
1962
1992
  async capturedRows() { return { turns: [], calls: [] }; }
@@ -14,7 +14,9 @@
14
14
  // A line longer than the read cap is read whole: a read always makes progress
15
15
  // derive the log's records as sessions-model records, the last line of a key winning:
16
16
  // session title, cwd, version, created, updated, archived, mode, kind, engine, client; its
17
- // `links` (stated on the child) as link records
17
+ // `links` (stated on the child) as link records; its `children` (a child no message
18
+ // names, Cursor's sub-agents) as links stated on the origin, one per child: a `child`
19
+ // record for the same child that says where it was found is the one kept
18
20
  // message order_key the native `order`, then the line's offset; ts the source's or null; a
19
21
  // `tool` part as a tool_call part and, once it has an output or an error, a
20
22
  // tool_result part; `file` as an attachment, `error` and `notice` as theirs; its
@@ -31,7 +33,7 @@ const path = require('path');
31
33
  const slog = require('../../editors/sessions-log');
32
34
 
33
35
  const ADAPTER = 'memhouse-bridge';
34
- const ADAPTER_VER = '1.0.1';
36
+ const ADAPTER_VER = '1.0.2';
35
37
  const STATE_VER = 1;
36
38
  const MAX_READ_BYTES = 64 * 1024 * 1024;
37
39
  const UNFLAGGED = { 'gemini-cli': { input_includes_cache: true, output_includes_reasoning: false } };
@@ -70,6 +72,12 @@ function usageRecord(id, anchor, u, harness, model) {
70
72
  reasoning: num(u.reasoning), cost: num(u.cost), input_includes_cache: iic, output_includes_reasoning: oir, granularity: 'per_message', model: String(model || '') };
71
73
  }
72
74
 
75
+ /** A child the session started, as a link stated on the origin: a `child` record, or one of the session record's `children`. */
76
+ function originLink(c, harness) {
77
+ return { type: 'link', kind: String(c.kind || ''), stated_on: 'origin', origin: {}, child: { harness, session_id: String(c.session_id || '') },
78
+ at: String(c.at || ''), evidence: c.evidence === 'inferred' ? 'inferred' : 'explicit' };
79
+ }
80
+
73
81
  /** One log line (its offset `at`) as sessions-model records. */
74
82
  function recordsOf(o, at, harness) {
75
83
  if (!o || typeof o !== 'object') return [];
@@ -80,16 +88,14 @@ function recordsOf(o, at, harness) {
80
88
  const links = (Array.isArray(o.links) ? o.links : []).map((l) => ({ type: 'link', kind: String(l.kind || ''), stated_on: 'child', child: {},
81
89
  origin: { harness, session_id: String(l.origin || ''), ...(l.origin_store ? { harness_path: String(l.origin_store) } : {}) }, at: String(l.at || ''),
82
90
  inherits: l.through ? { through_message_id: String(l.through) } : {}, evidence: l.evidence === 'inferred' ? 'inferred' : 'explicit' }));
83
- return [s, ...links];
91
+ const children = (Array.isArray(o.children) ? o.children : []).map((c) => originLink(c, harness));
92
+ return [s, ...links, ...children];
84
93
  }
85
94
  if (o.k === 'usage') {
86
95
  if (!o.usage) return [];
87
96
  return [usageRecord(o.usage_id != null && o.usage_id !== '' ? `usage:${o.usage_id}` : `usage:@${at}`, {}, o.usage, harness, o.model)];
88
97
  }
89
- if (o.k === 'child') {
90
- return [{ type: 'link', kind: String(o.kind || ''), stated_on: 'origin', origin: {}, child: { harness, session_id: String(o.session_id || '') },
91
- at: String(o.at || ''), evidence: o.evidence === 'inferred' ? 'inferred' : 'explicit' }];
92
- }
98
+ if (o.k === 'child') return [originLink(o, harness)];
93
99
  if (o.k !== 'message' || o.message_id == null) return [];
94
100
  const id = String(o.message_id);
95
101
  const parts = [];
@@ -204,6 +210,11 @@ function logAdapter(home, harness, { maxReadBytes = MAX_READ_BYTES } = {}) {
204
210
  for (const at of Object.keys(st.lines).sort((a, b) => Number(a) - Number(b))) {
205
211
  for (const r of recordsOf(st.lines[at], Number(at), harness)) byKey.set(`${r.type}\u0000${keyOf(r)}`, r);
206
212
  }
213
+ // One link per child: a link with no `at` (a session record's child) gives way to one of the
214
+ // same kind and child that says where it was found (a `child` record).
215
+ const placed = new Set();
216
+ for (const r of byKey.values()) if (r.type === 'link' && r.stated_on === 'origin' && r.at) placed.add(canon([r.kind, r.child]));
217
+ for (const [k, r] of byKey) if (r.type === 'link' && r.stated_on === 'origin' && !r.at && placed.has(canon([r.kind, r.child]))) byKey.delete(k);
207
218
  const changed = []; const live = {}; const emitted = {};
208
219
  for (const [k, r] of byKey) {
209
220
  const j = JSON.stringify(r);
@@ -56,10 +56,16 @@ const RETRY_MS = 5 * 60 * 1000;
56
56
  const BUDGET_MS = 30 * 1000;
57
57
  const keyOf = (s, inc = '') => `${s.harness}\t${s.harness_path}\t${s.session_id}\t${inc}`;
58
58
 
59
- /** Messages in an existing log (0 when there is none). */
59
+ /**
60
+ * Messages in an existing log (0 when there is none). A log that is there but cannot be read throws:
61
+ * counted as 0, it would disarm the refusal to rewrite a log of messages into none.
62
+ */
60
63
  function messagesIn(file) {
61
64
  let s;
62
- try { s = fs.readFileSync(file, 'utf-8'); } catch { return 0; }
65
+ try { s = fs.readFileSync(file, 'utf-8'); } catch (e) {
66
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return 0;
67
+ throw new Error(`cannot read the existing log (${(e && e.code) || 'error'}): ${path.basename(file)}`);
68
+ }
63
69
  let n = 0;
64
70
  for (const l of s.split('\n')) if (l.startsWith('{"k":"message"') || /"k"\s*:\s*"message"/.test(l.slice(0, 200))) n++;
65
71
  return n;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.20.1",
3
+ "version": "0.20.2",
4
4
  "description": "memhouse — agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, and the agentlytics dashboard",
5
5
  "main": "bin/memhouse.js",
6
6
  "bin": {
@@ -66,7 +66,7 @@
66
66
  "email": "security@memhouse.io"
67
67
  },
68
68
  "dependencies": {
69
- "@agent-realm/harness-session-adapters": "1.0.0",
69
+ "@agent-realm/harness-session-adapters": "1.1.0",
70
70
  "@clickhouse/client": "^1.23.1",
71
71
  "express": "^4.22.1"
72
72
  }