memhouse 0.4.3 → 0.4.4

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/bin/memhouse.js CHANGED
@@ -608,9 +608,13 @@ function memberSql(db, member, password) {
608
608
  -- 26.x accepts the setting as a no-op. The SET below covers any client that keeps a
609
609
  -- session — clickhouse-client, a GUI. Over HTTP, where each statement is its own
610
610
  -- request and SET does not persist, put it in the URL instead:
611
- -- curl "\$URL/?allow_experimental_full_text_index=1&multiquery=1" --data-binary @-
612
- -- multiquery=1 matters because this is one POST carrying many statements; without it
613
- -- ClickHouse parses only the first and reports a syntax error on the rest.
611
+ -- curl "\$URL/?allow_experimental_full_text_index=1" --data-binary "<one statement>"
612
+ --
613
+ -- One statement PER REQUEST over HTTP. The interface refuses a body carrying several
614
+ -- ("Multi-statements are not allowed"), and there is no setting that changes that —
615
+ -- 'multiquery' is a clickhouse-client flag and sending it as an HTTP param fails the
616
+ -- whole request with UNKNOWN_SETTING. Split on ';' and post each in turn, or use
617
+ -- clickhouse-client, which takes the file whole.
614
618
 
615
619
  SET allow_experimental_full_text_index = 1;
616
620
 
@@ -25,11 +25,23 @@ CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
25
25
  message_count UInt32 DEFAULT 0,
26
26
  path String DEFAULT '',
27
27
  extra JSON,
28
+ -- Who put this row here. The shipper CLEARS a session before re-inserting it (a
29
+ -- shorter re-parse must not leave a stale seq tail), and that clear used to remove
30
+ -- every row for the session regardless of origin -- destroying imported history the
31
+ -- adapters cannot reproduce. The clear now binds origin='ship', so anything imported
32
+ -- survives a re-ship of the same session.
33
+ -- IN THE SORTING KEY, and that is the load-bearing half. Guarding the DELETE alone
34
+ -- is not enough: ReplacingMergeTree collapses on the sorting key, so an imported row
35
+ -- and a shipped row sharing (session_id, user_id, seq) are the SAME row and the newer
36
+ -- ingested_at wins. Measured: 3 imported + 2 shipped rows became 3, and the two
37
+ -- imported ones the shipper happened to overlap were gone. Same loss as the
38
+ -- unguarded delete, reached through the merge instead.
39
+ origin LowCardinality(String) DEFAULT 'ship',
28
40
  user_id String MATERIALIZED currentUser(),
29
41
  ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
30
42
  )
31
43
  ENGINE = ReplacingMergeTree(ingested_at)
32
- ORDER BY (session_id, user_id);
44
+ ORDER BY (session_id, user_id, origin);
33
45
 
34
46
  CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
35
47
  (
@@ -50,6 +62,18 @@ CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
50
62
  is_subagent Bool DEFAULT false,
51
63
  extra JSON,
52
64
  line_hash UInt64,
65
+ -- Who put this row here. The shipper CLEARS a session before re-inserting it (a
66
+ -- shorter re-parse must not leave a stale seq tail), and that clear used to remove
67
+ -- every row for the session regardless of origin -- destroying imported history the
68
+ -- adapters cannot reproduce. The clear now binds origin='ship', so anything imported
69
+ -- survives a re-ship of the same session.
70
+ -- IN THE SORTING KEY, and that is the load-bearing half. Guarding the DELETE alone
71
+ -- is not enough: ReplacingMergeTree collapses on the sorting key, so an imported row
72
+ -- and a shipped row sharing (session_id, user_id, seq) are the SAME row and the newer
73
+ -- ingested_at wins. Measured: 3 imported + 2 shipped rows became 3, and the two
74
+ -- imported ones the shipper happened to overlap were gone. Same loss as the
75
+ -- unguarded delete, reached through the merge instead.
76
+ origin LowCardinality(String) DEFAULT 'ship',
53
77
  user_id String MATERIALIZED currentUser(),
54
78
  ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
55
79
  text_ngram String MATERIALIZED lower(text),
@@ -58,7 +82,7 @@ CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
58
82
  INDEX idx_text_word text_word TYPE text(tokenizer = splitByNonAlpha) GRANULARITY 1
59
83
  )
60
84
  ENGINE = ReplacingMergeTree(ingested_at)
61
- ORDER BY (session_id, user_id, seq);
85
+ ORDER BY (session_id, user_id, origin, seq);
62
86
 
63
87
  CREATE TABLE IF NOT EXISTS tool_calls_{{MEMBER}}
64
88
  (
@@ -72,11 +96,23 @@ CREATE TABLE IF NOT EXISTS tool_calls_{{MEMBER}}
72
96
  ts DateTime64(3, 'UTC'),
73
97
  project String DEFAULT '',
74
98
  folder String DEFAULT '',
99
+ -- Who put this row here. The shipper CLEARS a session before re-inserting it (a
100
+ -- shorter re-parse must not leave a stale seq tail), and that clear used to remove
101
+ -- every row for the session regardless of origin -- destroying imported history the
102
+ -- adapters cannot reproduce. The clear now binds origin='ship', so anything imported
103
+ -- survives a re-ship of the same session.
104
+ -- IN THE SORTING KEY, and that is the load-bearing half. Guarding the DELETE alone
105
+ -- is not enough: ReplacingMergeTree collapses on the sorting key, so an imported row
106
+ -- and a shipped row sharing (session_id, user_id, seq) are the SAME row and the newer
107
+ -- ingested_at wins. Measured: 3 imported + 2 shipped rows became 3, and the two
108
+ -- imported ones the shipper happened to overlap were gone. Same loss as the
109
+ -- unguarded delete, reached through the merge instead.
110
+ origin LowCardinality(String) DEFAULT 'ship',
75
111
  user_id String MATERIALIZED currentUser(),
76
112
  ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
77
113
  )
78
114
  ENGINE = ReplacingMergeTree(ingested_at)
79
- ORDER BY (session_id, user_id, idx);
115
+ ORDER BY (session_id, user_id, origin, idx);
80
116
 
81
117
  -- NO sessions_v HERE, deliberately. The session rollup is a SAVED QUERY substituted
82
118
  -- with these room names and run under the caller's own credential (see rooms.js
@@ -33,7 +33,7 @@ const crypto = require('crypto');
33
33
  const { createClient } = require('@clickhouse/client');
34
34
  const { getAllChats, getAdapterErrors, getMessages, resetCaches } = require('../../editors');
35
35
  const adapterErrorSink = require('../../editors/adapter-errors');
36
- const { resolveRooms, READ_SETTINGS } = require('../per-member/rooms');
36
+ const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../per-member/rooms');
37
37
 
38
38
  const BATCH_ROWS = 2000; // insert batch ceiling (binding)
39
39
  const TEXT_MAX = 50000; // messages.text truncation
@@ -112,8 +112,39 @@ function makeClient() {
112
112
  // It does NOT issue grants or create the Merge rooms. A solo owner needs neither. Adding a
113
113
  // SECOND member — grants, Merge rooms, sharing — is owner work and lives in
114
114
  // ../per-member/provision.js.
115
+ /**
116
+ * Refuse to write into rooms whose sorting key predates `origin`.
117
+ *
118
+ * Called at the top of EVERY ship pass, not just --ensure-schema: a plain `memhouse ship`
119
+ * never touches ensureSchema, so a guard living only there is a guard that never runs on
120
+ * the path that does the damage. Found exactly that way — the refusal was in place and
121
+ * 137 sessions shipped straight past it into a stale-key house.
122
+ */
123
+ async function assertOriginKeyed(client, rooms) {
124
+ const stale = [];
125
+ for (const t of ROOM_TYPES) {
126
+ const rs = await client.query({
127
+ query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {n:String}`,
128
+ query_params: { n: rooms[t] }, format: 'JSONEachRow',
129
+ });
130
+ const key = ((await rs.json())[0] || {}).sorting_key || '';
131
+ if (key && !/\borigin\b/.test(key)) stale.push(`${rooms[t]} (${key})`);
132
+ }
133
+ if (stale.length) {
134
+ throw new Error(
135
+ 'these rooms predate the origin sorting key, so imported rows would still be lost:\n'
136
+ + stale.map((s) => ` ${s}`).join('\n')
137
+ + '\n ORDER BY cannot be altered in place. Rebuild each room, then re-ship:'
138
+ + '\n RENAME TABLE <room> TO <room>_old;'
139
+ + '\n -- recreate from mem-house/per-member/schema-member.sql.tpl'
140
+ + '\n INSERT INTO <room> SELECT *, \'ship\' AS origin FROM <room>_old;'
141
+ + '\n A house with no imported rows can also just be re-shipped from scratch.');
142
+ }
143
+ }
144
+
115
145
  async function ensureSchema(client) {
116
- const { member } = await resolveRooms(client);
146
+ const rooms = await resolveRooms(client);
147
+ const { member } = rooms;
117
148
  const tpl = fs.readFileSync(path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
118
149
  const sql = tpl.replaceAll('{{MEMBER}}', member);
119
150
  const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
@@ -128,6 +159,29 @@ async function ensureSchema(client) {
128
159
  clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
129
160
  });
130
161
  }
162
+ // A house created before origin existed has no such column, and the clear binds it —
163
+ // an unguarded DELETE there would be the old destructive behaviour, and a guarded one
164
+ // would error. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
165
+ // pre-existing row reads as 'ship', which is what it was.
166
+ //
167
+ // Deliberately NOT applied to the Merge rooms: they take their structure from a member
168
+ // room at CREATE time and reject ALTER. A Merge room simply will not expose `origin`
169
+ // until it is recreated, which costs nothing — nothing reads origin through it.
170
+ for (const t of ROOM_TYPES) {
171
+ try {
172
+ await client.command({
173
+ query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS origin LowCardinality(String) DEFAULT 'ship'`,
174
+ // 25.11 refuses ANY alter on a table carrying the messages text indexes unless
175
+ // this is set — including an ADD COLUMN that has nothing to do with them
176
+ // (Code: 344, SUPPORT_IS_DISABLED). Without it the backfill fails silently and
177
+ // the guard ends up missing on precisely the houses that need upgrading. A no-op
178
+ // on 26.x, and query-scoped, so it needs no server config.
179
+ clickhouse_settings: { allow_experimental_full_text_index: 1 },
180
+ });
181
+ } catch { /* no rights to alter is not fatal: a member on someone else's house */ }
182
+ }
183
+
184
+ await assertOriginKeyed(client, rooms);
131
185
  return stmts.length;
132
186
  }
133
187
 
@@ -285,6 +339,7 @@ async function runShip(client, opts = {}) {
285
339
  // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
286
340
  // otherwise: ReplacingMergeTree collapses same-key rows only).
287
341
  const rooms = await resolveRooms(client);
342
+ await assertOriginKeyed(client, rooms);
288
343
  const existing = await loadExisting(client, rooms);
289
344
  const chats = getAllChats();
290
345
 
@@ -373,9 +428,16 @@ async function runShip(client, opts = {}) {
373
428
  // the identical predicate with the literal value deleted 2000 rows where
374
429
  // currentUser() deleted 0. The value is the same identity either way: it is read
375
430
  // from the server over this very connection.
431
+ // origin='ship' is the third bind, and it is not cosmetic. This clear exists so a
432
+ // shorter re-parse cannot leave a stale seq tail behind — but scoped to
433
+ // (session_id, user_id) alone it deletes EVERY row for the session, including rows
434
+ // the adapters did not write and cannot rewrite. Measured, on a real house: an
435
+ // import of 135,307 messages lost 27,948 of them to one ship pass, because
436
+ // memory-house had captured more per session than the adapters emit. The rows the
437
+ // shipper owns are the only rows it may remove.
376
438
  for (const t of ['messages', 'tool_calls']) {
377
439
  await client.command({
378
- query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String}`,
440
+ query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship'`,
379
441
  query_params: { id, uid: rooms.user },
380
442
  clickhouse_settings: { async_insert: 0 },
381
443
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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, kernel-agency install, and the agentlytics dashboard",
5
5
  "main": "bin/memhouse.js",
6
6
  "bin": {