memhtml 0.1.0
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/LICENSE +201 -0
- package/README.md +531 -0
- package/agent/agent.ts +68 -0
- package/agent/channels/eve.ts +73 -0
- package/agent/instructions.md +142 -0
- package/agent/sandbox/sandbox.ts +102 -0
- package/dist/dist-Bubu4ZZa.mjs +3 -0
- package/dist/dist-CrYVXFO2.mjs +12846 -0
- package/dist/dist-CrYVXFO2.mjs.map +1 -0
- package/dist/dist-DUuomISL.mjs +2221 -0
- package/dist/dist-DUuomISL.mjs.map +1 -0
- package/dist/memhtml-mcp.mjs +4077 -0
- package/dist/memhtml-mcp.mjs.map +1 -0
- package/dist/memhtml.mjs +5009 -0
- package/dist/memhtml.mjs.map +1 -0
- package/guest/corpus.mjs +193 -0
- package/migrations/.gitkeep +0 -0
- package/migrations/0001_files.sql +111 -0
- package/migrations/0002_chunks.sql +31 -0
- package/migrations/0003_fts.sql +40 -0
- package/migrations/0004_edges.sql +40 -0
- package/migrations/0005_traces.sql +92 -0
- package/migrations/0006_sleep.sql +33 -0
- package/migrations/0007_watermark.sql +32 -0
- package/migrations/0008_tasks.sql +214 -0
- package/migrations/0009_frame_key.sql +54 -0
- package/migrations/0010_trace_consolidations.sql +45 -0
- package/package.json +59 -0
- package/src/agent-build.ts +280 -0
- package/src/client.ts +1155 -0
- package/src/contract.ts +443 -0
- package/src/index.ts +23 -0
- package/src/mount.ts +279 -0
- package/src/run-auth.ts +231 -0
- package/state-migrations/S0001_access.sql +48 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
-- `task` becomes the tenth `memory_type`, `task` the fourth `edge_class`, and `files` gains the two
|
|
2
|
+
-- columns a task carries. Recreate-and-copy, because SQLite cannot ALTER a CHECK constraint and both
|
|
3
|
+
-- tables carry one. Every existing row passes the WIDENED CHECKs, so the copies are lossless.
|
|
4
|
+
--
|
|
5
|
+
-- ── The children are snapshotted, and that is the load-bearing part of this file ──────────────────
|
|
6
|
+
--
|
|
7
|
+
-- Probed 2026-08-12 on node 24.19.0: `DROP TABLE files` DELETES every row of every child table
|
|
8
|
+
-- (`file_tags`, `file_entities`, `file_facets`, `file_citations`, `chunks`, and `embeddings` behind
|
|
9
|
+
-- `chunks`) via `ON DELETE CASCADE`, including inside the one `immediate` transaction the migration
|
|
10
|
+
-- runner wraps this file in, which does NOT protect them. A migration that merely copied `files` would
|
|
11
|
+
-- therefore report success and silently destroy every embedding in the database: thousands of Bedrock
|
|
12
|
+
-- calls for text that never changed, plus the whole edge set.
|
|
13
|
+
--
|
|
14
|
+
-- `PRAGMA foreign_keys = OFF` around the drop is no escape, and the probe above measured that too: the
|
|
15
|
+
-- pragma is a NO-OP inside a transaction, exactly as SQLite documents it, so the cascade fires anyway
|
|
16
|
+
-- and a file that relied on the pragma would be silent data loss with no error anywhere. (Outside a
|
|
17
|
+
-- transaction the pragma does suppress the cascade, which is not where a migration runs.)
|
|
18
|
+
-- The snapshot does not depend on foreign-key state at all: each child's rows are copied out, the
|
|
19
|
+
-- cascade fires against an empty-of-consequence table, and the rows are copied back under the new
|
|
20
|
+
-- parent. Verified after the fact: `PRAGMA foreign_key_check` is empty, `foreign_keys` is still ON,
|
|
21
|
+
-- an orphan child insert is still refused, and `ON UPDATE CASCADE` still carries a chunk through an
|
|
22
|
+
-- archive rename.
|
|
23
|
+
--
|
|
24
|
+
-- `files_fts` IS dropped explicitly. It is a separate virtual table, so `DROP TABLE files` does not
|
|
25
|
+
-- take it, and an external-content FTS5 table left pointing at a dropped content table is a stale
|
|
26
|
+
-- index that answers MATCH from rows the corpus no longer has. Its triggers need no drop. A trigger
|
|
27
|
+
-- belongs to the table it is defined ON, so `DROP TABLE files` takes all three. Both are recreated
|
|
28
|
+
-- from 0003_fts.sql's definitions at the end of this file, over the finished table.
|
|
29
|
+
|
|
30
|
+
CREATE TABLE files_next (
|
|
31
|
+
path TEXT PRIMARY KEY,
|
|
32
|
+
blob_sha TEXT NOT NULL,
|
|
33
|
+
content_hash TEXT NOT NULL,
|
|
34
|
+
-- Widened by exactly one value. `task` is a memory TYPE rather than a second axis: three
|
|
35
|
+
-- overlapping type vocabularies is what made the predecessor memory system's classification unanswerable, so a
|
|
36
|
+
-- task's different treatment is stated by the filters that read this column (default-excluded
|
|
37
|
+
-- from retrieval scope, skipped by every sleep phase) and never by a parallel `kind`.
|
|
38
|
+
memory_type TEXT NOT NULL CHECK (memory_type IN (
|
|
39
|
+
'episodic','semantic','procedural','agent_insight',
|
|
40
|
+
'user_preference','error_pattern','verdict','precedent','arc','task')),
|
|
41
|
+
title TEXT NOT NULL,
|
|
42
|
+
body_text TEXT NOT NULL,
|
|
43
|
+
gist TEXT NOT NULL DEFAULT '',
|
|
44
|
+
fts_text TEXT NOT NULL DEFAULT '',
|
|
45
|
+
disclosure_text TEXT NOT NULL DEFAULT '',
|
|
46
|
+
para TEXT NOT NULL CHECK (para IN ('projects','areas','resources','archive')),
|
|
47
|
+
workspace TEXT,
|
|
48
|
+
confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence BETWEEN 0 AND 1),
|
|
49
|
+
importance INTEGER NOT NULL DEFAULT 5 CHECK (importance BETWEEN 1 AND 10),
|
|
50
|
+
archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0,1)),
|
|
51
|
+
origin_path TEXT,
|
|
52
|
+
word_count INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
created_at TEXT NOT NULL,
|
|
54
|
+
updated_at TEXT NOT NULL,
|
|
55
|
+
event_at TEXT,
|
|
56
|
+
archived_at TEXT,
|
|
57
|
+
valid_from TEXT,
|
|
58
|
+
valid_until TEXT,
|
|
59
|
+
reprieves INTEGER NOT NULL DEFAULT 0 CHECK (reprieves >= 0),
|
|
60
|
+
needs_revision INTEGER NOT NULL DEFAULT 0 CHECK (needs_revision IN (0,1)),
|
|
61
|
+
author TEXT NOT NULL DEFAULT 'agent',
|
|
62
|
+
session_id TEXT,
|
|
63
|
+
prompt_id TEXT,
|
|
64
|
+
turn_uuid TEXT,
|
|
65
|
+
indexed_at TEXT NOT NULL,
|
|
66
|
+
-- A task's lifecycle position, from `memhtml-task-status`. NULL on every non-task, which the CHECK
|
|
67
|
+
-- admits: an IN-list CHECK passes NULL (probed), so one column serves both cases without a
|
|
68
|
+
-- type-conditional constraint the ten-value vocabulary would have to restate.
|
|
69
|
+
--
|
|
70
|
+
-- A SEPARATE axis from `archived`. `done` is stamped here AND the file is archived by the same
|
|
71
|
+
-- `git mv` every eviction uses, so every path that switches on active/archived keeps its meaning.
|
|
72
|
+
task_status TEXT CHECK (task_status IN ('todo','doing','blocked','done')),
|
|
73
|
+
-- When a task is due, from `memhtml-due`. An ISO date or datetime, compared and ordered AS A STRING
|
|
74
|
+
-- exactly as `event_at` is. @memhtml/html refuses a value that does not sort alongside the others,
|
|
75
|
+
-- which is what makes `due_at < ?` an overdue query rather than a per-row parse.
|
|
76
|
+
due_at TEXT
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
INSERT INTO files_next
|
|
80
|
+
SELECT path, blob_sha, content_hash, memory_type, title, body_text, gist, fts_text,
|
|
81
|
+
disclosure_text, para, workspace, confidence, importance, archived, origin_path,
|
|
82
|
+
word_count, created_at, updated_at, event_at, archived_at, valid_from, valid_until,
|
|
83
|
+
reprieves, needs_revision, author, session_id, prompt_id, turn_uuid, indexed_at,
|
|
84
|
+
NULL, NULL
|
|
85
|
+
FROM files;
|
|
86
|
+
|
|
87
|
+
CREATE TABLE file_tags_snap AS SELECT * FROM file_tags;
|
|
88
|
+
CREATE TABLE file_entities_snap AS SELECT * FROM file_entities;
|
|
89
|
+
CREATE TABLE file_facets_snap AS SELECT * FROM file_facets;
|
|
90
|
+
CREATE TABLE file_citations_snap AS SELECT * FROM file_citations;
|
|
91
|
+
CREATE TABLE chunks_snap AS SELECT * FROM chunks;
|
|
92
|
+
-- `embeddings` hangs off `chunks`, not off `files`, so it is lost one cascade further down. The
|
|
93
|
+
-- vector BLOB survives `CREATE TABLE … AS SELECT` at full length (probed: 8 bytes in, 8 out).
|
|
94
|
+
CREATE TABLE embeddings_snap AS SELECT * FROM embeddings;
|
|
95
|
+
|
|
96
|
+
DROP TABLE files_fts;
|
|
97
|
+
DROP TABLE files;
|
|
98
|
+
ALTER TABLE files_next RENAME TO files;
|
|
99
|
+
|
|
100
|
+
-- Parent first, then `chunks`, then `embeddings` behind it: the restore is FK-valid at every step
|
|
101
|
+
-- rather than relying on the constraints being off.
|
|
102
|
+
INSERT INTO file_tags SELECT * FROM file_tags_snap;
|
|
103
|
+
INSERT INTO file_entities SELECT * FROM file_entities_snap;
|
|
104
|
+
INSERT INTO file_facets SELECT * FROM file_facets_snap;
|
|
105
|
+
INSERT INTO file_citations SELECT * FROM file_citations_snap;
|
|
106
|
+
INSERT INTO chunks SELECT * FROM chunks_snap;
|
|
107
|
+
INSERT INTO embeddings SELECT * FROM embeddings_snap;
|
|
108
|
+
|
|
109
|
+
DROP TABLE file_tags_snap;
|
|
110
|
+
DROP TABLE file_entities_snap;
|
|
111
|
+
DROP TABLE file_facets_snap;
|
|
112
|
+
DROP TABLE file_citations_snap;
|
|
113
|
+
DROP TABLE chunks_snap;
|
|
114
|
+
DROP TABLE embeddings_snap;
|
|
115
|
+
|
|
116
|
+
-- Every index the old `files` carried, recreated. A dropped table takes its indexes with it, so an
|
|
117
|
+
-- index missing from this list is an index the database silently no longer has.
|
|
118
|
+
--
|
|
119
|
+
-- The dedup index gains `AND memory_type <> 'task'`. Two open tasks with identical bodies are
|
|
120
|
+
-- legitimately distinct work items ("review the deploy runbook" twice is two things to do), while
|
|
121
|
+
-- two identical active MEMORIES are one fact stored twice, which is what this index exists to
|
|
122
|
+
-- refuse. `dedupeLookup` (`traces-persist.ts` `activePathForHash`) carries the same exclusion, so
|
|
123
|
+
-- the write path's question and the database's answer agree by construction rather than by
|
|
124
|
+
-- discipline. Probed: two identical-hash open tasks are admitted, two identical-hash memories are
|
|
125
|
+
-- still refused, and a memory may share a hash with an open task without colliding.
|
|
126
|
+
CREATE UNIQUE INDEX files_content_hash_active ON files (content_hash)
|
|
127
|
+
WHERE archived = 0 AND memory_type <> 'task';
|
|
128
|
+
CREATE INDEX files_type_active ON files (memory_type) WHERE archived = 0;
|
|
129
|
+
CREATE INDEX files_workspace ON files (workspace) WHERE archived = 0;
|
|
130
|
+
CREATE INDEX files_para ON files (para);
|
|
131
|
+
CREATE INDEX files_updated ON files (updated_at) WHERE archived = 0;
|
|
132
|
+
CREATE INDEX files_event ON files (event_at) WHERE event_at IS NOT NULL;
|
|
133
|
+
CREATE INDEX files_session ON files (session_id) WHERE session_id IS NOT NULL;
|
|
134
|
+
CREATE INDEX files_ttl ON files (valid_until) WHERE valid_until IS NOT NULL AND archived = 0;
|
|
135
|
+
CREATE INDEX files_blob ON files (blob_sha);
|
|
136
|
+
|
|
137
|
+
-- The task list's own index. `memhtml task list` reads by status over live tasks and nothing else, and
|
|
138
|
+
-- the partial predicate keeps the index the size of the open work rather than of the corpus.
|
|
139
|
+
CREATE INDEX files_task_status ON files (task_status)
|
|
140
|
+
WHERE memory_type = 'task' AND archived = 0;
|
|
141
|
+
|
|
142
|
+
-- The lexical index and its triggers, rebuilt over the finished table. These must stay identical to
|
|
143
|
+
-- 0003_fts.sql: two definitions of one index that drifted would make a fresh store and a migrated one
|
|
144
|
+
-- rank differently, which no test comparing a store to itself would catch.
|
|
145
|
+
CREATE VIRTUAL TABLE files_fts USING fts5(
|
|
146
|
+
fts_text,
|
|
147
|
+
content='files',
|
|
148
|
+
content_rowid='rowid'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
-- The content table already holds every row, so the index is built in one pass here rather than
|
|
152
|
+
-- accumulated through the triggers.
|
|
153
|
+
INSERT INTO files_fts(files_fts) VALUES ('rebuild');
|
|
154
|
+
|
|
155
|
+
CREATE TRIGGER files_fts_insert AFTER INSERT ON files BEGIN
|
|
156
|
+
INSERT INTO files_fts(rowid, fts_text) VALUES (new.rowid, new.fts_text);
|
|
157
|
+
END;
|
|
158
|
+
|
|
159
|
+
CREATE TRIGGER files_fts_delete AFTER DELETE ON files BEGIN
|
|
160
|
+
INSERT INTO files_fts(files_fts, rowid, fts_text) VALUES ('delete', old.rowid, old.fts_text);
|
|
161
|
+
END;
|
|
162
|
+
|
|
163
|
+
CREATE TRIGGER files_fts_update AFTER UPDATE OF fts_text ON files BEGIN
|
|
164
|
+
INSERT INTO files_fts(files_fts, rowid, fts_text) VALUES ('delete', old.rowid, old.fts_text);
|
|
165
|
+
INSERT INTO files_fts(rowid, fts_text) VALUES (new.rowid, new.fts_text);
|
|
166
|
+
END;
|
|
167
|
+
|
|
168
|
+
-- ── edges: the fourth class ──────────────────────────────────────────────────────────────────────
|
|
169
|
+
--
|
|
170
|
+
-- Nothing references `edges`, so a plain recreate-and-copy loses nothing. Its rows all carry one of
|
|
171
|
+
-- the three existing classes and pass the widened CHECK unchanged.
|
|
172
|
+
|
|
173
|
+
CREATE TABLE edges_next (
|
|
174
|
+
src_path TEXT NOT NULL,
|
|
175
|
+
rel TEXT NOT NULL,
|
|
176
|
+
dst_path TEXT NOT NULL,
|
|
177
|
+
-- The four classes do not mix. A person or TASK edge is structurally incapable of entering
|
|
178
|
+
-- PageRank, MMR, or the retention bridge count, because every memory-graph query filters on this
|
|
179
|
+
-- column and the CHECKs below refuse a rel from another class. Task topology is working state: a
|
|
180
|
+
-- `blocks` edge reaching PageRank would let an agent's to-do list reweight the retention of its
|
|
181
|
+
-- knowledge.
|
|
182
|
+
edge_class TEXT NOT NULL DEFAULT 'memory'
|
|
183
|
+
CHECK (edge_class IN ('memory','person','provenance','task')),
|
|
184
|
+
derived INTEGER NOT NULL DEFAULT 0 CHECK (derived IN (0,1)),
|
|
185
|
+
strength REAL NOT NULL DEFAULT 1.0 CHECK (strength BETWEEN 0 AND 1),
|
|
186
|
+
provenance TEXT NOT NULL DEFAULT 'authored'
|
|
187
|
+
CHECK (provenance IN ('authored','sleep','import')),
|
|
188
|
+
sleep_run TEXT,
|
|
189
|
+
src_hash TEXT,
|
|
190
|
+
dst_hash TEXT,
|
|
191
|
+
created_at TEXT NOT NULL,
|
|
192
|
+
PRIMARY KEY (src_path, rel, dst_path),
|
|
193
|
+
CHECK (src_path <> dst_path),
|
|
194
|
+
CHECK (edge_class <> 'memory' OR rel IN (
|
|
195
|
+
'supersedes','contradicts','caused_by','leads_to','part_of',
|
|
196
|
+
'relates_to','example_of','supports','laterally_related')),
|
|
197
|
+
CHECK (edge_class <> 'person' OR rel IN ('about_person','authored_by')),
|
|
198
|
+
CHECK (edge_class <> 'provenance' OR rel IN ('from_session')),
|
|
199
|
+
CHECK (edge_class <> 'task' OR rel IN ('blocks','subtask_of')),
|
|
200
|
+
CHECK (derived = 0 OR provenance = 'sleep')
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
INSERT INTO edges_next
|
|
204
|
+
SELECT src_path, rel, dst_path, edge_class, derived, strength, provenance, sleep_run,
|
|
205
|
+
src_hash, dst_hash, created_at
|
|
206
|
+
FROM edges;
|
|
207
|
+
|
|
208
|
+
DROP TABLE edges;
|
|
209
|
+
ALTER TABLE edges_next RENAME TO edges;
|
|
210
|
+
|
|
211
|
+
CREATE INDEX edges_src ON edges (src_path, edge_class) WHERE derived = 0;
|
|
212
|
+
CREATE INDEX edges_dst ON edges (dst_path, edge_class) WHERE derived = 0;
|
|
213
|
+
CREATE INDEX edges_rel ON edges (rel, edge_class);
|
|
214
|
+
CREATE INDEX edges_derived ON edges (derived, rel);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- `files` gains `frame_key`: the claim's SLOT, derived from its gist at projection time by
|
|
2
|
+
-- `@memhtml/domain`'s `frameKeyOf`. Two active memories sharing a frame key state the same relation with
|
|
3
|
+
-- (possibly) different values, which is what makes a contradiction findable by one indexed lookup
|
|
4
|
+
-- instead of an O(corpus) scan or an LLM pass over every pair.
|
|
5
|
+
--
|
|
6
|
+
-- ── ADDITIVE, unlike 0008 ────────────────────────────────────────────────────────────────────────
|
|
7
|
+
--
|
|
8
|
+
-- `ALTER TABLE … ADD COLUMN` plus one index, and deliberately NOT the recreate-and-copy 0008 used.
|
|
9
|
+
-- That pattern was forced by a CHECK-constraint edit (SQLite cannot ALTER a CHECK) and it carried
|
|
10
|
+
-- real risk: `DROP TABLE files` cascades to every child down to `embeddings`, so 0008 had to snapshot
|
|
11
|
+
-- and restore six tables to avoid re-paying Bedrock for the whole corpus. A nullable column with no
|
|
12
|
+
-- constraint needs none of that. Copying 0008's shape here would take on all of its danger to buy
|
|
13
|
+
-- nothing, and this file must not be read as evidence that recreate-and-copy is the house style.
|
|
14
|
+
--
|
|
15
|
+
-- ── NOT UNIQUE, and that is the design, not an omission ──────────────────────────────────────────
|
|
16
|
+
--
|
|
17
|
+
-- Multiple ACTIVE rows may share a frame key, today and permanently. Two memories claiming different
|
|
18
|
+
-- values for one slot are exactly what the conflict assist exists to REPORT, so a unique index would
|
|
19
|
+
-- refuse the write that produces the signal. The corpus would stay clean by never recording the
|
|
20
|
+
-- disagreement, and the assist would have nothing to find. The eval's gold on MAB Conflict_Resolution
|
|
21
|
+
-- IS the contradiction pair. The assist reports; it never blocks.
|
|
22
|
+
--
|
|
23
|
+
-- ── The index predicate mirrors the lookup's ─────────────────────────────────────────────────────
|
|
24
|
+
--
|
|
25
|
+
-- `archived = 0 AND memory_type <> 'task' AND frame_key IS NOT NULL`, matching `activeFramesFor`
|
|
26
|
+
-- (`traces-persist.ts`) clause for clause, on the same reasoning `files_content_hash_active` and
|
|
27
|
+
-- `activePathForHash` share: the query is the question and the index is the answer, so a predicate on
|
|
28
|
+
-- one and not the other is a lookup that scans instead of seeks, silently and only under load.
|
|
29
|
+
--
|
|
30
|
+
-- * `archived = 0`: an evicted memory is not a live claim, so it cannot contradict one.
|
|
31
|
+
-- * `memory_type <> 'task'`: the same carve-out `files_content_hash_active` carries. Two open
|
|
32
|
+
-- tasks phrased alike are two real work items ("the owner of the deploy runbook is Priya" as a
|
|
33
|
+
-- to-do, twice), not a contradiction to report.
|
|
34
|
+
-- * `frame_key IS NOT NULL`: most gists have no frame shape (the guards fail closed), so this
|
|
35
|
+
-- keeps the index the size of the KEYED rows rather than of the corpus.
|
|
36
|
+
--
|
|
37
|
+
-- ── Existing rows arrive NULL, and self-heal ─────────────────────────────────────────────────────
|
|
38
|
+
--
|
|
39
|
+
-- `ADD COLUMN` gives every existing row NULL, and this migration does NOT backfill. It cannot: the
|
|
40
|
+
-- key derives from the gist through TypeScript, and SQL cannot call `frameKeyOf`. A SQL
|
|
41
|
+
-- reimplementation of the regex would be a second copy of a measured heuristic, free to drift from
|
|
42
|
+
-- the one the eval validated.
|
|
43
|
+
--
|
|
44
|
+
-- No backfill is needed, because `index.db` is a disposable projection of the git tree. `memhtml index
|
|
45
|
+
-- rebuild` recomputes every row from the files and every `frame_key` lands populated; short of that,
|
|
46
|
+
-- each file's next update rewrites its own row through the projection's upsert. So the column fills
|
|
47
|
+
-- in on the next rebuild or the next touch, and until then a NULL simply means "not yet keyed". The
|
|
48
|
+
-- assist finds fewer conflicts, never wrong ones, because the lookup requires a non-NULL match on
|
|
49
|
+
-- both sides.
|
|
50
|
+
|
|
51
|
+
ALTER TABLE files ADD COLUMN frame_key TEXT;
|
|
52
|
+
|
|
53
|
+
CREATE INDEX files_frame_key_active ON files (frame_key)
|
|
54
|
+
WHERE archived = 0 AND memory_type <> 'task' AND frame_key IS NOT NULL;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- The trace-consolidation watermark: which sessions the sleep cycle has already distilled.
|
|
2
|
+
--
|
|
3
|
+
-- ── Run state, not an index projection ───────────────────────────────────────────────────────────
|
|
4
|
+
--
|
|
5
|
+
-- The same category as `sleep_runs`/`sleep_phases` (0006), and NOT the category of `files` or
|
|
6
|
+
-- `chunks`. A projection row is a pure function of the git tree and is deleted and recomputed by
|
|
7
|
+
-- every `memhtml index rebuild`; this row records that a MODEL CALL happened, which no tree can restate.
|
|
8
|
+
--
|
|
9
|
+
-- It therefore survives a rebuild, and by construction rather than by exemption: `rebuild` empties
|
|
10
|
+
-- exactly `MEMORY_TABLES` (`packages/index/src/schema-const.ts:48-57`, applied at
|
|
11
|
+
-- `packages/index/src/indexer.ts:408`) and never drops or recreates the database file, so a table
|
|
12
|
+
-- absent from that list is untouched. Verified the same way `sleep_runs` is.
|
|
13
|
+
--
|
|
14
|
+
-- If the file itself is ever deleted, every watermark goes with it and the next cycle reconsolidates
|
|
15
|
+
-- the sessions it can still see. That is wasteful (it re-pays Opus for transcripts already read)
|
|
16
|
+
-- and it is SAFE, which is the ordering that matters: the phase writes memories through the same
|
|
17
|
+
-- reviewable-commit discipline as every other sleep mutation, so a duplicate candidate is a commit a
|
|
18
|
+
-- reviewer declines, never a corruption. Nothing here is load-bearing for correctness.
|
|
19
|
+
--
|
|
20
|
+
-- ── One row per session, and no foreign key ──────────────────────────────────────────────────────
|
|
21
|
+
--
|
|
22
|
+
-- `session_id` is the primary key because "has this session been consolidated" is a per-session
|
|
23
|
+
-- question with one answer; a second consolidation of one session overwrites its row and moves
|
|
24
|
+
-- `run_id`, which is what makes a reconsolidation after a lost database file idempotent in shape.
|
|
25
|
+
--
|
|
26
|
+
-- No `REFERENCES traces (session_id)`, for the reason `memory_session_links` states in 0005: the
|
|
27
|
+
-- trace plane is a rebuildable index over `~/.claude/projects`, so a session's `traces` row can be
|
|
28
|
+
-- rebuilt away (its transcript rotated, its directory pruned) while the fact that the cycle already
|
|
29
|
+
-- read it stays true. A foreign key would delete the watermark and invite a re-read of a file that
|
|
30
|
+
-- may no longer exist.
|
|
31
|
+
|
|
32
|
+
CREATE TABLE trace_consolidations (
|
|
33
|
+
session_id TEXT PRIMARY KEY,
|
|
34
|
+
-- The sleep run that distilled it, e.g. `sleep/2026-08-08`. Reporting and provenance only:
|
|
35
|
+
-- nothing reads this to decide anything, the same posture 0006's tables carry.
|
|
36
|
+
run_id TEXT NOT NULL,
|
|
37
|
+
-- The run's own instant, not a clock read at insert time: a phase derives every stamp from the
|
|
38
|
+
-- injected run date (`packages/sleep/src/env.ts:60-67`), so two runs of one date agree here.
|
|
39
|
+
consolidated_at TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- The unconsolidated-session query is an anti-join FROM `traces`, so it seeks this table by its
|
|
43
|
+
-- primary key and needs no second index. `consolidated_at` is ordered only in a report, over a table
|
|
44
|
+
-- whose row count is bounded by the number of sessions ever consolidated.
|
|
45
|
+
CREATE INDEX trace_consolidations_run ON trace_consolidations (run_id);
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "memhtml",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An agent's long-term memory: one fact per semantic HTML file in git, four-arm retrieval, and a nightly sleep cycle.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"memory",
|
|
7
|
+
"agent",
|
|
8
|
+
"mcp",
|
|
9
|
+
"retrieval",
|
|
10
|
+
"sqlite",
|
|
11
|
+
"git",
|
|
12
|
+
"semantic-html",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/memhtml/memhtml",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/memhtml/memhtml/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/memhtml/memhtml.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=24"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"memhtml": "./dist/memhtml.mjs",
|
|
30
|
+
"memhtml-mcp": "./dist/memhtml-mcp.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"migrations",
|
|
35
|
+
"state-migrations",
|
|
36
|
+
"guest",
|
|
37
|
+
"agent",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@ai-sdk/amazon-bedrock": "5.0.53",
|
|
44
|
+
"@aws-sdk/client-bedrock-runtime": "3.1108.0",
|
|
45
|
+
"@aws/bedrock-token-generator": "1.1.0",
|
|
46
|
+
"@effect/platform-node": "4.0.0-beta.107",
|
|
47
|
+
"ai": "7.0.61",
|
|
48
|
+
"effect": "4.0.0-beta.107",
|
|
49
|
+
"eve": "0.33.0",
|
|
50
|
+
"highlight.js": "11.11.2",
|
|
51
|
+
"just-bash": "3.2.0",
|
|
52
|
+
"node-html-parser": "9.0.1",
|
|
53
|
+
"parse5": "8.0.1",
|
|
54
|
+
"zod": "4.4.3"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { cp, mkdir, readdir, readFile, symlink, writeFile } from "node:fs/promises"
|
|
4
|
+
import { createRequire } from "node:module"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import { dirname, join, resolve } from "node:path"
|
|
7
|
+
import { Effect } from "effect"
|
|
8
|
+
|
|
9
|
+
import { ConsolidatorUnavailable } from "./contract.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where `eve build` may run, which is not always where this package is installed.
|
|
13
|
+
*
|
|
14
|
+
* eve is filesystem-first: `eve build` compiles `agent/` — and the `../../src/*.ts` it reaches — into
|
|
15
|
+
* `.output/`, and `eve start` serves that directory. In a checkout that is `pnpm build:agent` writing
|
|
16
|
+
* into the package itself, and it works.
|
|
17
|
+
*
|
|
18
|
+
* From an INSTALLED package it does not, and the failure is worse than an error: the build succeeds and
|
|
19
|
+
* the server it produces cannot boot. Measured 2026-08-17 against an npm-installed tarball —
|
|
20
|
+
* `eve build` exited 0, then `eve start` exited 13 on `Detected unsettled top-level await ... await
|
|
21
|
+
* workflowWorld.start?.()`. The discriminator is the tree's LOCATION, not its contents: nitro
|
|
22
|
+
* externalizes any module resolved from inside `node_modules`, so an installed `@memhtml/consolidator`
|
|
23
|
+
* became a traced lib chunk (`server/index.mjs` 17.3 kB beside a 4.73 MB `_libs/@memhtml/…` chunk),
|
|
24
|
+
* while the same sources built from a checkout were inlined (`index.mjs` 317 kB) and answered
|
|
25
|
+
* `/eve/v1/health` with `{"ok":true,"status":"ready"}` in ~2s.
|
|
26
|
+
*
|
|
27
|
+
* So the agent tree is COPIED out to a cache directory and built there, where nothing above it is
|
|
28
|
+
* named `node_modules` and nitro inlines it. Shipping a prebuilt `.output/` in the tarball is the other
|
|
29
|
+
* candidate and is refused: the build traces native binaries into it
|
|
30
|
+
* (`server/node_modules/node-liblzma/build/Release/node_lzma.node`) and eve says so itself — "Ensure
|
|
31
|
+
* your production environment matches the builder OS and architecture (linux-x64)". A published
|
|
32
|
+
* artifact cannot carry one platform's binaries.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* eve's CLI entry point, or `null` when eve does not resolve from here.
|
|
37
|
+
*
|
|
38
|
+
* Spawned as `process.execPath <path>` rather than through a package manager, because a consumer who
|
|
39
|
+
* installed this package has whatever manager they used and need not have any particular one on PATH.
|
|
40
|
+
* `apps/cli/src/serve.ts` spawns the MCP server the same way, for the same reason.
|
|
41
|
+
*
|
|
42
|
+
* Resolution goes through the MANIFEST, not the bin. `resolve("eve/bin/eve.js")` raises
|
|
43
|
+
* `ERR_PACKAGE_PATH_NOT_EXPORTED`: eve's `exports` map declares no `./bin/*` subpath, so node refuses
|
|
44
|
+
* the deep path even though the file is there (probed against eve 0.33.0). `./package.json` IS
|
|
45
|
+
* exported, and the `bin` field beside it names the entry point.
|
|
46
|
+
*/
|
|
47
|
+
export const eveBinPath = (): string | null => {
|
|
48
|
+
const require = createRequire(import.meta.url)
|
|
49
|
+
let manifestPath: string
|
|
50
|
+
try {
|
|
51
|
+
manifestPath = require.resolve("eve/package.json")
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
const { bin } = require(manifestPath) as { readonly bin?: Record<string, string> | string }
|
|
56
|
+
const entry = typeof bin === "string" ? bin : bin?.eve
|
|
57
|
+
return entry === undefined ? null : resolve(dirname(manifestPath), entry)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Per-version, so an upgrade builds fresh instead of serving the previous release's output. */
|
|
61
|
+
const cacheRootFor = (version: string): string =>
|
|
62
|
+
join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "memhtml", "eve", version)
|
|
63
|
+
|
|
64
|
+
/** A bare specifier's package name: two segments when scoped, one otherwise. */
|
|
65
|
+
const packageOf = (specifier: string): string => {
|
|
66
|
+
const parts = specifier.split("/")
|
|
67
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every package the staged tree imports, read from the tree rather than from a manifest.
|
|
72
|
+
*
|
|
73
|
+
* A manifest looks like the obvious source and is the wrong one twice over. The published package is
|
|
74
|
+
* assembled with its `@memhtml/*` edges resolved as siblings and its `dependencies` field deliberately
|
|
75
|
+
* empty — declaring them inside a bundled manifest makes npm create phantom empty directories in the
|
|
76
|
+
* vendored subtree, which poisons resolution for every sibling (probed 2026-08-17: an empty
|
|
77
|
+
* `memhtml/node_modules/effect` made `import "effect"` fail from every vendored package). And the
|
|
78
|
+
* agent tree's real requirement is what it IMPORTS, which is a subset a manifest cannot narrow to.
|
|
79
|
+
*
|
|
80
|
+
* So the specifiers are read off the files eve is about to compile. Relative imports resolve inside the
|
|
81
|
+
* staged tree and `node:` builtins need nothing, so neither is linked.
|
|
82
|
+
*/
|
|
83
|
+
const importedPackages = async (roots: ReadonlyArray<string>): Promise<ReadonlyArray<string>> => {
|
|
84
|
+
const found = new Set<string>()
|
|
85
|
+
const pattern = /(?:from|import|require)\s*\(?\s*["']([^"']+)["']/g
|
|
86
|
+
for (const root of roots) {
|
|
87
|
+
for (const file of await sourceFiles(root)) {
|
|
88
|
+
const text = await readFile(file, "utf8")
|
|
89
|
+
for (const [, specifier] of text.matchAll(pattern)) {
|
|
90
|
+
if (specifier === undefined) continue
|
|
91
|
+
if (specifier.startsWith(".") || specifier.startsWith("/")) continue
|
|
92
|
+
if (specifier.startsWith("node:")) continue
|
|
93
|
+
found.add(packageOf(specifier))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...found].sort()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every `.ts` file under a directory, at any depth. */
|
|
101
|
+
const sourceFiles = async (root: string): Promise<ReadonlyArray<string>> => {
|
|
102
|
+
if (!existsSync(root)) return []
|
|
103
|
+
const out: string[] = []
|
|
104
|
+
for (const entry of await readdir(root, { withFileTypes: true, recursive: true })) {
|
|
105
|
+
if (entry.isFile() && entry.name.endsWith(".ts")) out.push(join(entry.parentPath, entry.name))
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const packageVersion = async (packageRoot: string): Promise<string> => {
|
|
111
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")) as {
|
|
112
|
+
readonly version?: string
|
|
113
|
+
}
|
|
114
|
+
return manifest.version ?? "0.0.0"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where a dependency's directory actually is, found the way node finds it.
|
|
119
|
+
*
|
|
120
|
+
* `require.resolve("<name>/package.json")` is the obvious route and is not enough: an `exports` map
|
|
121
|
+
* that does not list `./package.json` makes node refuse the subpath, and two of this package's own
|
|
122
|
+
* dependencies are like that — `@memhtml/contracts` and `just-bash` both answer
|
|
123
|
+
* `ERR_PACKAGE_PATH_NOT_EXPORTED` (probed 2026-08-17). Walking the ancestors' `node_modules` asks the
|
|
124
|
+
* filesystem instead of the resolver, so an exports map cannot hide a directory that is plainly there.
|
|
125
|
+
*
|
|
126
|
+
* The walk covers every layout this ships into: pnpm's per-package symlink farm, npm's hoisted
|
|
127
|
+
* top-level tree, and the vendored single-package tarball, where `@memhtml/*` sit one `node_modules`
|
|
128
|
+
* in and the externals one further up.
|
|
129
|
+
*/
|
|
130
|
+
const dependencyDir = (fromDir: string, name: string): string | null => {
|
|
131
|
+
let at = fromDir
|
|
132
|
+
for (;;) {
|
|
133
|
+
const candidate = join(at, "node_modules", name)
|
|
134
|
+
if (existsSync(join(candidate, "package.json"))) return candidate
|
|
135
|
+
const up = dirname(at)
|
|
136
|
+
if (up === at) return null
|
|
137
|
+
at = up
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Link every package the staged tree imports into the cache directory.
|
|
143
|
+
*
|
|
144
|
+
* A cache directory under `~/.cache` has no ancestor holding this package's dependencies — which is
|
|
145
|
+
* the entire point of building outside `node_modules` — so node's upward walk from there finds nothing.
|
|
146
|
+
* One symlink per imported package reproduces the module graph the installed package already has,
|
|
147
|
+
* resolved from `packageRoot` because that is where the real tree is.
|
|
148
|
+
*/
|
|
149
|
+
const linkDependencies = async (input: {
|
|
150
|
+
readonly packageRoot: string
|
|
151
|
+
readonly cacheRoot: string
|
|
152
|
+
}): Promise<void> => {
|
|
153
|
+
const { packageRoot, cacheRoot } = input
|
|
154
|
+
const names = await importedPackages([join(cacheRoot, "agent"), join(cacheRoot, "src")])
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
const from = dependencyDir(packageRoot, name)
|
|
157
|
+
// A package that is not on disk is the build's problem to report, not this step's: eve names the
|
|
158
|
+
// unresolved import, which is a better message than anything guessable here.
|
|
159
|
+
if (from === null) continue
|
|
160
|
+
const to = join(cacheRoot, "node_modules", name)
|
|
161
|
+
if (existsSync(to)) continue
|
|
162
|
+
await mkdir(dirname(to), { recursive: true })
|
|
163
|
+
await symlink(from, to, "dir")
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Copy the buildable tree into `cacheRoot`, ready for `eve build`.
|
|
169
|
+
*
|
|
170
|
+
* Exported because this is the half a reader can get subtly wrong and the half that needs no 17 MB
|
|
171
|
+
* build to check: `agent/` reaches `../../src/*.js`, so the two directories travel TOGETHER and at
|
|
172
|
+
* their original depth. Flattening them, or staging `agent/` alone, produces the
|
|
173
|
+
* `UNRESOLVED_IMPORT` that a missing `src/` in the tarball already produced once.
|
|
174
|
+
*/
|
|
175
|
+
export const stageAgentTree = async (input: {
|
|
176
|
+
readonly packageRoot: string
|
|
177
|
+
readonly cacheRoot: string
|
|
178
|
+
readonly version: string
|
|
179
|
+
}): Promise<void> => {
|
|
180
|
+
const { packageRoot, cacheRoot, version } = input
|
|
181
|
+
await mkdir(cacheRoot, { recursive: true })
|
|
182
|
+
await cp(join(packageRoot, "agent"), join(cacheRoot, "agent"), { recursive: true })
|
|
183
|
+
await cp(join(packageRoot, "src"), join(cacheRoot, "src"), { recursive: true })
|
|
184
|
+
await writeFile(
|
|
185
|
+
join(cacheRoot, "package.json"),
|
|
186
|
+
`${JSON.stringify(
|
|
187
|
+
{ name: "memhtml-consolidator-agent", version, private: true, type: "module" },
|
|
188
|
+
null,
|
|
189
|
+
2
|
|
190
|
+
)}\n`
|
|
191
|
+
)
|
|
192
|
+
await linkDependencies({ packageRoot, cacheRoot })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const runEveBuild = (input: {
|
|
196
|
+
readonly eveBin: string
|
|
197
|
+
readonly cwd: string
|
|
198
|
+
}): Effect.Effect<void, ConsolidatorUnavailable> =>
|
|
199
|
+
Effect.callback<void, ConsolidatorUnavailable>((resume) => {
|
|
200
|
+
const child = spawn(process.execPath, [input.eveBin, "build"], {
|
|
201
|
+
cwd: input.cwd,
|
|
202
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
203
|
+
})
|
|
204
|
+
let stderr = ""
|
|
205
|
+
child.stderr.setEncoding("utf8")
|
|
206
|
+
child.stderr.on("data", (chunk: string) => {
|
|
207
|
+
stderr += chunk
|
|
208
|
+
})
|
|
209
|
+
child.once("error", (cause) => {
|
|
210
|
+
resume(
|
|
211
|
+
Effect.fail(
|
|
212
|
+
ConsolidatorUnavailable.make({ reason: `could not spawn eve build: ${String(cause)}` })
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
child.once("exit", (code) => {
|
|
217
|
+
resume(
|
|
218
|
+
code === 0
|
|
219
|
+
? Effect.void
|
|
220
|
+
: Effect.fail(
|
|
221
|
+
ConsolidatorUnavailable.make({
|
|
222
|
+
reason: `eve build exited with code ${String(code)} in ${input.cwd}. ${stderr.slice(-400)}`
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
return Effect.sync(() => {
|
|
228
|
+
child.kill("SIGKILL")
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The directory `eve start` will be run in, building the agent first when nothing has.
|
|
234
|
+
*
|
|
235
|
+
* Order is deliberate. An explicit `appRoot` is an operator's choice and is never second-guessed. A
|
|
236
|
+
* package that already holds `.output/` is a checkout where `build:agent` has run, and reusing it keeps
|
|
237
|
+
* development behaviour byte-identical. Only the remaining case — an installed package with no output —
|
|
238
|
+
* materializes the cache directory, and it costs one ~17 MB build per version rather than one per run.
|
|
239
|
+
*/
|
|
240
|
+
export const resolveAgentAppRoot = (input: {
|
|
241
|
+
readonly packageRoot: string
|
|
242
|
+
readonly configured?: string | undefined
|
|
243
|
+
readonly eveBin: string
|
|
244
|
+
}): Effect.Effect<string, ConsolidatorUnavailable> =>
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
const { packageRoot, configured, eveBin } = input
|
|
247
|
+
if (configured !== undefined) return configured
|
|
248
|
+
if (existsSync(join(packageRoot, ".output"))) return packageRoot
|
|
249
|
+
|
|
250
|
+
const version = yield* Effect.tryPromise({
|
|
251
|
+
try: () => packageVersion(packageRoot),
|
|
252
|
+
catch: (cause) =>
|
|
253
|
+
ConsolidatorUnavailable.make({
|
|
254
|
+
reason: `could not read the consolidator's version: ${String(cause)}`
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
const cacheRoot = cacheRootFor(version)
|
|
258
|
+
if (existsSync(join(cacheRoot, ".output"))) return cacheRoot
|
|
259
|
+
|
|
260
|
+
yield* Effect.logInfo(`building the consolidator agent into ${cacheRoot} (once per version)`)
|
|
261
|
+
yield* Effect.tryPromise({
|
|
262
|
+
try: () => stageAgentTree({ packageRoot, cacheRoot, version }),
|
|
263
|
+
catch: (cause) =>
|
|
264
|
+
ConsolidatorUnavailable.make({
|
|
265
|
+
reason: `could not stage the consolidator agent in ${cacheRoot}: ${String(cause)}`
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
yield* runEveBuild({ eveBin, cwd: cacheRoot })
|
|
270
|
+
|
|
271
|
+
if (!existsSync(join(cacheRoot, ".output"))) {
|
|
272
|
+
return yield* Effect.fail(
|
|
273
|
+
ConsolidatorUnavailable.make({ reason: `eve build wrote no .output/ in ${cacheRoot}` })
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
return cacheRoot
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
/** Exported for the tests that assert the location, which is the part a reader can get wrong. */
|
|
280
|
+
export const agentCacheRootFor = (version: string): string => resolve(cacheRootFor(version))
|