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
|
|
612
|
-
--
|
|
613
|
-
--
|
|
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
|
|
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
|
+
"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": {
|