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
package/guest/corpus.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The code-mode helper, as it runs INSIDE the `memhtml exec` sandbox.
|
|
3
|
+
*
|
|
4
|
+
* Not TypeScript and not built: this file is read as BYTES by `apps/cli/src/exec.ts` and written into
|
|
5
|
+
* the guest filesystem verbatim, where `js-exec` loads it under QuickJS. A `.ts` source would have to
|
|
6
|
+
* survive `tsc`'s emit and land in `dist/` in a shape the guest's loader accepts, which buys nothing
|
|
7
|
+
* — nothing on the host ever imports this module — and costs the one property that matters: what a
|
|
8
|
+
* reader sees here is exactly what the guest executes.
|
|
9
|
+
*
|
|
10
|
+
* `docs/code-mode.md` is the cookbook whose recipes this serves, and the field set below is that
|
|
11
|
+
* document's `Memhtml` interface. Its parser is cheerio because its runtime is bun; here the runtime is
|
|
12
|
+
* QuickJS and the parser is `node-html-parser` (see {@link module:parse}), so the LIBRARY differs
|
|
13
|
+
* while every selector carries over — the contract is the closed vocabulary, not the library.
|
|
14
|
+
*
|
|
15
|
+
* Three facts about this guest, each measured against just-bash 3.2.0 on 2026-08-09 rather than
|
|
16
|
+
* assumed from Node's semantics:
|
|
17
|
+
*
|
|
18
|
+
* 1. **`statSync(...).isDirectory` is a boolean PROPERTY here, not a method.** Node-shaped code
|
|
19
|
+
* calling `.isDirectory()` throws "not a function". {@link isDirectory} handles both shapes, so
|
|
20
|
+
* this file also runs unchanged under Node — which is what lets a test parse the same corpus twice
|
|
21
|
+
* and compare.
|
|
22
|
+
* 2. **Edge `href`s are root-absolute** (`/areas/x.html`) while a directory walk yields paths under
|
|
23
|
+
* the mount. {@link corpus} keys by the href convention for exactly this reason: the spike's first
|
|
24
|
+
* traversal reported `0/410 edges resolved`, which reads as a finding about the corpus and was
|
|
25
|
+
* entirely a normalization bug on the reading side.
|
|
26
|
+
* 3. **`atob` does not exist.** QuickJS ships no base64 builtins and `node-html-parser` decodes a
|
|
27
|
+
* base64 entity table at load, so the host installs a shim through `javascript.bootstrap` before
|
|
28
|
+
* this module is ever imported. Probed: without it the parser fails at load with
|
|
29
|
+
* "'atob' is not defined".
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import * as fs from "node:fs"
|
|
33
|
+
import { parse } from "/workspace/lib/nhp.mjs"
|
|
34
|
+
|
|
35
|
+
/** Where `memhtml exec` mounts the corpus. Matches `CORPUS_MOUNT` in `apps/cli/src/exec.ts`. */
|
|
36
|
+
export const ROOT = "/mnt/memhtml"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when `path` is a directory, under either `statSync` shape.
|
|
40
|
+
*
|
|
41
|
+
* The QuickJS `node:fs` shim returns `isDirectory` as a boolean property while Node returns a method.
|
|
42
|
+
* Both are handled so this file is one implementation rather than a guest fork of a host helper.
|
|
43
|
+
*/
|
|
44
|
+
const isDirectory = (path) => {
|
|
45
|
+
const stats = fs.statSync(path)
|
|
46
|
+
return typeof stats.isDirectory === "function" ? stats.isDirectory() : stats.isDirectory === true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every `.html` file under `dir`, recursively, in `readdirSync` order.
|
|
51
|
+
*
|
|
52
|
+
* `index.html` is skipped because it is GENERATED — `memhtml publish` writes one per directory as a
|
|
53
|
+
* listing (`packages/store/src/layout.ts`), and counting them as memories inflates every census by
|
|
54
|
+
* the directory count and pollutes an entity tally with link text. The fixture corpus happens to
|
|
55
|
+
* contain none, so a test built only on the fixture would not catch a regression here; a real
|
|
56
|
+
* `$MEMHTML_ROOT` has one per directory.
|
|
57
|
+
*
|
|
58
|
+
* `.git` is skipped because the corpus is the HTML tree and git's own storage is not part of it. The
|
|
59
|
+
* name covers both shapes it takes: a directory when a plain root is mounted, and a FILE when a
|
|
60
|
+
* pinned worktree is (`git worktree add` writes a `.git` file naming the parent repo). Two reasons,
|
|
61
|
+
* and the first is correctness: a ref is a file at a path the author chooses, so a branch named
|
|
62
|
+
* `foo.html` lands at `.git/refs/heads/foo.html` and is counted as a memory whose every field is
|
|
63
|
+
* empty — a census one higher than any grep of the corpus reports. The second is cost, and it is
|
|
64
|
+
* paid per entry: every name here is one round-trip across the sandbox's synchronous host bridge, and
|
|
65
|
+
* 578 of the 305-memory fixture's 935 entries are git's (measured 2026-08-14), so the walk spent 62%
|
|
66
|
+
* of its calls on files no recipe can read.
|
|
67
|
+
*/
|
|
68
|
+
export const walk = (dir = ROOT, found = []) => {
|
|
69
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
70
|
+
if (entry === ".git") continue
|
|
71
|
+
const path = `${dir}/${entry}`
|
|
72
|
+
if (isDirectory(path)) walk(path, found)
|
|
73
|
+
else if (path.endsWith(".html") && entry !== "index.html") found.push(path)
|
|
74
|
+
}
|
|
75
|
+
return found
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The href convention: root-absolute, so a parsed path and an edge target are the same string. */
|
|
79
|
+
const idFor = (path, root) => (path.startsWith(`${root}/`) ? path.slice(root.length) : path)
|
|
80
|
+
|
|
81
|
+
/** Every `content` of a repeated `meta[name=…]`, blanks dropped. */
|
|
82
|
+
const metaValues = (document, name) =>
|
|
83
|
+
document
|
|
84
|
+
.querySelectorAll(`meta[name="${name}"]`)
|
|
85
|
+
.map((element) => element.getAttribute("content") ?? "")
|
|
86
|
+
.filter((value) => value !== "")
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One memory, read to the planes the closed vocabulary guarantees.
|
|
90
|
+
*
|
|
91
|
+
* `claim` uses `article mark`, a DESCENDANT selector. The markup is `<article><p><mark>`
|
|
92
|
+
* (`docs/code-mode.md`), so `article > mark` matches NOTHING — a helper written from ROADMAP item 7's
|
|
93
|
+
* prose, which says `article > mark`, silently reports zero claims on every file. Measured on the
|
|
94
|
+
* 305-file fixture: `article mark` finds 305, `article > mark` finds 0.
|
|
95
|
+
*
|
|
96
|
+
* `document` is returned as an escape hatch. Every field here is a projection the cookbook's recipes
|
|
97
|
+
* needed, and a question none of them covers is answerable with the same parser rather than by
|
|
98
|
+
* editing this file — which is the whole reason code-mode exists instead of a fixed tool per question.
|
|
99
|
+
*/
|
|
100
|
+
export const memoryAt = (path, root = ROOT) => {
|
|
101
|
+
const document = parse(fs.readFileSync(path, "utf8"))
|
|
102
|
+
const facets = {}
|
|
103
|
+
for (const term of document.querySelectorAll("article dl dt")) {
|
|
104
|
+
const key = term.text.trim()
|
|
105
|
+
const definition = term.nextElementSibling
|
|
106
|
+
if (key !== "" && definition !== null && definition.rawTagName === "dd") {
|
|
107
|
+
facets[key] = definition.text.trim()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
/** Root-absolute, the id an edge `href` names. */
|
|
112
|
+
path: idFor(path, root),
|
|
113
|
+
/** Where the file is in the guest, for a re-read that bypasses these fields. */
|
|
114
|
+
file: path,
|
|
115
|
+
title: document.querySelector("title")?.text.trim() ?? "",
|
|
116
|
+
memoryType: metaValues(document, "memhtml-type")[0] ?? "",
|
|
117
|
+
status: metaValues(document, "memhtml-status")[0] ?? "",
|
|
118
|
+
claim: document.querySelector("article mark")?.text.trim() ?? "",
|
|
119
|
+
tags: metaValues(document, "memhtml-tag"),
|
|
120
|
+
entities: metaValues(document, "memhtml-entity"),
|
|
121
|
+
links: document.querySelectorAll('link[rel^="memhtml-"]').map((element) => ({
|
|
122
|
+
rel: element.getAttribute("rel") ?? "",
|
|
123
|
+
href: element.getAttribute("href") ?? ""
|
|
124
|
+
})),
|
|
125
|
+
facets,
|
|
126
|
+
citations: document.querySelectorAll("article cite").map((element) => element.text.trim()),
|
|
127
|
+
eventAt: document.querySelector("article time[datetime]")?.getAttribute("datetime") ?? null,
|
|
128
|
+
document
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every memory under `root`, keyed by the root-absolute path an edge `href` names. */
|
|
133
|
+
export const corpus = (root = ROOT) => {
|
|
134
|
+
const byPath = new Map()
|
|
135
|
+
for (const path of walk(root)) {
|
|
136
|
+
const memory = memoryAt(path, root)
|
|
137
|
+
byPath.set(memory.path, memory)
|
|
138
|
+
}
|
|
139
|
+
return byPath
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The authored edge set reversed: for each target path, who points at it and with which rel. */
|
|
143
|
+
export const backlinks = (memories) => {
|
|
144
|
+
const incoming = new Map()
|
|
145
|
+
for (const memory of memories.values()) {
|
|
146
|
+
for (const link of memory.links) {
|
|
147
|
+
const existing = incoming.get(link.href)
|
|
148
|
+
if (existing === undefined) incoming.set(link.href, [{ from: memory.path, rel: link.rel }])
|
|
149
|
+
else existing.push({ from: memory.path, rel: link.rel })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return incoming
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Follow one rel from `start` to exhaustion: a supersedence chain, a part-of ancestry.
|
|
157
|
+
*
|
|
158
|
+
* The cycle guard is a membership test on the path travelled, not a hop cap: an authored edge set can
|
|
159
|
+
* be cyclic (nothing in the format forbids `A memhtml-supersedes B memhtml-supersedes A`) and a capped walk
|
|
160
|
+
* would return a truncated chain that looks like a real one. The returned array always starts at
|
|
161
|
+
* `start`, including when `start` is absent from `memories` — a caller comparing lengths needs the
|
|
162
|
+
* one-element case to mean "no chain" rather than "unknown".
|
|
163
|
+
*/
|
|
164
|
+
export const chain = (memories, start, rel) => {
|
|
165
|
+
const travelled = []
|
|
166
|
+
let at = start
|
|
167
|
+
while (at !== undefined && at !== null && !travelled.includes(at)) {
|
|
168
|
+
travelled.push(at)
|
|
169
|
+
at = memories.get(at)?.links.find((link) => link.rel === rel)?.href
|
|
170
|
+
}
|
|
171
|
+
return travelled
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Every edge, split by whether its target is a file in this corpus.
|
|
176
|
+
*
|
|
177
|
+
* Present as a helper rather than left to each script because the resolution RATE is the number that
|
|
178
|
+
* catches a normalization bug: `dangling` counting every edge reads as "the corpus has no valid
|
|
179
|
+
* edges", which is a plausible-looking finding and was in fact a bug in the reader. A script that
|
|
180
|
+
* reports `resolved` beside a total it derived independently cannot make that mistake quietly.
|
|
181
|
+
*/
|
|
182
|
+
export const edges = (memories) => {
|
|
183
|
+
const resolved = []
|
|
184
|
+
const dangling = []
|
|
185
|
+
for (const memory of memories.values()) {
|
|
186
|
+
for (const link of memory.links) {
|
|
187
|
+
const edge = { from: memory.path, rel: link.rel, href: link.href }
|
|
188
|
+
if (memories.has(link.href)) resolved.push(edge)
|
|
189
|
+
else dangling.push(edge)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { resolved, dangling }
|
|
193
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- The memory corpus, one row per file in the git tree. Rebuildable: every column here is
|
|
2
|
+
-- derived from a committed file plus its blob sha, so `rm index.db` costs a rebuild and no data.
|
|
3
|
+
--
|
|
4
|
+
-- `para`, `workspace`, `word_count`, `gist`, `fts_text`, and `disclosure_text` are computed by the
|
|
5
|
+
-- indexer rather than by the database: generated columns are unavailable on this driver.
|
|
6
|
+
--
|
|
7
|
+
-- Every child table declares `ON UPDATE CASCADE` as well as `ON DELETE CASCADE`. `files.path` is the
|
|
8
|
+
-- primary key AND it moves: eviction is a `git mv` into `archive/<YYYY>/`, so a rename is an UPDATE
|
|
9
|
+
-- of a parent key. Foreign keys are immediate on this driver, so without `ON UPDATE CASCADE` that
|
|
10
|
+
-- UPDATE fails outright (probed 2026-08-02), and a rename handled as a delete plus an insert would
|
|
11
|
+
-- cascade the chunk rows away and take their embeddings with them, re-paying Bedrock for text that
|
|
12
|
+
-- did not change.
|
|
13
|
+
|
|
14
|
+
CREATE TABLE files (
|
|
15
|
+
path TEXT PRIMARY KEY,
|
|
16
|
+
blob_sha TEXT NOT NULL,
|
|
17
|
+
content_hash TEXT NOT NULL,
|
|
18
|
+
memory_type TEXT NOT NULL CHECK (memory_type IN (
|
|
19
|
+
'episodic','semantic','procedural','agent_insight',
|
|
20
|
+
'user_preference','error_pattern','verdict','precedent','arc')),
|
|
21
|
+
title TEXT NOT NULL,
|
|
22
|
+
body_text TEXT NOT NULL,
|
|
23
|
+
gist TEXT NOT NULL DEFAULT '',
|
|
24
|
+
-- title, gist, and body joined by newlines: the ONE column `files_fts` covers, so that a single
|
|
25
|
+
-- MATCH finds a term wherever it lives. A multi-column FTS5 table would make `bm25()` weight the
|
|
26
|
+
-- columns against each other, which is a ranking decision the RRF fusion already owns.
|
|
27
|
+
fts_text TEXT NOT NULL DEFAULT '',
|
|
28
|
+
-- What memory_recall may QUOTE, which is narrower than what it may search: the <mark> claim,
|
|
29
|
+
-- the <summary> headlines, the <dl> facets, the citations. <details> bodies are excluded (Tier 3,
|
|
30
|
+
-- memory_read only) and <aside> texts are excluded (a scope caveat quoted as the memory would
|
|
31
|
+
-- present the exception as the rule). Stored rather than re-derived at read time so the exclusion
|
|
32
|
+
-- is decided once, by the indexer, where the parsed structure is still in hand.
|
|
33
|
+
disclosure_text TEXT NOT NULL DEFAULT '',
|
|
34
|
+
para TEXT NOT NULL CHECK (para IN ('projects','areas','resources','archive')),
|
|
35
|
+
workspace TEXT,
|
|
36
|
+
confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence BETWEEN 0 AND 1),
|
|
37
|
+
importance INTEGER NOT NULL DEFAULT 5 CHECK (importance BETWEEN 1 AND 10),
|
|
38
|
+
archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0,1)),
|
|
39
|
+
origin_path TEXT,
|
|
40
|
+
word_count INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
updated_at TEXT NOT NULL,
|
|
43
|
+
-- When the remembered fact HAPPENED, from the article's first <time datetime>. World time,
|
|
44
|
+
-- not write time: the recency arm ranks by coalesce(event_at, updated_at) so a memory about
|
|
45
|
+
-- last month's incident does not outrank one about today merely by being written later.
|
|
46
|
+
event_at TEXT,
|
|
47
|
+
archived_at TEXT,
|
|
48
|
+
valid_from TEXT,
|
|
49
|
+
valid_until TEXT,
|
|
50
|
+
reprieves INTEGER NOT NULL DEFAULT 0 CHECK (reprieves >= 0),
|
|
51
|
+
needs_revision INTEGER NOT NULL DEFAULT 0 CHECK (needs_revision IN (0,1)),
|
|
52
|
+
author TEXT NOT NULL DEFAULT 'agent',
|
|
53
|
+
session_id TEXT,
|
|
54
|
+
prompt_id TEXT,
|
|
55
|
+
turn_uuid TEXT,
|
|
56
|
+
indexed_at TEXT NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
-- Structural dedup. A duplicate active body cannot even be indexed, so the write path's
|
|
60
|
+
-- content-hash lookup and the database agree by construction rather than by discipline.
|
|
61
|
+
CREATE UNIQUE INDEX files_content_hash_active ON files (content_hash) WHERE archived = 0;
|
|
62
|
+
CREATE INDEX files_type_active ON files (memory_type) WHERE archived = 0;
|
|
63
|
+
CREATE INDEX files_workspace ON files (workspace) WHERE archived = 0;
|
|
64
|
+
CREATE INDEX files_para ON files (para);
|
|
65
|
+
CREATE INDEX files_updated ON files (updated_at) WHERE archived = 0;
|
|
66
|
+
CREATE INDEX files_event ON files (event_at) WHERE event_at IS NOT NULL;
|
|
67
|
+
CREATE INDEX files_session ON files (session_id) WHERE session_id IS NOT NULL;
|
|
68
|
+
CREATE INDEX files_ttl ON files (valid_until) WHERE valid_until IS NOT NULL AND archived = 0;
|
|
69
|
+
CREATE INDEX files_blob ON files (blob_sha);
|
|
70
|
+
|
|
71
|
+
-- Open vocabulary. Tags broaden a scoped search (ANY-of overlap), so a new tag never has to be
|
|
72
|
+
-- registered anywhere before it is usable.
|
|
73
|
+
CREATE TABLE file_tags (
|
|
74
|
+
path TEXT NOT NULL REFERENCES files (path) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
75
|
+
tag TEXT NOT NULL,
|
|
76
|
+
PRIMARY KEY (path, tag)
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX file_tags_tag ON file_tags (tag);
|
|
79
|
+
|
|
80
|
+
-- `type:name` references, split at the first colon. `concept:` rows are promoted by the indexer
|
|
81
|
+
-- from the article's <dfn> terms, so a semantic memory that defines a term is findable by the
|
|
82
|
+
-- term without the author also writing a memhtml-entity meta.
|
|
83
|
+
CREATE TABLE file_entities (
|
|
84
|
+
path TEXT NOT NULL REFERENCES files (path) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
85
|
+
entity_type TEXT NOT NULL,
|
|
86
|
+
entity_name TEXT NOT NULL,
|
|
87
|
+
PRIMARY KEY (path, entity_type, entity_name)
|
|
88
|
+
);
|
|
89
|
+
CREATE INDEX file_entities_name ON file_entities (entity_type, entity_name);
|
|
90
|
+
|
|
91
|
+
-- <dt>/<dd> pairs. `numeric_value` is present only when the <dd> carries a <data value> that
|
|
92
|
+
-- parses as a finite number, and it is UNITLESS: the unit lives in the human phrasing
|
|
93
|
+
-- (<data value="120">about two minutes</data> is seconds because the prose says so), so a
|
|
94
|
+
-- consumer must never infer a unit from the number.
|
|
95
|
+
CREATE TABLE file_facets (
|
|
96
|
+
path TEXT NOT NULL REFERENCES files (path) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
97
|
+
name TEXT NOT NULL,
|
|
98
|
+
value TEXT NOT NULL,
|
|
99
|
+
numeric_value REAL,
|
|
100
|
+
PRIMARY KEY (path, name, value)
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX file_facets_name ON file_facets (name);
|
|
103
|
+
|
|
104
|
+
-- <cite> and <q cite>. `href` is the quotation's source URI when the file gave one, an
|
|
105
|
+
-- arbitrary URI, not necessarily a memory path, so it is deliberately unconstrained.
|
|
106
|
+
CREATE TABLE file_citations (
|
|
107
|
+
path TEXT NOT NULL REFERENCES files (path) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
108
|
+
text TEXT NOT NULL,
|
|
109
|
+
href TEXT,
|
|
110
|
+
PRIMARY KEY (path, text)
|
|
111
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Chunks and their vectors. Both key on `content_hash`, not on `path`: a `git mv`, which is what
|
|
2
|
+
-- eviction and every rename are, reuses the vector with zero Bedrock calls, and two files whose
|
|
3
|
+
-- bodies later diverge never share one.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE chunks (
|
|
6
|
+
-- sha256(content_hash || ':' || ordinal). An identical body anywhere in the tree therefore hits
|
|
7
|
+
-- the same chunk row and the same embedding.
|
|
8
|
+
chunk_id TEXT PRIMARY KEY,
|
|
9
|
+
path TEXT NOT NULL REFERENCES files (path) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
10
|
+
content_hash TEXT NOT NULL,
|
|
11
|
+
-- 0-based position within THIS file's chunk sequence.
|
|
12
|
+
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
|
13
|
+
text TEXT NOT NULL,
|
|
14
|
+
char_count INTEGER NOT NULL
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX chunks_path ON chunks (path);
|
|
17
|
+
CREATE INDEX chunks_hash ON chunks (content_hash);
|
|
18
|
+
CREATE UNIQUE INDEX chunks_hash_ord ON chunks (content_hash, ordinal);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE embeddings (
|
|
21
|
+
chunk_id TEXT PRIMARY KEY REFERENCES chunks (chunk_id) ON DELETE CASCADE,
|
|
22
|
+
-- The vector space, as `<model-id>@<dim>`. A model id alone does not identify a space: the same
|
|
23
|
+
-- id at another output_dimension produces vectors silently incomparable with the stored ones.
|
|
24
|
+
model TEXT NOT NULL,
|
|
25
|
+
dim INTEGER NOT NULL CHECK (dim > 0),
|
|
26
|
+
-- float32 little-endian, bound as Buffer.from(Float32Array.buffer). `vector_distance_cos` reads
|
|
27
|
+
-- it directly; the MMR pass decodes it with Float32Array rather than paying vector_extract.
|
|
28
|
+
vec BLOB NOT NULL,
|
|
29
|
+
created_at TEXT NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX embeddings_model ON embeddings (model);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- The lexical index: an FTS5 table over `files.fts_text`, plus the triggers that keep it in step.
|
|
2
|
+
--
|
|
3
|
+
-- EXTERNAL CONTENT (`content='files'`) rather than a standalone FTS5 table, so the indexed text is
|
|
4
|
+
-- stored once. `files` already holds `fts_text`; a standalone table would keep a second copy of every
|
|
5
|
+
-- memory's searchable text, doubling the largest column in the schema for nothing. `content_rowid`
|
|
6
|
+
-- names the join back: `files` has a TEXT primary key and therefore an ordinary implicit rowid, which
|
|
7
|
+
-- is what FTS5 addresses rows by.
|
|
8
|
+
--
|
|
9
|
+
-- ONE column, deliberately, and the reason is unchanged: `fts_text` is title + gist + body_text
|
|
10
|
+
-- joined, so a term in any of the three is found by one MATCH. A multi-column FTS5 table would scope
|
|
11
|
+
-- an unqualified MATCH across all columns but make `bm25()` weight them, which is a ranking decision
|
|
12
|
+
-- the RRF fusion already owns.
|
|
13
|
+
--
|
|
14
|
+
-- Ranking is `bm25(files_fts)`, a real relevance score, ascending, most relevant first (FTS5 returns
|
|
15
|
+
-- bm25 as a negative number). The lexical arm's `ROW_NUMBER()` orders by it.
|
|
16
|
+
--
|
|
17
|
+
-- The triggers are the whole maintenance contract. External-content FTS5 does NOT observe its content
|
|
18
|
+
-- table on its own: without them the index silently stops matching rows the corpus has. A delete is
|
|
19
|
+
-- written as the `'delete'` command with the OLD text, because FTS5 needs the previous terms to
|
|
20
|
+
-- unindex them. Passing the new text, or omitting it, corrupts the index rather than failing.
|
|
21
|
+
CREATE VIRTUAL TABLE files_fts USING fts5(
|
|
22
|
+
fts_text,
|
|
23
|
+
content='files',
|
|
24
|
+
content_rowid='rowid'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TRIGGER files_fts_insert AFTER INSERT ON files BEGIN
|
|
28
|
+
INSERT INTO files_fts(rowid, fts_text) VALUES (new.rowid, new.fts_text);
|
|
29
|
+
END;
|
|
30
|
+
|
|
31
|
+
CREATE TRIGGER files_fts_delete AFTER DELETE ON files BEGIN
|
|
32
|
+
INSERT INTO files_fts(files_fts, rowid, fts_text) VALUES ('delete', old.rowid, old.fts_text);
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
-- `OF fts_text` and not a bare `AFTER UPDATE`: every other column is projection metadata the lexical
|
|
36
|
+
-- index does not read, and firing on those would pay an unindex+reindex for an `archived` flip.
|
|
37
|
+
CREATE TRIGGER files_fts_update AFTER UPDATE OF fts_text ON files BEGIN
|
|
38
|
+
INSERT INTO files_fts(files_fts, rowid, fts_text) VALUES ('delete', old.rowid, old.fts_text);
|
|
39
|
+
INSERT INTO files_fts(rowid, fts_text) VALUES (new.rowid, new.fts_text);
|
|
40
|
+
END;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- The graph. Authored edges come from the files' <link rel="memhtml-*"> elements; derived edges are
|
|
2
|
+
-- sleep-mined and live only here, because they are a re-derivable function of the corpus and the
|
|
3
|
+
-- embedder. `derived` is the firewall: the retention penalty counts only `derived = 0`, so an
|
|
4
|
+
-- uncorroborated machine suspicion can never evict a memory.
|
|
5
|
+
--
|
|
6
|
+
-- No FK on src_path/dst_path. A <link> may legitimately point at a file the indexer has not reached
|
|
7
|
+
-- yet, or at an archived path; a hard FK would make the indexer order-dependent. Dangling links are
|
|
8
|
+
-- found by a LEFT JOIN in the integrity phase and repaired in a commit.
|
|
9
|
+
|
|
10
|
+
CREATE TABLE edges (
|
|
11
|
+
src_path TEXT NOT NULL,
|
|
12
|
+
rel TEXT NOT NULL,
|
|
13
|
+
dst_path TEXT NOT NULL,
|
|
14
|
+
-- The three classes do not mix. A person edge is structurally incapable of entering PageRank,
|
|
15
|
+
-- MMR, or the retention bridge count, because every memory-graph query filters on this column
|
|
16
|
+
-- and the CHECKs below refuse a rel from another class.
|
|
17
|
+
edge_class TEXT NOT NULL DEFAULT 'memory'
|
|
18
|
+
CHECK (edge_class IN ('memory','person','provenance')),
|
|
19
|
+
derived INTEGER NOT NULL DEFAULT 0 CHECK (derived IN (0,1)),
|
|
20
|
+
-- Unitless in [0, 1]. An authored edge is 1.0; a mined one carries its cosine.
|
|
21
|
+
strength REAL NOT NULL DEFAULT 1.0 CHECK (strength BETWEEN 0 AND 1),
|
|
22
|
+
provenance TEXT NOT NULL DEFAULT 'authored'
|
|
23
|
+
CHECK (provenance IN ('authored','sleep','import')),
|
|
24
|
+
sleep_run TEXT,
|
|
25
|
+
src_hash TEXT,
|
|
26
|
+
dst_hash TEXT,
|
|
27
|
+
created_at TEXT NOT NULL,
|
|
28
|
+
PRIMARY KEY (src_path, rel, dst_path),
|
|
29
|
+
CHECK (src_path <> dst_path),
|
|
30
|
+
CHECK (edge_class <> 'memory' OR rel IN (
|
|
31
|
+
'supersedes','contradicts','caused_by','leads_to','part_of',
|
|
32
|
+
'relates_to','example_of','supports','laterally_related')),
|
|
33
|
+
CHECK (edge_class <> 'person' OR rel IN ('about_person','authored_by')),
|
|
34
|
+
CHECK (edge_class <> 'provenance' OR rel IN ('from_session')),
|
|
35
|
+
CHECK (derived = 0 OR provenance = 'sleep')
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX edges_src ON edges (src_path, edge_class) WHERE derived = 0;
|
|
38
|
+
CREATE INDEX edges_dst ON edges (dst_path, edge_class) WHERE derived = 0;
|
|
39
|
+
CREATE INDEX edges_rel ON edges (rel, edge_class);
|
|
40
|
+
CREATE INDEX edges_derived ON edges (derived, rel);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
-- The trace plane: a read-only index over ~/.claude/projects. `.memhtml` never holds session content,
|
|
2
|
+
-- so every column here is a pointer or a capped head, never a copy.
|
|
3
|
+
--
|
|
4
|
+
-- Firewalled from retrieval. Nothing in the retrieval SQL assembler names `traces` or
|
|
5
|
+
-- `trace_prompts`, and a test greps every assembled statement to prove it. The schema separation
|
|
6
|
+
-- the predecessor memory system got from a second Postgres schema is a table-name firewall here.
|
|
7
|
+
|
|
8
|
+
CREATE TABLE traces (
|
|
9
|
+
session_id TEXT PRIMARY KEY,
|
|
10
|
+
-- The ~/.claude/projects/<slug> directory name: a PATH slug, derived from the cwd. Not the
|
|
11
|
+
-- `slug` field on a subagent record, which is a title slug for the agent's task.
|
|
12
|
+
slug TEXT NOT NULL,
|
|
13
|
+
cwd TEXT,
|
|
14
|
+
git_branch TEXT,
|
|
15
|
+
entrypoint TEXT,
|
|
16
|
+
model TEXT,
|
|
17
|
+
version TEXT,
|
|
18
|
+
started_at TEXT,
|
|
19
|
+
ended_at TEXT,
|
|
20
|
+
prompt_count INTEGER NOT NULL DEFAULT 0,
|
|
21
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
agent_count INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
first_prompt TEXT NOT NULL DEFAULT '',
|
|
24
|
+
ai_title TEXT,
|
|
25
|
+
-- The main transcript's path. A session's subagent sidecars are separate files upserting into
|
|
26
|
+
-- this one row, so this is always the `kind: "session"` file.
|
|
27
|
+
file_path TEXT NOT NULL,
|
|
28
|
+
file_size INTEGER NOT NULL,
|
|
29
|
+
file_mtime TEXT NOT NULL,
|
|
30
|
+
-- first_prompt + ai_title joined by a newline, for the same single-column reason as files.fts_text.
|
|
31
|
+
search_text TEXT NOT NULL DEFAULT '',
|
|
32
|
+
indexed_at TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX traces_slug ON traces (slug);
|
|
35
|
+
CREATE INDEX traces_cwd ON traces (cwd);
|
|
36
|
+
CREATE INDEX traces_started ON traces (started_at);
|
|
37
|
+
CREATE INDEX traces_mtime ON traces (file_mtime);
|
|
38
|
+
|
|
39
|
+
-- Trace search, on the same external-content pattern as `files_fts` (see 0003_fts.sql for why).
|
|
40
|
+
CREATE VIRTUAL TABLE traces_fts USING fts5(
|
|
41
|
+
search_text,
|
|
42
|
+
content='traces',
|
|
43
|
+
content_rowid='rowid'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TRIGGER traces_fts_insert AFTER INSERT ON traces BEGIN
|
|
47
|
+
INSERT INTO traces_fts(rowid, search_text) VALUES (new.rowid, new.search_text);
|
|
48
|
+
END;
|
|
49
|
+
|
|
50
|
+
CREATE TRIGGER traces_fts_delete AFTER DELETE ON traces BEGIN
|
|
51
|
+
INSERT INTO traces_fts(traces_fts, rowid, search_text)
|
|
52
|
+
VALUES ('delete', old.rowid, old.search_text);
|
|
53
|
+
END;
|
|
54
|
+
|
|
55
|
+
CREATE TRIGGER traces_fts_update AFTER UPDATE OF search_text ON traces BEGIN
|
|
56
|
+
INSERT INTO traces_fts(traces_fts, rowid, search_text)
|
|
57
|
+
VALUES ('delete', old.rowid, old.search_text);
|
|
58
|
+
INSERT INTO traces_fts(rowid, search_text) VALUES (new.rowid, new.search_text);
|
|
59
|
+
END;
|
|
60
|
+
|
|
61
|
+
CREATE TABLE trace_prompts (
|
|
62
|
+
session_id TEXT NOT NULL REFERENCES traces (session_id) ON DELETE CASCADE,
|
|
63
|
+
prompt_id TEXT NOT NULL,
|
|
64
|
+
turn_uuid TEXT NOT NULL,
|
|
65
|
+
-- 0-based position among the distinct prompts of THIS session, in first-appearance order.
|
|
66
|
+
-- Per-session scope: comparable only within one session_id.
|
|
67
|
+
ordinal INTEGER NOT NULL,
|
|
68
|
+
at TEXT NOT NULL,
|
|
69
|
+
agent_id TEXT,
|
|
70
|
+
-- Capped at 200 characters by the extractor. This is an index, not a copy.
|
|
71
|
+
text_head TEXT NOT NULL DEFAULT '',
|
|
72
|
+
PRIMARY KEY (session_id, prompt_id)
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX trace_prompts_uuid ON trace_prompts (session_id, turn_uuid);
|
|
75
|
+
|
|
76
|
+
-- Written at memory-write time by the store's injected recorder, not by the trace scanner. The
|
|
77
|
+
-- same link is also file-borne as memhtml-session/memhtml-prompt/memhtml-turn, so it survives a rebuild;
|
|
78
|
+
-- this table is what makes it queryable in both directions.
|
|
79
|
+
--
|
|
80
|
+
-- No FK to `traces`: a memory can be written in a session whose transcript has not been scanned
|
|
81
|
+
-- yet, and refusing the link would lose the provenance the file already carries.
|
|
82
|
+
CREATE TABLE memory_session_links (
|
|
83
|
+
path TEXT NOT NULL,
|
|
84
|
+
session_id TEXT NOT NULL,
|
|
85
|
+
prompt_id TEXT,
|
|
86
|
+
turn_uuid TEXT,
|
|
87
|
+
link_kind TEXT NOT NULL CHECK (link_kind IN ('wrote','read','corrected','reinforced')),
|
|
88
|
+
at TEXT NOT NULL,
|
|
89
|
+
PRIMARY KEY (path, session_id, link_kind, at)
|
|
90
|
+
);
|
|
91
|
+
CREATE INDEX msl_session ON memory_session_links (session_id);
|
|
92
|
+
CREATE INDEX msl_path ON memory_session_links (path);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- Sleep-run reporting. Not load-bearing: the commit trailers on the sleep branch are what
|
|
2
|
+
-- `memhtml sleep resume` reads (`git log --format=%B base..HEAD | grep '^Memhtml-Phase:'`), so this pair of
|
|
3
|
+
-- tables is a reporting convenience the git history can regenerate. That is deliberate. A journal
|
|
4
|
+
-- table that a resume depended on would be a second source of truth for what already happened.
|
|
5
|
+
|
|
6
|
+
CREATE TABLE sleep_runs (
|
|
7
|
+
-- `sleep/<YYYY-MM-DD>`, suffixed `-2` on a same-day rerun. Also the branch name.
|
|
8
|
+
run_id TEXT PRIMARY KEY,
|
|
9
|
+
branch TEXT NOT NULL,
|
|
10
|
+
base_sha TEXT NOT NULL,
|
|
11
|
+
head_sha TEXT,
|
|
12
|
+
status TEXT NOT NULL CHECK (status IN ('running','review','merged','abandoned','failed')),
|
|
13
|
+
started_at TEXT NOT NULL,
|
|
14
|
+
ended_at TEXT
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX sleep_runs_started ON sleep_runs (started_at);
|
|
17
|
+
|
|
18
|
+
CREATE TABLE sleep_phases (
|
|
19
|
+
run_id TEXT NOT NULL REFERENCES sleep_runs (run_id) ON DELETE CASCADE,
|
|
20
|
+
phase TEXT NOT NULL,
|
|
21
|
+
-- 1-based ordinal of the phase within the 15-phase sequence. A display label, never arithmetic.
|
|
22
|
+
ordinal INTEGER NOT NULL,
|
|
23
|
+
-- Per-phase isolation is the design driver: a failed phase keeps every prior phase's commits and
|
|
24
|
+
-- later phases still run, so `failed` here is a normal terminal state, not an aborted run.
|
|
25
|
+
status TEXT NOT NULL CHECK (status IN ('ok','failed','skipped')),
|
|
26
|
+
commit_sha TEXT,
|
|
27
|
+
counts TEXT NOT NULL DEFAULT '{}',
|
|
28
|
+
error TEXT,
|
|
29
|
+
llm_calls INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
started_at TEXT NOT NULL,
|
|
31
|
+
ended_at TEXT,
|
|
32
|
+
PRIMARY KEY (run_id, phase)
|
|
33
|
+
);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- The two watermarks. Each answers "what did the last run already consume", and each is what makes
|
|
2
|
+
-- the corresponding incremental path cheap enough to run on a cron.
|
|
3
|
+
|
|
4
|
+
-- Exactly one row, by CHECK. The incremental indexer's watermark.
|
|
5
|
+
CREATE TABLE index_state (
|
|
6
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
7
|
+
-- The commit the index describes. NULL before the first rebuild. `git diff <head_sha> HEAD`
|
|
8
|
+
-- is the whole change set the next update has to apply.
|
|
9
|
+
head_sha TEXT,
|
|
10
|
+
-- The vector space, as `<model-id>@<dim>` (@memhtml/llm's EMBED_WATERMARK). A mismatch against
|
|
11
|
+
-- configuration is a hard refusal, never a silent reindex: a half-migrated vector space degrades
|
|
12
|
+
-- every cosine and is invisible in tests. Only `rebuild --embed-model` rewrites it, and it
|
|
13
|
+
-- truncates `embeddings` first.
|
|
14
|
+
embed_model TEXT NOT NULL,
|
|
15
|
+
embed_dim INTEGER NOT NULL CHECK (embed_dim > 0),
|
|
16
|
+
rebuilt_at TEXT NOT NULL,
|
|
17
|
+
updated_at TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
-- One row per transcript file. Size AND mtime must both match to skip: size alone misses an
|
|
21
|
+
-- in-place rewrite that preserves the length, mtime alone misses a write inside the same clock tick.
|
|
22
|
+
CREATE TABLE trace_watermarks (
|
|
23
|
+
file_path TEXT PRIMARY KEY,
|
|
24
|
+
size INTEGER NOT NULL,
|
|
25
|
+
-- ISO-8601 UTC. @memhtml/traces reports mtime in epoch MILLISECONDS, so the adapter converts at this
|
|
26
|
+
-- boundary and the column carries exactly one unit.
|
|
27
|
+
mtime TEXT NOT NULL,
|
|
28
|
+
-- 0-based byte offset: one past the last byte consumed, and therefore the `start` of the next
|
|
29
|
+
-- read. Equal to `size` after a complete scan.
|
|
30
|
+
byte_off INTEGER NOT NULL DEFAULT 0 CHECK (byte_off >= 0),
|
|
31
|
+
scanned_at TEXT NOT NULL
|
|
32
|
+
);
|