memhouse 0.18.10 → 0.18.11
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/CHANGELOG.md +127 -0
- package/README.md +2 -2
- package/bin/memhouse.js +99 -43
- package/docs/guides/storage.md +8 -5
- package/docs/guides/uninstall.md +4 -2
- package/docs/releases/README.md +1 -0
- package/docs/releases/v0.18.11.md +191 -0
- package/editors/claude.js +68 -10
- package/memhouse/DESIGN.md +9 -2
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/reference/HOUSE.md +18 -6
- package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/house/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +34 -14
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +1 -1
- package/memhouse/flags.js +28 -1
- package/memhouse/host.js +11 -6
- package/memhouse/house/HOUSE.md +1 -1
- package/memhouse/house/house.js +27 -1
- package/memhouse/invite-target.js +37 -0
- package/memhouse/server/queries.js +9 -4
- package/memhouse/shipper/ship.js +148 -31
- package/package.json +1 -1
- package/public/THIRD_PARTY_NOTICES.txt +2114 -0
- package/ui/vite.config.js +4 -1
package/editors/claude.js
CHANGED
|
@@ -74,13 +74,18 @@ function getChats() {
|
|
|
74
74
|
} catch { /* no index */ }
|
|
75
75
|
|
|
76
76
|
// Scan all .jsonl files on disk (some may not be in the index)
|
|
77
|
-
let
|
|
78
|
-
try {
|
|
77
|
+
let names;
|
|
78
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
79
|
+
const files = names.filter(f => f.endsWith('.jsonl'));
|
|
80
|
+
// A session with subagents also has a directory named for it; one without has none, so
|
|
81
|
+
// this listing is how the mtime check below stays free for the sessions it cannot help.
|
|
82
|
+
const sessionDirs = new Set(names.filter(f => !f.endsWith('.jsonl')));
|
|
79
83
|
|
|
80
84
|
for (const file of files) {
|
|
81
85
|
const sessionId = file.replace('.jsonl', '');
|
|
82
86
|
const fullPath = path.join(dir, file);
|
|
83
87
|
const entry = indexed.get(sessionId);
|
|
88
|
+
const subTouched = sessionDirs.has(sessionId) ? newestSubagentMtime(path.join(dir, sessionId, 'subagents')) : 0;
|
|
84
89
|
|
|
85
90
|
if (entry) {
|
|
86
91
|
// Use index metadata
|
|
@@ -89,7 +94,7 @@ function getChats() {
|
|
|
89
94
|
composerId: sessionId,
|
|
90
95
|
name: cleanPrompt(entry.firstPrompt),
|
|
91
96
|
createdAt: entry.created ? new Date(entry.created).getTime() : null,
|
|
92
|
-
lastUpdatedAt: entry.modified ? new Date(entry.modified).getTime() : null,
|
|
97
|
+
lastUpdatedAt: latest(entry.modified ? new Date(entry.modified).getTime() : null, subTouched),
|
|
93
98
|
mode: 'claude',
|
|
94
99
|
folder: entry.projectPath || decodedFolder,
|
|
95
100
|
encrypted: false,
|
|
@@ -107,7 +112,7 @@ function getChats() {
|
|
|
107
112
|
composerId: sessionId,
|
|
108
113
|
name: meta.firstPrompt ? cleanPrompt(meta.firstPrompt) : null,
|
|
109
114
|
createdAt: meta.timestamp || stat.birthtime.getTime(),
|
|
110
|
-
lastUpdatedAt: stat.mtime.getTime(),
|
|
115
|
+
lastUpdatedAt: latest(stat.mtime.getTime(), subTouched),
|
|
111
116
|
mode: 'claude',
|
|
112
117
|
folder: meta.cwd || decodedFolder,
|
|
113
118
|
encrypted: false,
|
|
@@ -195,7 +200,7 @@ function cleanPrompt(prompt) {
|
|
|
195
200
|
// Parse one Claude session .jsonl into the adapter's message shape. When
|
|
196
201
|
// `isSubagent` is set, each message is tagged `[subagent]` so folded subagent turns
|
|
197
202
|
// are clearly attributed in the transcript.
|
|
198
|
-
function parseSessionFile(filePath, isSubagent, agent = null) {
|
|
203
|
+
function parseSessionFile(filePath, isSubagent, agent = null, info = null) {
|
|
199
204
|
const messages = [];
|
|
200
205
|
let lines;
|
|
201
206
|
try { lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return messages; }
|
|
@@ -205,7 +210,9 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
|
|
|
205
210
|
// cited, or told apart from a sibling's. An agent looking for the fork as its own session
|
|
206
211
|
// concluded it was never shipped. Every turn now carries the agent's id, the description
|
|
207
212
|
// the parent gave it, and its position within that subagent — in `extra`, which is not
|
|
208
|
-
// part of the line hash
|
|
213
|
+
// part of the line hash. The position is not only a label any more: the shipper numbers
|
|
214
|
+
// a subagent's rows from it (seq = block + turn, see subagentSeq in ship.js), so `turn`
|
|
215
|
+
// must count exactly the turns this parse keeps, in order.
|
|
209
216
|
let turn = 0;
|
|
210
217
|
const stamp = (m) => { if (agent) { m._agent = { ...agent, turn: turn++ }; } return m; };
|
|
211
218
|
|
|
@@ -228,6 +235,7 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
|
|
|
228
235
|
// time simply omit it.
|
|
229
236
|
const _ts = obj.timestamp ? Date.parse(obj.timestamp) : undefined;
|
|
230
237
|
const at = Number.isFinite(_ts) ? _ts : undefined;
|
|
238
|
+
if (info && info.firstTs === undefined && at !== undefined) info.firstTs = at;
|
|
231
239
|
|
|
232
240
|
if (obj.type === 'user' && obj.message) {
|
|
233
241
|
const content = extractContent(obj.message.content);
|
|
@@ -249,6 +257,10 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
|
|
|
249
257
|
return messages;
|
|
250
258
|
}
|
|
251
259
|
|
|
260
|
+
// How long a subagent transcript without a single timestamped line may hold back its
|
|
261
|
+
// session (see getMessages). A real one gets its first line within seconds.
|
|
262
|
+
const SUBAGENT_STAMP_GRACE_MS = 10 * 60 * 1000;
|
|
263
|
+
|
|
252
264
|
function getMessages(chat) {
|
|
253
265
|
const filePath = chat._fullPath;
|
|
254
266
|
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
@@ -262,30 +274,76 @@ function getMessages(chat) {
|
|
|
262
274
|
const subagentsDir = path.join(filePath.replace(/\.jsonl$/, ''), 'subagents');
|
|
263
275
|
const subFiles = subagentFiles(subagentsDir);
|
|
264
276
|
const named = subFiles.length ? subagentNames(filePath) : new Map();
|
|
277
|
+
const folded = [];
|
|
278
|
+
let unstamped = null;
|
|
265
279
|
for (const { file, workflow } of subFiles) {
|
|
266
280
|
const id = path.basename(file).replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
267
281
|
const meta = named.get(id) || {};
|
|
268
282
|
const agent = { id, description: meta.description || '', type: meta.type || (workflow ? 'workflow-subagent' : ''), file: path.basename(file) };
|
|
269
283
|
if (workflow) agent.workflow = workflow;
|
|
270
|
-
const
|
|
284
|
+
const info = {};
|
|
285
|
+
const turns = parseSessionFile(file, true, agent, info);
|
|
271
286
|
// A fork the parent never named (a Workflow-run agent, or a parent from before the
|
|
272
287
|
// ids were reported) is described by its own first prompt — what it was told to do.
|
|
273
288
|
if (!agent.description) {
|
|
274
289
|
const first = turns.find((m) => m.role === 'user');
|
|
275
290
|
if (first) { const d = String(first.content).replace(/^\[subagent\] /, '').replace(/\s+/g, ' ').trim().slice(0, 160); for (const m of turns) m._agent.description = d; }
|
|
276
291
|
}
|
|
277
|
-
|
|
292
|
+
folded.push({ key: path.relative(subagentsDir, file), firstTs: info.firstTs, turns });
|
|
293
|
+
// A subagent caught before its first line is written has no start time, so it ranks
|
|
294
|
+
// last — and when its first line lands with an EARLIER timestamp than a sibling's, it
|
|
295
|
+
// moves, renumbers every sibling after it, and the session forks and re-ships whole
|
|
296
|
+
// (GLM-F1). So a fresh one defers the whole session to the next pass, when it has a
|
|
297
|
+
// time. Bounded by age: a file that never gets a timestamped line stops deferring after
|
|
298
|
+
// SUBAGENT_STAMP_GRACE_MS and ranks last, as before.
|
|
299
|
+
if (info.firstTs === undefined && !unstamped) {
|
|
300
|
+
let age = Infinity;
|
|
301
|
+
try { age = Date.now() - fs.statSync(file).mtimeMs; } catch { /* gone: nothing to wait for */ }
|
|
302
|
+
if (age < SUBAGENT_STAMP_GRACE_MS) unstamped = path.relative(subagentsDir, file);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Each subagent gets a SLOT, and the shipper numbers its rows from that slot rather than
|
|
306
|
+
// from where they happen to land in this array (see subagentSeq in ship.js). The slot is
|
|
307
|
+
// the subagent's rank by when it started — the timestamp on its transcript's first line —
|
|
308
|
+
// because that is the one order a new subagent cannot disturb: it started after every
|
|
309
|
+
// subagent already on disk, so it ranks last. The file-name order this used to fold in
|
|
310
|
+
// does not have that property: agent ids are random, so a new fork sorted into the middle
|
|
311
|
+
// and pushed every later subagent's rows down, and a session with subagents re-shipped
|
|
312
|
+
// whole on every pass (one 6,120-line session was stored 60 times in a day). A file with
|
|
313
|
+
// no timestamp yet ranks after every one that has one; the path breaks ties.
|
|
314
|
+
folded.sort((a, b) => (a.firstTs === undefined) - (b.firstTs === undefined)
|
|
315
|
+
|| (a.firstTs || 0) - (b.firstTs || 0) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
316
|
+
folded.forEach(({ turns }, slot) => { for (const m of turns) m._agent.slot = slot; messages.push(...turns); });
|
|
317
|
+
if (unstamped) {
|
|
318
|
+
Object.defineProperty(messages, '_defer', { value: `subagent ${unstamped} has no timestamped line yet`, enumerable: false });
|
|
278
319
|
}
|
|
279
320
|
|
|
280
321
|
return messages;
|
|
281
322
|
}
|
|
282
323
|
|
|
324
|
+
/**
|
|
325
|
+
* When a session was last written, counting its subagents. The shipper's incremental skip
|
|
326
|
+
* compares `lastUpdatedAt` with what the house recorded, and a subagent writes only its own
|
|
327
|
+
* file: a background agent that runs on after its parent goes quiet left the parent's mtime
|
|
328
|
+
* (and the index's `modified`) where they were, so its turns shipped at the parent's next
|
|
329
|
+
* write, or never. Stat calls only, and only for a session that has a directory.
|
|
330
|
+
*/
|
|
331
|
+
function newestSubagentMtime(subagentsDir) {
|
|
332
|
+
let newest = 0;
|
|
333
|
+
for (const { file } of subagentFiles(subagentsDir)) {
|
|
334
|
+
try { newest = Math.max(newest, fs.statSync(file).mtimeMs); } catch { /* vanished mid-pass */ }
|
|
335
|
+
}
|
|
336
|
+
return Math.floor(newest);
|
|
337
|
+
}
|
|
338
|
+
function latest(a, b) { return b && (!a || b > a) ? b : a; }
|
|
339
|
+
|
|
283
340
|
/**
|
|
284
341
|
* Every subagent transcript under a session, wherever Claude Code put it:
|
|
285
342
|
* <session>/subagents/agent-<id>.jsonl — Agent tool forks
|
|
286
343
|
* <session>/subagents/workflows/wf_<id>/agent-<id>.jsonl — Workflow-run agents
|
|
287
|
-
* journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted
|
|
288
|
-
*
|
|
344
|
+
* journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted so the listing is
|
|
345
|
+
* deterministic; the fold ORDER is set by start time in getMessages, not by this.
|
|
346
|
+
* On real data the workflow layer held 100 of 165 forks and shipped none.
|
|
289
347
|
*/
|
|
290
348
|
function subagentFiles(subagentsDir) {
|
|
291
349
|
const out = [];
|
package/memhouse/DESIGN.md
CHANGED
|
@@ -134,8 +134,15 @@ Config file: `~/.memhouse/config.json` (`hiddenProjects`, future prefs).
|
|
|
134
134
|
- Per-message `ts`: interpolated across the session's `[createdAt, lastUpdatedAt]`
|
|
135
135
|
(adapters don't expose per-message timestamps; monotonic by `seq`; documented
|
|
136
136
|
approximation, refine per-adapter later).
|
|
137
|
-
- `seq` is the message
|
|
138
|
-
|
|
137
|
+
- `seq` is the message's place in the session. The session's own turns are dense from 0;
|
|
138
|
+
a folded subagent's turns are numbered from their own block,
|
|
139
|
+
`1,000,000,000 + slot × 100,000 + turn`, where `slot` is the subagent's rank by start
|
|
140
|
+
time. `tool_calls.idx` is laid out the same way (the session's own calls dense from 0,
|
|
141
|
+
a subagent's counted within its block). A row's key therefore never depends on how long
|
|
142
|
+
the parent or any other subagent is, which is what lets a session with subagents ship
|
|
143
|
+
tail-only; positional numbering moved every subagent row whenever the parent grew. A
|
|
144
|
+
parse that does not fit the blocks falls back to positional numbering. Readers order by
|
|
145
|
+
`seq` and count with `count()`; nothing may treat `seq` as dense. `is_subagent` = message content begins with
|
|
139
146
|
`[subagent]` (the fold marker from the Claude adapter).
|
|
140
147
|
- Incremental shipping: read existing `(session_id, last_updated_at, message_count)`
|
|
141
148
|
(FINAL) and skip chats that haven't grown/changed; a full re-ship must remain
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
3
|
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.11",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
|
@@ -101,23 +101,35 @@ session finds nothing — look inside the parent:
|
|
|
101
101
|
```sql
|
|
102
102
|
-- the subagents a session spawned
|
|
103
103
|
SELECT toString(extra.agent.id) AS agent, any(toString(extra.agent.description)) AS asked_to, count() AS turns
|
|
104
|
-
FROM messages
|
|
104
|
+
FROM messages FINAL
|
|
105
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND is_subagent
|
|
106
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
107
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
108
|
+
GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
|
|
105
109
|
-- one subagent's transcript, in order
|
|
106
|
-
SELECT seq, role, substring(text, 1, 2000) FROM messages
|
|
107
|
-
WHERE session_id = '<sid>' AND extra.agent.id = '<agent>'
|
|
110
|
+
SELECT seq, role, substring(text, 1, 2000) AS text FROM messages FINAL
|
|
111
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND toString(extra.agent.id) = '<agent>'
|
|
112
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
113
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
114
|
+
ORDER BY seq
|
|
108
115
|
```
|
|
109
116
|
|
|
117
|
+
Both carry the current-epoch filter (trap 1 below). A session whose subagents were
|
|
118
|
+
re-parsed holds every parse, so an unfiltered count doubles each subagent's turns, and an
|
|
119
|
+
unfiltered transcript repeats them. `<uid>` is the session's `user_id`, from whatever
|
|
120
|
+
query found it (trap 2).
|
|
121
|
+
|
|
110
122
|
**`messages`** — one row per turn.
|
|
111
123
|
|
|
112
124
|
| column | meaning |
|
|
113
125
|
|---|---|
|
|
114
126
|
| `session_id` | `<source>:<adapter-local id>`, e.g. `claude-code:6b1f…` |
|
|
115
|
-
| `seq` |
|
|
127
|
+
| `seq` | order within the session. The session's own turns are `0, 1, 2, …`; a subagent's turns sit in their own block, `1,000,000,000 + slot × 100,000 + turn` (slot = the subagent's rank by start time), so `seq` is **sparse** — count rows with `count()`, never `max(seq) + 1` |
|
|
116
128
|
| `source` | editor: `claude-code`, `codex`, `cursor`, … |
|
|
117
|
-
| `host` | the machine, `<hostname>-<8 hex>` — random
|
|
129
|
+
| `host` | the machine, `<hostname>-<8 hex>` — the 8 hex are derived from the OS machine id (random only where none can be read), so a reinstall keeps it and same-named machines differ |
|
|
118
130
|
| `ts` | when the turn happened (`DateTime64(3,'UTC')`) |
|
|
119
131
|
| `role` | `user`, `assistant`, `system` |
|
|
120
|
-
| `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns. Subagents are never separate sessions. |
|
|
132
|
+
| `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns, each subagent's turns contiguous and the subagents in the order they started. Subagents are never separate sessions. |
|
|
121
133
|
| `extra` | JSON. For a subagent turn: `extra.agent.id`, `extra.agent.description` (what the parent asked it to do, else the fork's own first prompt), `extra.agent.type` (`general-purpose`, `workflow-subagent`, …), `extra.agent.turn` (position within that subagent), and `extra.agent.workflow` (`wf_…`) when a Workflow run spawned it. Empty `{}` otherwise, and on rows shipped before 0.18.3. |
|
|
122
134
|
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` | usage; **empty for several adapters** — see below |
|
|
123
135
|
| `text` | the turn, **truncated at 50,000 chars** |
|
|
@@ -13,7 +13,7 @@ Two things that look alike and are not:
|
|
|
13
13
|
- **Invite** mints a ClickHouse *user* and their own *house*. Needs an administrator.
|
|
14
14
|
- **Share** grants an existing user `SELECT` on *your* house. You can do this yourself.
|
|
15
15
|
|
|
16
|
-
**Read
|
|
16
|
+
**Read `../../reference/HOUSE.md` first** for the connection block. One rule dominates
|
|
17
17
|
everything here: **a password must never enter this conversation.** memhouse ships this
|
|
18
18
|
transcript into the house, and the archive is insert-only — a credential pasted here
|
|
19
19
|
cannot be withdrawn.
|
|
@@ -12,7 +12,7 @@ A member owns a house, or rooms in one. An **administrator** owns the ClickHouse
|
|
|
12
12
|
account, every grant. This skill is the second one, and it is the only skill allowed to
|
|
13
13
|
act outside the caller's own house.
|
|
14
14
|
|
|
15
|
-
**Read
|
|
15
|
+
**Read `../../reference/HOUSE.md` first** — connection, schema, and the traps every query
|
|
16
16
|
here inherits.
|
|
17
17
|
|
|
18
18
|
**An admin password must never enter this conversation.** memhouse ships this transcript
|
|
@@ -11,7 +11,7 @@ allowed-tools: Bash
|
|
|
11
11
|
**Invoking this IS the request. Run the report and present it; there is nothing to
|
|
12
12
|
clarify.** Read-only throughout.
|
|
13
13
|
|
|
14
|
-
**Read
|
|
14
|
+
**Read `../../reference/HOUSE.md` first** — connection, schema, and the epoch filter every
|
|
15
15
|
count below depends on.
|
|
16
16
|
|
|
17
17
|
## Nothing configured yet? Answer that instead
|
|
@@ -16,7 +16,7 @@ answer still exists.
|
|
|
16
16
|
path — this skill retrieves rows and YOU read them. Never present a guess as a retrieval:
|
|
17
17
|
if the house has nothing, say so.
|
|
18
18
|
|
|
19
|
-
**Read
|
|
19
|
+
**Read `../../reference/HOUSE.md` first** — connection, schema, and the three traps
|
|
20
20
|
(epoch filtering, `(session_id, user_id)` joins, escaping). Every query below assumes it.
|
|
21
21
|
|
|
22
22
|
## Which shape is being asked for
|
|
@@ -43,7 +43,8 @@ found 8 messages where `%clickhouse%` AND `%ttl%` found 1,461.
|
|
|
43
43
|
```sql
|
|
44
44
|
SELECT session_id, user_id, any(folder) AS folder,
|
|
45
45
|
formatDateTime(max(ts), '%Y-%m-%d') AS day, count() AS hits,
|
|
46
|
-
|
|
46
|
+
min(seq) AS hit_seq,
|
|
47
|
+
substring(argMin(text, seq), 1, 300) AS sample
|
|
47
48
|
FROM messages
|
|
48
49
|
WHERE text_ngram LIKE '%needle%'
|
|
49
50
|
AND (origin != 'ship'
|
|
@@ -55,8 +56,8 @@ ORDER BY hits DESC, max(ts) DESC
|
|
|
55
56
|
LIMIT 8
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
Every row counted here matched, so `sample` is a line that actually matched: the
|
|
60
|
+
session's first hit, at `hit_seq`. Step 2 reads around that position. **Order by `hits`,
|
|
60
61
|
not just recency**: a session mentioning the term 197 times outranks yesterday's passing
|
|
61
62
|
reference.
|
|
62
63
|
|
|
@@ -64,15 +65,27 @@ reference.
|
|
|
64
65
|
the match so you see the resolution, not the complaint:
|
|
65
66
|
|
|
66
67
|
```sql
|
|
67
|
-
SELECT seq, role, substring(text, 1, 2000) AS text
|
|
68
|
-
FROM messages
|
|
69
|
-
WHERE session_id = '<sid>' AND user_id = '<uid>'
|
|
70
|
-
|
|
68
|
+
SELECT seq, role, is_subagent, substring(text, 1, 2000) AS text FROM (
|
|
69
|
+
(SELECT seq, role, is_subagent, text FROM messages FINAL
|
|
70
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq < <hit_seq>
|
|
71
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
72
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
73
|
+
ORDER BY seq DESC LIMIT 3)
|
|
74
|
+
UNION ALL
|
|
75
|
+
(SELECT seq, role, is_subagent, text FROM messages FINAL
|
|
76
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq >= <hit_seq>
|
|
77
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
78
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
79
|
+
ORDER BY seq LIMIT 13))
|
|
71
80
|
ORDER BY seq
|
|
72
81
|
```
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
`<sid>`, `<uid>` and `<hit_seq>` come from step 1's row. The window is counted in rows,
|
|
84
|
+
not in `seq` values: three turns before the hit and twelve after. `seq` has gaps. A
|
|
85
|
+
subagent's turns are numbered in a block of their own, far above the parent's, so
|
|
86
|
+
`seq BETWEEN hit_seq - 3 AND hit_seq + 12` could return the hit alone. The epoch filter
|
|
87
|
+
keeps one parse: an unfiltered read of a compacted session interleaves two versions of
|
|
88
|
+
the same conversation. `FINAL` collapses row versions the server has not merged yet.
|
|
76
89
|
|
|
77
90
|
**3. Answer, then cite.** Lead with the answer. Under it list the sessions it came from —
|
|
78
91
|
`session_id`, date, project — so the user can reopen one (`memhouse resume <session_id>`
|
|
@@ -95,13 +108,20 @@ one representative line each. Offer to read the strongest. Do not dump ten rows
|
|
|
95
108
|
prefer it over hand-joining. For a plain list:
|
|
96
109
|
|
|
97
110
|
```sql
|
|
98
|
-
SELECT session_id, name, source, host, project,
|
|
99
|
-
formatDateTime(
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
SELECT session_id, user_id, name, source, host, project,
|
|
112
|
+
formatDateTime(created_at, '%Y-%m-%d %H:%i') AS started,
|
|
113
|
+
formatDateTime(coalesce(last_updated_at, created_at), '%Y-%m-%d %H:%i') AS last_active,
|
|
114
|
+
message_count
|
|
115
|
+
FROM sessions FINAL
|
|
116
|
+
ORDER BY coalesce(last_updated_at, created_at) DESC
|
|
102
117
|
LIMIT 20
|
|
103
118
|
```
|
|
104
119
|
|
|
120
|
+
`sessions` keeps one row per session and the newest write wins, so read it `FINAL`.
|
|
121
|
+
"Recent" means recently active (`last_updated_at`): a session started last month and
|
|
122
|
+
worked on today is recent. The room has no `started_at`; `created_at` is when the
|
|
123
|
+
session began.
|
|
124
|
+
|
|
105
125
|
Narrow with `WHERE project = '…'`, `source = 'cursor'`, `host = '…'`, or a date range as
|
|
106
126
|
asked. In a shared house every member's sessions are here — add `AND user_id = '<name>'`
|
|
107
127
|
only when the user asks for one person's.
|
|
@@ -11,7 +11,7 @@ allowed-tools: Bash
|
|
|
11
11
|
For **numbers**. When the answer is a passage from a past conversation, that is
|
|
12
12
|
`/mem:recall` — this skill counts, groups and ranks.
|
|
13
13
|
|
|
14
|
-
**Read
|
|
14
|
+
**Read `../../reference/HOUSE.md` first.** Schema, connection, and the three traps. The epoch
|
|
15
15
|
filter in particular is not optional here: this is the skill most likely to produce a
|
|
16
16
|
figure someone acts on, and omitting it over-counted a real house by **34%**.
|
|
17
17
|
|
package/memhouse/flags.js
CHANGED
|
@@ -75,4 +75,31 @@ function allowedFlags(cmd) {
|
|
|
75
75
|
return new Set([...GLOBAL_FLAGS, ...known]);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
// Options that never take a value. The parser used to read `--x y` as x = "y" for every
|
|
79
|
+
// option, so a switch swallowed the word after it: `update --no-install yes` set
|
|
80
|
+
// no-install to "yes" (not true) and ran the npm install it was asked to skip, and
|
|
81
|
+
// `share --revoke bob` revoked nobody. A switch is true when present, and the next word
|
|
82
|
+
// stays a positional.
|
|
83
|
+
const BOOLEAN_FLAGS = new Set([
|
|
84
|
+
'json', 'yes', 'help', 'version', 'full', 'check', 'migrate', 'no-install', 'all-origins',
|
|
85
|
+
'dry-run', 'print-sql', 'no-ship', 'keep-admin', 'ensure-schema', 'rotate-password', 'force',
|
|
86
|
+
'adopt', 'allow-local', 'local', 'down', 'credentials', 'full-removal', 'insecure-native',
|
|
87
|
+
'keep-shipper', 'guides', 'revoke', 'list', 'admin', 'install', 'all',
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/** argv (after the command) -> { flags, positional }. */
|
|
91
|
+
function parseArgv(rest) {
|
|
92
|
+
const flags = {}; const positional = [];
|
|
93
|
+
for (let i = 0; i < rest.length; i++) {
|
|
94
|
+
const a = rest[i];
|
|
95
|
+
if (a.startsWith('--')) {
|
|
96
|
+
const key = a.slice(2);
|
|
97
|
+
const next = rest[i + 1];
|
|
98
|
+
if (!BOOLEAN_FLAGS.has(key) && next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
|
|
99
|
+
else flags[key] = true;
|
|
100
|
+
} else positional.push(a);
|
|
101
|
+
}
|
|
102
|
+
return { flags, positional };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { GLOBAL_FLAGS, COMMAND_FLAGS, BOOLEAN_FLAGS, parseArgv, unknownFlags, allowedFlags, suggestFlag, editDistance };
|
package/memhouse/host.js
CHANGED
|
@@ -14,13 +14,18 @@
|
|
|
14
14
|
// * One machine splits. Rename the box and every row shipped afterwards lands under a
|
|
15
15
|
// new host, so the machine's own history appears to end and a stranger's to begin.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
17
|
+
// So the fingerprint comes from the OS's own machine id (IOPlatformUUID, /etc/machine-id,
|
|
18
|
+
// MachineGuid), hashed with a fixed salt (machineFingerprint), never from the hostname: two
|
|
19
|
+
// machines cannot share it and a rename cannot move it. Since 0.18.6. Before that it was
|
|
20
|
+
// random, which fixed both of the above but split a machine whenever MEMHOUSE_HOME was
|
|
21
|
+
// wiped: a re-imaged laptop came back as a stranger. Where no machine id can be read it is
|
|
22
|
+
// still random.
|
|
19
23
|
//
|
|
20
|
-
// It
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
+
// It is written once to MEMHOUSE_HOME/host.json and an existing file always wins, so no
|
|
25
|
+
// install changes identity under it. `uninstall --full-removal` drops the file, and the
|
|
26
|
+
// next install derives the same fingerprint again from the same machine. The id also
|
|
27
|
+
// carries the hostname the machine had at first install, so it comes back the same unless
|
|
28
|
+
// the machine was renamed in between, or has no readable machine id.
|
|
24
29
|
|
|
25
30
|
const fs = require('fs');
|
|
26
31
|
const os = require('os');
|
package/memhouse/house/HOUSE.md
CHANGED
|
@@ -17,7 +17,7 @@ Two columns say where every row came from:
|
|
|
17
17
|
| Column | Meaning | Comes from |
|
|
18
18
|
|---|---|---|
|
|
19
19
|
| `user_id` | who wrote it | `String MATERIALIZED currentUser()` — stamped by the server, unforgeable by clients. `async_insert = 0 CONST` is pinned on each user (`ADD SETTING`, which merges — a bare `SETTINGS` clause replaces the user's whole list) because an async flush stores the stamp as the empty string (measured on 25.11.9.34) |
|
|
20
|
-
| `host` | which machine | the
|
|
20
|
+
| `host` | which machine | the machine's fingerprint (`../host.js`) — derived from the OS machine id (random only where none can be read), so two machines with the same hostname cannot collide and renaming one cannot split its history |
|
|
21
21
|
|
|
22
22
|
`WHERE user_id = 'alice'` is one person. `WHERE host = 'macbook-4127a95b'` is one
|
|
23
23
|
machine. No filter is the whole house — which is what a team dashboard wants.
|
package/memhouse/house/house.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// clients (async_insert is pinned to 0
|
|
9
9
|
// on the user so the stamp cannot be
|
|
10
10
|
// skipped)
|
|
11
|
-
// host LowCardinality(String) — the machine's fingerprint,
|
|
11
|
+
// host LowCardinality(String) — the machine's fingerprint, derived from its OS machine id
|
|
12
12
|
// per install (../host.js)
|
|
13
13
|
//
|
|
14
14
|
// That pair IS the provenance model.
|
|
@@ -412,6 +412,31 @@ function statRefreshStatements(rooms, at = 'now64(3)') {
|
|
|
412
412
|
`INSERT INTO ${names[k]} SELECT ${at} AS refreshed_at, * FROM (${bodies[k].body})`);
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
/**
|
|
416
|
+
* When the shipper refreshes the stat tables (G11). After every pass that shipped something,
|
|
417
|
+
* as before — and ALSO when the newest generation is older than STATS_MAX_AGE_MS, even if
|
|
418
|
+
* nothing shipped. The tables carry a one-day TTL, so a house whose machines went quiet for
|
|
419
|
+
* a day used to lose every generation and the dashboard went empty while the rooms still
|
|
420
|
+
* held everything. `ageMs` null means "could not tell" (no table, no rights): do not refresh
|
|
421
|
+
* on age then; the read layer is already on the inline rollup.
|
|
422
|
+
*/
|
|
423
|
+
const STATS_MAX_AGE_MS = 60 * 60 * 1000;
|
|
424
|
+
function statsNeedRefresh({ shipped, ageMs, maxAgeMs = STATS_MAX_AGE_MS }) {
|
|
425
|
+
if (shipped > 0) return true;
|
|
426
|
+
return ageMs !== null && ageMs !== undefined && ageMs > maxAgeMs;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* How long a reader may keep its resolved rooms (G11). resolveRooms() decides once whether
|
|
431
|
+
* the stat tables are usable or the inline rollup must answer; a long-running dashboard that
|
|
432
|
+
* never asked again kept reading a stat table after its TTL had emptied it. Re-resolving
|
|
433
|
+
* every few minutes costs one small round trip and lets it follow the house either way.
|
|
434
|
+
*/
|
|
435
|
+
const ROOMS_TTL_MS = 5 * 60 * 1000;
|
|
436
|
+
function roomsCacheStale(resolvedAt, now = Date.now(), ttlMs = ROOMS_TTL_MS) {
|
|
437
|
+
return !resolvedAt || now - resolvedAt > ttlMs;
|
|
438
|
+
}
|
|
439
|
+
|
|
415
440
|
/** The read-side source for one stat table: its newest generation, and nothing else. */
|
|
416
441
|
function latestGeneration(table) {
|
|
417
442
|
return `(SELECT * FROM ${table} WHERE refreshed_at = (SELECT max(refreshed_at) FROM ${table}))`;
|
|
@@ -598,4 +623,5 @@ module.exports = {
|
|
|
598
623
|
sessionsRollup, currentParse, createStatement, roomNames, currentUser, resolveRooms,
|
|
599
624
|
SESSION_STATS, MODEL_STATS, TOOL_STATS, statTableNames, hasSessionStats, statTables,
|
|
600
625
|
statCreateStatements, statRefreshStatements, latestGeneration,
|
|
626
|
+
STATS_MAX_AGE_MS, statsNeedRefresh, ROOMS_TTL_MS, roomsCacheStale,
|
|
601
627
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Where `invite` may send a credential — and the one place that decides it (G4).
|
|
2
|
+
//
|
|
3
|
+
// `--url` is the address the INVITEE will reach the house at. It goes into the invite file;
|
|
4
|
+
// it is typed on a command line, so it can be a typo, a tunnel, or somebody else's server.
|
|
5
|
+
// A credential this install KEEPS — the member's own, or an admin saved with --keep-admin —
|
|
6
|
+
// belongs to the house it was saved for, and is only ever sent there. Before 0.18.11 the
|
|
7
|
+
// stored credential was sent to `--url`: resolveConfig() lets the flag outrank the env file,
|
|
8
|
+
// so `cfg.url` WAS `--url`, and the reachability probe, the grants read and the provisioning
|
|
9
|
+
// all authenticated against whatever `--url` named.
|
|
10
|
+
//
|
|
11
|
+
// A credential given for THIS command — `--admin-user`, or an exported MEMHOUSE_ADMIN_USER,
|
|
12
|
+
// with a password from a file, stdin, the environment or a prompt — was chosen by the person
|
|
13
|
+
// who also typed `--url`, so it provisions there, as it always has.
|
|
14
|
+
//
|
|
15
|
+
// Pure, so it is unit tested.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} o
|
|
19
|
+
* @param {string} o.url the invitee-facing --url
|
|
20
|
+
* @param {string|null} o.storedUrl the house this install is configured for (env or env file)
|
|
21
|
+
* @param {'flag'|'env'|'file'|'member'} o.adminUserSource
|
|
22
|
+
* where the credential's USER came from: --admin-user, an exported MEMHOUSE_ADMIN_USER,
|
|
23
|
+
* the env file's MEMHOUSE_ADMIN_USER, or (no admin at all) the member credential
|
|
24
|
+
* @param {string|null} [o.passwordSource] resolveAdminPassword's `source` ('stored' = the env file)
|
|
25
|
+
* @returns {{ url: string, stored: boolean } | { error: string }}
|
|
26
|
+
*/
|
|
27
|
+
function provisionTarget({ url, storedUrl, adminUserSource, passwordSource = null }) {
|
|
28
|
+
const stored = adminUserSource === 'file' || adminUserSource === 'member' || passwordSource === 'stored';
|
|
29
|
+
if (!stored) return { url, stored: false };
|
|
30
|
+
if (!storedUrl) {
|
|
31
|
+
return { error: 'this install keeps no house URL to send its stored credential to — '
|
|
32
|
+
+ 'give the admin for this command instead (--admin-user, password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file)' };
|
|
33
|
+
}
|
|
34
|
+
return { url: storedUrl, stored: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { provisionTarget };
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const { createClient, ClickHouseLogLevel } = require('@clickhouse/client');
|
|
20
20
|
const { calculateCost, normalizeModelName } = require('../../pricing');
|
|
21
|
-
const { resolveRooms, READ_SETTINGS } = require('../house/house');
|
|
21
|
+
const { resolveRooms, READ_SETTINGS, roomsCacheStale } = require('../house/house');
|
|
22
22
|
|
|
23
23
|
const config = {
|
|
24
24
|
url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
|
|
@@ -90,11 +90,16 @@ const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
|
90
90
|
// braces, no type.
|
|
91
91
|
const ROOM_TOKEN = /\{\{([a-z_]+)\}\}/g;
|
|
92
92
|
let roomsPromise = null;
|
|
93
|
+
let roomsAt = 0;
|
|
93
94
|
|
|
94
95
|
function rooms() {
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
96
|
+
// Cached, because resolveRooms asks the server for currentUser() and which stat tables
|
|
97
|
+
// are usable, and every dashboard request would otherwise pay that round trip. But not
|
|
98
|
+
// for the life of the process: whether the stat tables can answer changes — their TTL
|
|
99
|
+
// empties them on a quiet house, a refresh fills them again — and a dashboard that
|
|
100
|
+
// decided once kept reading an emptied table and showed nothing (G11).
|
|
101
|
+
if (!roomsPromise || roomsCacheStale(roomsAt)) {
|
|
102
|
+
roomsAt = Date.now();
|
|
98
103
|
roomsPromise = resolveRooms(getClient()).catch((e) => { roomsPromise = null; throw e; });
|
|
99
104
|
}
|
|
100
105
|
return roomsPromise;
|