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.
@@ -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))