@particle-academy/prism-graph 0.0.0-stage → 0.2.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,102 @@
1
+ import { InvalidArgumentError } from '../errors.js';
2
+ /**
3
+ * Which app-owned tables play each role. Apps create and extend these tables
4
+ * in their own migrations; the package only reads and writes the core columns
5
+ * it documents. Names are capped at 50 characters so derived names stay inside
6
+ * PostgreSQL's 63-character identifier limit.
7
+ */
8
+ export class TableMap {
9
+ sources;
10
+ chunks;
11
+ facts;
12
+ evidence;
13
+ constructor(sources = 'graph_sources', chunks = 'graph_chunks', facts = 'graph_facts', evidence = 'graph_fact_evidence') {
14
+ this.sources = sources;
15
+ this.chunks = chunks;
16
+ this.facts = facts;
17
+ this.evidence = evidence;
18
+ for (const [role, table] of Object.entries({ sources, chunks, facts, evidence })) {
19
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,49}$/.test(table)) {
20
+ throw new InvalidArgumentError(`Invalid table name for ${role}.`);
21
+ }
22
+ }
23
+ Object.freeze(this);
24
+ }
25
+ /** The full-text index over chunks (an FTS5 table on SQLite). */
26
+ fts() {
27
+ return `${this.chunks}_fts`;
28
+ }
29
+ }
30
+ /**
31
+ * The core schema, as plain SQL for SQLite, matching the PHP reference's
32
+ * BaseSchema. The package never creates tables at runtime: apps run these from
33
+ * their own migration tooling, and own them afterwards.
34
+ */
35
+ export function baseSchema(t = new TableMap()) {
36
+ const scope = 'tenant_id VARCHAR(191) NOT NULL, owner_id VARCHAR(191), agent_id VARCHAR(191), thread_id VARCHAR(191)';
37
+ return [
38
+ `CREATE TABLE ${t.sources} (id CHAR(64) PRIMARY KEY, source_key VARCHAR(191) NOT NULL, ${scope},
39
+ uri TEXT NOT NULL, sha256 CHAR(64) NOT NULL, metadata TEXT NOT NULL,
40
+ trust VARCHAR(20) NOT NULL, writer VARCHAR(191) NOT NULL,
41
+ occurred_at CHAR(27), recorded_at CHAR(27) NOT NULL)`,
42
+ `CREATE INDEX ${t.sources}_scope ON ${t.sources} (tenant_id, owner_id, agent_id, thread_id)`,
43
+ // seq is the explicit integer key the full-text index joins on: an implicit
44
+ // rowid is not guaranteed stable across VACUUM.
45
+ `CREATE TABLE ${t.chunks} (seq INTEGER PRIMARY KEY, id CHAR(64) NOT NULL UNIQUE, source_id CHAR(64) NOT NULL REFERENCES ${t.sources} (id),
46
+ text TEXT NOT NULL, byte_start INTEGER NOT NULL, byte_end INTEGER NOT NULL,
47
+ line_start INTEGER NOT NULL, line_end INTEGER NOT NULL, heading_path TEXT NOT NULL,
48
+ recipe VARCHAR(191) NOT NULL, retired_at CHAR(27))`,
49
+ `CREATE INDEX ${t.chunks}_source ON ${t.chunks} (source_id, byte_start)`,
50
+ `CREATE TABLE ${t.facts} (id CHAR(64) PRIMARY KEY, fact_key VARCHAR(191) NOT NULL, ${scope},
51
+ subject VARCHAR(191) NOT NULL, predicate VARCHAR(191) NOT NULL, object TEXT NOT NULL,
52
+ status VARCHAR(20) NOT NULL, valid_from CHAR(27) NOT NULL, valid_to CHAR(27),
53
+ superseded_by VARCHAR(191), recorded_at CHAR(27) NOT NULL, expired_at CHAR(27))`,
54
+ `CREATE INDEX ${t.facts}_subject ON ${t.facts} (tenant_id, subject, valid_to)`,
55
+ `CREATE TABLE ${t.evidence} (fact_id CHAR(64) NOT NULL REFERENCES ${t.facts} (id),
56
+ chunk_id CHAR(64) NOT NULL REFERENCES ${t.chunks} (id), PRIMARY KEY (fact_id, chunk_id))`,
57
+ `CREATE INDEX ${t.evidence}_chunk ON ${t.evidence} (chunk_id)`,
58
+ ...fullTextSchema(t),
59
+ ];
60
+ }
61
+ /** External-content FTS5 kept in step by triggers; the text lives once, in the chunks table. */
62
+ export function fullTextSchema(t = new TableMap()) {
63
+ const fts = t.fts();
64
+ return [
65
+ `CREATE VIRTUAL TABLE ${fts} USING fts5(text, content='${t.chunks}', content_rowid='seq', tokenize='unicode61')`,
66
+ `CREATE TRIGGER ${t.chunks}_ai AFTER INSERT ON ${t.chunks} BEGIN
67
+ INSERT INTO ${fts} (rowid, text) VALUES (new.seq, new.text); END`,
68
+ `CREATE TRIGGER ${t.chunks}_ad AFTER DELETE ON ${t.chunks} BEGIN
69
+ INSERT INTO ${fts} (${fts}, rowid, text) VALUES ('delete', old.seq, old.text); END`,
70
+ `CREATE TRIGGER ${t.chunks}_au AFTER UPDATE OF text ON ${t.chunks} BEGIN
71
+ INSERT INTO ${fts} (${fts}, rowid, text) VALUES ('delete', old.seq, old.text);
72
+ INSERT INTO ${fts} (rowid, text) VALUES (new.seq, new.text); END`,
73
+ ];
74
+ }
75
+ /**
76
+ * Restore the full-text index after a migration that rebuilt the chunks table
77
+ * (which drops its triggers), and rebuild it from the stored chunks. Safe to
78
+ * run at any time.
79
+ */
80
+ export function repairFullTextSchema(t = new TableMap()) {
81
+ const fts = t.fts();
82
+ const [virtual, insert, del, update] = fullTextSchema(t);
83
+ return [
84
+ virtual.replace('CREATE VIRTUAL TABLE', 'CREATE VIRTUAL TABLE IF NOT EXISTS'),
85
+ `DROP TRIGGER IF EXISTS ${t.chunks}_ai`,
86
+ `DROP TRIGGER IF EXISTS ${t.chunks}_ad`,
87
+ `DROP TRIGGER IF EXISTS ${t.chunks}_au`,
88
+ insert,
89
+ del,
90
+ update,
91
+ `INSERT INTO ${fts} (${fts}) VALUES ('rebuild')`,
92
+ ];
93
+ }
94
+ /** The optional vector tables (exact cosine search over packed float32). */
95
+ export function vectorSchema(indexes = 'graph_vector_indexes', vectors = 'graph_vectors') {
96
+ return [
97
+ `CREATE TABLE ${indexes} (name VARCHAR(100) PRIMARY KEY, fingerprint CHAR(64) NOT NULL, provider VARCHAR(100) NOT NULL,
98
+ model VARCHAR(191) NOT NULL, dimensions INTEGER NOT NULL, recipe VARCHAR(191) NOT NULL)`,
99
+ `CREATE TABLE ${vectors} (index_name VARCHAR(100) NOT NULL REFERENCES ${indexes} (name), chunk_id CHAR(64) NOT NULL,
100
+ vector BLOB NOT NULL, PRIMARY KEY (index_name, chunk_id))`,
101
+ ];
102
+ }
@@ -0,0 +1,129 @@
1
+ import { type Chunk } from '../chunk.js';
2
+ import type { EraseReport, Fact } from '../fact.js';
3
+ import type { Scope } from '../scope.js';
4
+ import { type AcceptancePolicy, type Origin } from '../trust.js';
5
+ import type { SqlDriver } from './driver.js';
6
+ import { TableMap } from './schema.js';
7
+ export declare const MAX_QUERY_TERMS = 16;
8
+ export interface StoredSource {
9
+ readonly source: string;
10
+ readonly stored: number;
11
+ readonly retired: number;
12
+ }
13
+ /**
14
+ * Scoped memory storage over a SqlDriver, a port of the PHP reference's
15
+ * PdoGraphStore. SCOPE IS PART OF EVERY QUERY: reads apply the visibility
16
+ * predicate in SQL, so a row outside the reader's scope is never fetched.
17
+ * Every write runs in driver.atomic(): all-or-nothing, including inside the
18
+ * caller's own transaction.
19
+ */
20
+ /** A source in a listing (G-282, contract g282.1). */
21
+ export interface ListedSource {
22
+ readonly sourceId: string;
23
+ readonly title: string;
24
+ readonly kind: string | null;
25
+ readonly createdAt: string;
26
+ readonly chunkCount: number;
27
+ readonly trust: string;
28
+ }
29
+ /** A chunk in a listing (G-282, contract g282.1). */
30
+ export interface ListedChunk {
31
+ readonly chunkId: string;
32
+ readonly sourceId: string;
33
+ readonly text: string;
34
+ readonly position: number;
35
+ readonly provenance: {
36
+ readonly uri: string;
37
+ readonly sha256: string;
38
+ readonly byteStart: number;
39
+ readonly byteEnd: number;
40
+ readonly lineStart: number;
41
+ readonly lineEnd: number;
42
+ readonly headingPath: string[];
43
+ readonly recipe: string;
44
+ readonly trust: string;
45
+ readonly writer: string;
46
+ readonly recordedAt: string;
47
+ };
48
+ }
49
+ /** A page of a listing. A consumer refuses a contractVersion it does not know. */
50
+ export interface ListingPage<T> {
51
+ readonly contractVersion: 'g282.1';
52
+ readonly items: T[];
53
+ readonly nextCursor: string | null;
54
+ }
55
+ export declare class GraphStore {
56
+ private readonly db;
57
+ private readonly tables;
58
+ private readonly clock;
59
+ constructor(db: SqlDriver, tables?: TableMap, clock?: () => string);
60
+ /**
61
+ * Store a source and its verbatim chunks at `scope`, in ONE atomic block.
62
+ * A source's trust is fixed when first stored: the upsert itself refuses a
63
+ * different trust, so there is no read-then-write window.
64
+ */
65
+ storeSource(scope: Scope, key: string, uri: string, sha256: string, metadata: Record<string, unknown>, origin: Origin, occurredAt: string | null, chunks: readonly Chunk[]): Promise<StoredSource>;
66
+ /** Retire every chunk of a source stored at exactly `scope`. */
67
+ retireSource(scope: Scope, key: string): Promise<number>;
68
+ /** Chunks visible from `reader`, in the order asked. */
69
+ getChunks(reader: Scope, ids: readonly string[]): Promise<Chunk[]>;
70
+ /**
71
+ * Sources visible from `reader`, newest first, with at least one live chunk (G-282, contract g282.1). Rows are in
72
+ * the agreed shape; the caller pages with `after` = [createdAt, sourceId] of the last item it returned.
73
+ */
74
+ listSources(reader: Scope, after: readonly [string, string] | null, limit: number): Promise<ListedSource[]>;
75
+ /** Live chunks of one source visible from `reader`, in source order. A source outside the scope yields nothing. */
76
+ listChunks(reader: Scope, sourceId: string, afterSeq: number | null, limit: number): Promise<Array<ListedChunk & {
77
+ seq: number;
78
+ }>>;
79
+ /** Full-text search over live chunks visible from `reader`, best first. */
80
+ textSearch(reader: Scope, query: string, limit: number): Promise<Array<{
81
+ chunkId: string;
82
+ score: number;
83
+ }>>;
84
+ assertFact(scope: Scope, fact: Fact, policy: AcceptancePolicy): Promise<Fact>;
85
+ /**
86
+ * The world changed: close `oldKey`'s valid time and record the replacement.
87
+ * The overwrite guard decides BEFORE any write: an accepted fact is closed
88
+ * only for a replacement the policy also accepts, in the same statement.
89
+ */
90
+ supersede(scope: Scope, oldKey: string, replacement: Fact, policy: AcceptancePolicy): Promise<Fact>;
91
+ /**
92
+ * We were wrong: end the fact's RECORDED time. An app decision, never a
93
+ * tool: it does not check the acceptance policy, so retract-then-assert can
94
+ * replace an accepted fact with weaker evidence; use supersede() to record
95
+ * that the world changed.
96
+ */
97
+ retract(scope: Scope, key: string): Promise<void>;
98
+ /** An app decision: a proposed fact still in force becomes accepted. */
99
+ accept(scope: Scope, key: string): Promise<void>;
100
+ facts(reader: Scope, subject: string, options?: {
101
+ at?: string | null;
102
+ knownAt?: string | null;
103
+ unaccepted?: boolean;
104
+ }): Promise<Fact[]>;
105
+ /** Every version of every fact about `subject` visible from `reader`, oldest first. */
106
+ timeline(reader: Scope, subject: string): Promise<Fact[]>;
107
+ /** Erase a source stored at exactly `scope`: its chunks, their evidence links, and every fact left with no evidence. */
108
+ eraseSource(scope: Scope, key: string): Promise<EraseReport>;
109
+ /** Validate a fact and decide its status, READING ONLY. */
110
+ private prepareFact;
111
+ private writeFact;
112
+ private retireExcept;
113
+ /** The visibility predicate for a reader, over a table aliased `alias` carrying the scope columns. */
114
+ private visible;
115
+ private toFact;
116
+ private now;
117
+ }
118
+ /**
119
+ * An FTS5 MATCH expression in which every term is a quoted string (inner quotes
120
+ * doubled): a literal, never an operator, column filter or prefix. ORed, so
121
+ * BM25 ranks partial matches instead of dropping them.
122
+ */
123
+ export declare function ftsMatch(terms: readonly string[]): string;
124
+ /**
125
+ * Validate a UTC ISO 8601 time and return it in ONE fixed-width form
126
+ * (microseconds, Z), identical to the PHP reference, so stored times compare
127
+ * correctly as strings whatever precision they were given in.
128
+ */
129
+ export declare function normaliseTime(value: string): string;
@@ -0,0 +1,391 @@
1
+ import { chunkId } from '../chunk.js';
2
+ import { GraphError, InvalidArgumentError } from '../errors.js';
3
+ import { FactStatus, isTrust, Trust } from '../trust.js';
4
+ import { TableMap } from './schema.js';
5
+ export const MAX_QUERY_TERMS = 16;
6
+ const TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{1,6})?Z$/;
7
+ const KEY = /^[\x21-\x7E]{1,191}$/;
8
+ export class GraphStore {
9
+ db;
10
+ tables;
11
+ clock;
12
+ constructor(db, tables = new TableMap(), clock = () => new Date().toISOString()) {
13
+ this.db = db;
14
+ this.tables = tables;
15
+ this.clock = clock;
16
+ }
17
+ /**
18
+ * Store a source and its verbatim chunks at `scope`, in ONE atomic block.
19
+ * A source's trust is fixed when first stored: the upsert itself refuses a
20
+ * different trust, so there is no read-then-write window.
21
+ */
22
+ async storeSource(scope, key, uri, sha256, metadata, origin, occurredAt, chunks) {
23
+ assertKey(key);
24
+ const id = scope.qualify('source', key);
25
+ const occurred = occurredAt === null ? null : normaliseTime(occurredAt);
26
+ return this.db.atomic(async () => {
27
+ const written = await this.db.run(`INSERT INTO ${this.tables.sources}
28
+ (id, source_key, tenant_id, owner_id, agent_id, thread_id, uri, sha256, metadata, trust, writer, occurred_at, recorded_at)
29
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
30
+ ON CONFLICT (id) DO UPDATE SET uri = excluded.uri, sha256 = excluded.sha256, metadata = excluded.metadata,
31
+ writer = excluded.writer, occurred_at = excluded.occurred_at
32
+ WHERE ${this.tables.sources}.trust = excluded.trust`, [id, key, ...scope.toRow(), uri, sha256, JSON.stringify(metadata), origin.trust, origin.writer, occurred, this.now()]);
33
+ if (written !== 1) {
34
+ const stored = (await this.db.all(`SELECT trust FROM ${this.tables.sources} WHERE id = ?`, [id]))[0]?.['trust'] ?? 'unknown';
35
+ throw new GraphError(`Source [${key}] was stored with trust [${String(stored)}]; trust cannot change. Erase it and ingest again.`);
36
+ }
37
+ let stored = 0;
38
+ const keep = [];
39
+ for (const chunk of chunks) {
40
+ if (chunk.sourceId !== id) {
41
+ throw new GraphError('A chunk belongs to a different source than the one being stored.');
42
+ }
43
+ if (chunkId(chunk.recipe, chunk.sourceId, chunk.byteStart, chunk.byteEnd, Buffer.from(chunk.text, 'utf8')) !== chunk.id) {
44
+ throw new GraphError('A chunk id does not match its content; ids are content-addressed.');
45
+ }
46
+ if (chunk.byteStart < 0 || Buffer.byteLength(chunk.text, 'utf8') !== chunk.byteEnd - chunk.byteStart) {
47
+ throw new GraphError('A chunk text must be exactly the bytes of its span.');
48
+ }
49
+ stored += await this.db.run(`INSERT INTO ${this.tables.chunks} (id, source_id, text, byte_start, byte_end, line_start, line_end, heading_path, recipe)
50
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`, [chunk.id, chunk.sourceId, chunk.text, chunk.byteStart, chunk.byteEnd, chunk.lineStart, chunk.lineEnd, JSON.stringify(chunk.headingPath), chunk.recipe]);
51
+ keep.push(chunk.id);
52
+ }
53
+ return { source: id, stored, retired: await this.retireExcept(id, keep) };
54
+ });
55
+ }
56
+ /** Retire every chunk of a source stored at exactly `scope`. */
57
+ async retireSource(scope, key) {
58
+ return this.db.atomic(() => this.retireExcept(scope.qualify('source', key), []));
59
+ }
60
+ /** Chunks visible from `reader`, in the order asked. */
61
+ async getChunks(reader, ids) {
62
+ const wanted = [...new Set(ids.filter((id) => typeof id === 'string'))];
63
+ const byId = new Map();
64
+ for (const batch of batches(wanted, 500)) {
65
+ const [visible, bindings] = this.visible('s', reader);
66
+ const rows = await this.db.all(`SELECT c.*, s.source_key, s.trust FROM ${this.tables.chunks} c
67
+ JOIN ${this.tables.sources} s ON s.id = c.source_id
68
+ WHERE c.id IN (${marks(batch)}) AND ${visible}`, [...batch, ...bindings]);
69
+ for (const row of rows) {
70
+ byId.set(String(row['id']), toChunk(row));
71
+ }
72
+ }
73
+ return wanted.map((id) => byId.get(id)).filter((c) => c !== undefined);
74
+ }
75
+ /**
76
+ * Sources visible from `reader`, newest first, with at least one live chunk (G-282, contract g282.1). Rows are in
77
+ * the agreed shape; the caller pages with `after` = [createdAt, sourceId] of the last item it returned.
78
+ */
79
+ async listSources(reader, after, limit) {
80
+ const [visible, bindings] = this.visible('s', reader);
81
+ const live = `SELECT 1 FROM ${this.tables.chunks} c WHERE c.source_id = s.id AND c.retired_at IS NULL`;
82
+ const page = after === null ? '' : ' AND (s.recorded_at < ? OR (s.recorded_at = ? AND s.id < ?))';
83
+ const rows = await this.db.all(`SELECT s.id, s.uri, s.metadata, s.trust, s.recorded_at,
84
+ (SELECT COUNT(*) FROM ${this.tables.chunks} c WHERE c.source_id = s.id AND c.retired_at IS NULL) AS chunk_count
85
+ FROM ${this.tables.sources} s
86
+ WHERE ${visible} AND EXISTS (${live})${page}
87
+ ORDER BY s.recorded_at DESC, s.id DESC
88
+ LIMIT ${Math.max(1, Math.min(1001, Math.trunc(limit)))}`, after === null ? bindings : [...bindings, after[0], after[0], after[1]]);
89
+ return rows.map((row) => {
90
+ let metadata;
91
+ try {
92
+ metadata = JSON.parse(String(row['metadata']));
93
+ }
94
+ catch {
95
+ metadata = {};
96
+ }
97
+ const meta = typeof metadata === 'object' && metadata !== null ? metadata : {};
98
+ const title = typeof meta['title'] === 'string' && meta['title'] !== '' ? meta['title'] : String(row['uri']);
99
+ const kind = typeof meta['kind'] === 'string' && meta['kind'] !== '' ? meta['kind'] : null;
100
+ return {
101
+ sourceId: String(row['id']).trim(),
102
+ title,
103
+ kind,
104
+ createdAt: String(row['recorded_at']).trim(),
105
+ chunkCount: Number(row['chunk_count']),
106
+ trust: String(row['trust']),
107
+ };
108
+ });
109
+ }
110
+ /** Live chunks of one source visible from `reader`, in source order. A source outside the scope yields nothing. */
111
+ async listChunks(reader, sourceId, afterSeq, limit) {
112
+ const [visible, bindings] = this.visible('s', reader);
113
+ const rows = await this.db.all(`SELECT * FROM (
114
+ SELECT c.seq, c.id, c.source_id, c.text, c.byte_start, c.byte_end, c.line_start, c.line_end,
115
+ c.heading_path, c.recipe, s.uri, s.sha256, s.trust, s.writer, s.recorded_at,
116
+ ROW_NUMBER() OVER (ORDER BY c.seq) AS position
117
+ FROM ${this.tables.chunks} c
118
+ JOIN ${this.tables.sources} s ON s.id = c.source_id
119
+ WHERE c.source_id = ? AND c.retired_at IS NULL AND ${visible}
120
+ ) listed WHERE listed.seq > ? ORDER BY listed.seq LIMIT ${Math.max(1, Math.min(1001, Math.trunc(limit)))}`, [sourceId, ...bindings, afterSeq ?? 0]);
121
+ return rows.map((row) => ({
122
+ chunkId: String(row['id']).trim(),
123
+ sourceId: String(row['source_id']).trim(),
124
+ text: String(row['text']),
125
+ position: Number(row['position']),
126
+ seq: Number(row['seq']),
127
+ provenance: {
128
+ uri: String(row['uri']),
129
+ sha256: String(row['sha256']).trim(),
130
+ byteStart: Number(row['byte_start']),
131
+ byteEnd: Number(row['byte_end']),
132
+ lineStart: Number(row['line_start']),
133
+ lineEnd: Number(row['line_end']),
134
+ headingPath: JSON.parse(String(row['heading_path'])),
135
+ recipe: String(row['recipe']),
136
+ trust: String(row['trust']),
137
+ writer: String(row['writer']),
138
+ recordedAt: String(row['recorded_at']).trim(),
139
+ },
140
+ }));
141
+ }
142
+ /** Full-text search over live chunks visible from `reader`, best first. */
143
+ async textSearch(reader, query, limit) {
144
+ const terms = [...new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t !== ''))].slice(0, MAX_QUERY_TERMS);
145
+ if (terms.length === 0) {
146
+ return [];
147
+ }
148
+ const [visible, bindings] = this.visible('s', reader);
149
+ const fts = this.tables.fts();
150
+ const bounded = Number.isFinite(limit) ? Math.max(1, Math.min(1000, Math.trunc(limit))) : 1;
151
+ const rows = await this.db.all(`SELECT c.id AS chunk_id, -bm25(${fts}) AS score
152
+ FROM ${fts} JOIN ${this.tables.chunks} c ON c.seq = ${fts}.rowid
153
+ JOIN ${this.tables.sources} s ON s.id = c.source_id
154
+ WHERE ${fts} MATCH ? AND c.retired_at IS NULL AND ${visible}
155
+ ORDER BY score DESC, c.id LIMIT ${bounded}`, [ftsMatch(terms), ...bindings]);
156
+ return rows.map((r) => ({ chunkId: String(r['chunk_id']), score: Number(r['score']) }));
157
+ }
158
+ async assertFact(scope, fact, policy) {
159
+ return this.db.atomic(async () => this.writeFact(scope, fact, await this.prepareFact(scope, fact, policy)));
160
+ }
161
+ /**
162
+ * The world changed: close `oldKey`'s valid time and record the replacement.
163
+ * The overwrite guard decides BEFORE any write: an accepted fact is closed
164
+ * only for a replacement the policy also accepts, in the same statement.
165
+ */
166
+ async supersede(scope, oldKey, replacement, policy) {
167
+ return this.db.atomic(async () => {
168
+ const prepared = await this.prepareFact(scope, replacement, policy);
169
+ const oldId = scope.qualify('fact', oldKey);
170
+ const closed = await this.db.run(`UPDATE ${this.tables.facts} SET valid_to = ?, superseded_by = ?
171
+ WHERE id = ? AND valid_to IS NULL AND expired_at IS NULL AND valid_from <= ?
172
+ AND (status <> ? OR ? = ?)`, [prepared.from, replacement.id, oldId, prepared.from, FactStatus.Accepted, prepared.status, FactStatus.Accepted]);
173
+ if (closed !== 1) {
174
+ const old = (await this.db.all(`SELECT status, valid_to, expired_at FROM ${this.tables.facts} WHERE id = ?`, [oldId]))[0];
175
+ if (old !== undefined && old['valid_to'] === null && old['expired_at'] === null
176
+ && old['status'] === FactStatus.Accepted && prepared.status !== FactStatus.Accepted) {
177
+ throw new GraphError(`An accepted fact can only be superseded by one the policy also accepts; this replacement would be a ${prepared.status}.`);
178
+ }
179
+ throw new GraphError('Only a fact still in force, valid before the replacement starts, can be superseded.');
180
+ }
181
+ return this.writeFact(scope, replacement, prepared);
182
+ });
183
+ }
184
+ /**
185
+ * We were wrong: end the fact's RECORDED time. An app decision, never a
186
+ * tool: it does not check the acceptance policy, so retract-then-assert can
187
+ * replace an accepted fact with weaker evidence; use supersede() to record
188
+ * that the world changed.
189
+ */
190
+ async retract(scope, key) {
191
+ const changed = await this.db.atomic(() => this.db.run(`UPDATE ${this.tables.facts} SET expired_at = ? WHERE id = ? AND expired_at IS NULL`, [this.now(), scope.qualify('fact', key)]));
192
+ if (changed !== 1) {
193
+ throw new GraphError(`No current fact [${key}] at this scope to retract.`);
194
+ }
195
+ }
196
+ /** An app decision: a proposed fact still in force becomes accepted. */
197
+ async accept(scope, key) {
198
+ const changed = await this.db.atomic(() => this.db.run(`UPDATE ${this.tables.facts} SET status = ? WHERE id = ? AND status = ? AND expired_at IS NULL AND valid_to IS NULL`, [FactStatus.Accepted, scope.qualify('fact', key), FactStatus.Proposed]));
199
+ if (changed !== 1) {
200
+ throw new GraphError(`No proposed fact [${key}] at this scope to accept.`);
201
+ }
202
+ }
203
+ async facts(reader, subject, options = {}) {
204
+ const [visible, bindings] = this.visible('f', reader);
205
+ const where = ['f.subject = ?', visible];
206
+ const values = [subject, ...bindings];
207
+ if (options.at == null) {
208
+ where.push('f.valid_to IS NULL');
209
+ }
210
+ else {
211
+ const at = normaliseTime(options.at);
212
+ where.push('f.valid_from <= ? AND (f.valid_to IS NULL OR f.valid_to > ?)');
213
+ values.push(at, at);
214
+ }
215
+ if (options.knownAt == null) {
216
+ where.push('f.expired_at IS NULL');
217
+ }
218
+ else {
219
+ const known = normaliseTime(options.knownAt);
220
+ where.push('f.recorded_at <= ? AND (f.expired_at IS NULL OR f.expired_at > ?)');
221
+ values.push(known, known);
222
+ }
223
+ if (options.unaccepted !== true) {
224
+ where.push('f.status = ?');
225
+ values.push(FactStatus.Accepted);
226
+ }
227
+ const rows = await this.db.all(`SELECT f.* FROM ${this.tables.facts} f WHERE ${where.join(' AND ')} ORDER BY f.valid_from, f.fact_key`, values);
228
+ return Promise.all(rows.map((row) => this.toFact(row)));
229
+ }
230
+ /** Every version of every fact about `subject` visible from `reader`, oldest first. */
231
+ async timeline(reader, subject) {
232
+ const [visible, bindings] = this.visible('f', reader);
233
+ const rows = await this.db.all(`SELECT f.* FROM ${this.tables.facts} f WHERE f.subject = ? AND ${visible} ORDER BY f.valid_from, f.recorded_at, f.fact_key`, [subject, ...bindings]);
234
+ return Promise.all(rows.map((row) => this.toFact(row)));
235
+ }
236
+ /** Erase a source stored at exactly `scope`: its chunks, their evidence links, and every fact left with no evidence. */
237
+ async eraseSource(scope, key) {
238
+ return this.db.atomic(async () => {
239
+ const sourceId = scope.qualify('source', key);
240
+ if ((await this.db.all(`SELECT id FROM ${this.tables.sources} WHERE id = ?`, [sourceId])).length === 0) {
241
+ return { sourceFound: false, chunks: 0, evidenceLinks: 0, factsErased: 0, factsKept: 0, vectors: 0, chunkIds: [] };
242
+ }
243
+ const chunkIds = (await this.db.all(`SELECT id FROM ${this.tables.chunks} WHERE source_id = ?`, [sourceId])).map((r) => String(r['id']));
244
+ let links = 0;
245
+ const affected = new Set();
246
+ for (const batch of batches(chunkIds, 500)) {
247
+ for (const row of await this.db.all(`SELECT DISTINCT fact_id FROM ${this.tables.evidence} WHERE chunk_id IN (${marks(batch)})`, batch)) {
248
+ affected.add(String(row['fact_id']));
249
+ }
250
+ links += await this.db.run(`DELETE FROM ${this.tables.evidence} WHERE chunk_id IN (${marks(batch)})`, batch);
251
+ }
252
+ let erased = 0;
253
+ let kept = 0;
254
+ for (const batch of batches([...affected], 500)) {
255
+ const orphans = (await this.db.all(`SELECT f.id FROM ${this.tables.facts} f WHERE f.id IN (${marks(batch)})
256
+ AND NOT EXISTS (SELECT 1 FROM ${this.tables.evidence} e WHERE e.fact_id = f.id)`, batch)).map((r) => String(r['id']));
257
+ if (orphans.length > 0) {
258
+ erased += await this.db.run(`DELETE FROM ${this.tables.facts} WHERE id IN (${marks(orphans)})`, orphans);
259
+ }
260
+ kept += batch.length - orphans.length;
261
+ }
262
+ const chunks = await this.db.run(`DELETE FROM ${this.tables.chunks} WHERE source_id = ?`, [sourceId]);
263
+ await this.db.run(`DELETE FROM ${this.tables.sources} WHERE id = ?`, [sourceId]);
264
+ return { sourceFound: true, chunks, evidenceLinks: links, factsErased: erased, factsKept: kept, vectors: 0, chunkIds };
265
+ });
266
+ }
267
+ /** Validate a fact and decide its status, READING ONLY. */
268
+ async prepareFact(scope, fact, policy) {
269
+ assertKey(fact.id);
270
+ const from = normaliseTime(fact.validFrom);
271
+ const to = fact.validTo == null ? null : normaliseTime(fact.validTo);
272
+ if (to !== null && to < from) {
273
+ throw new GraphError('A fact cannot end before it starts.');
274
+ }
275
+ if (fact.supersededBy != null || fact.status != null || fact.recordedAt != null || fact.expiredAt != null) {
276
+ throw new GraphError('Status, recorded times and superseded_by are set by the store, never asserted.');
277
+ }
278
+ const evidence = [...new Set(fact.evidence)];
279
+ if (evidence.length === 0) {
280
+ throw new GraphError('A fact must cite at least one chunk as evidence.');
281
+ }
282
+ // Evidence must be visible from the FACT's scope: a tenant-wide fact can
283
+ // never cite one person's private thread.
284
+ const chunks = await this.getChunks(scope, evidence);
285
+ if (chunks.length !== evidence.length) {
286
+ throw new GraphError("Every evidence id must name a stored chunk visible from the fact's scope.");
287
+ }
288
+ if (chunks.some((c) => c.retiredAt != null)) {
289
+ throw new GraphError('A retired chunk cannot be cited as new evidence.');
290
+ }
291
+ return { from, to, evidence, status: policy.decide(chunks.map((c) => c.trust ?? Trust.System)) };
292
+ }
293
+ async writeFact(scope, fact, prepared) {
294
+ const now = this.now();
295
+ const id = scope.qualify('fact', fact.id);
296
+ await this.db.run(`INSERT INTO ${this.tables.facts}
297
+ (id, fact_key, tenant_id, owner_id, agent_id, thread_id, subject, predicate, object, status, valid_from, valid_to, superseded_by, recorded_at, expired_at)
298
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, NULL)`, [id, fact.id, ...scope.toRow(), fact.subject, fact.predicate, fact.object, prepared.status, prepared.from, prepared.to, now]);
299
+ for (const chunk of prepared.evidence) {
300
+ await this.db.run(`INSERT INTO ${this.tables.evidence} (fact_id, chunk_id) VALUES (?, ?)`, [id, chunk]);
301
+ }
302
+ return { ...fact, evidence: prepared.evidence, validFrom: prepared.from, validTo: prepared.to, supersededBy: null, status: prepared.status, recordedAt: now, expiredAt: null };
303
+ }
304
+ async retireExcept(sourceId, keep) {
305
+ await this.db.run(`UPDATE ${this.tables.chunks} SET retired_at = ? WHERE source_id = ? AND retired_at IS NULL`, [this.now(), sourceId]);
306
+ for (const batch of batches([...new Set(keep)], 500)) {
307
+ await this.db.run(`UPDATE ${this.tables.chunks} SET retired_at = NULL WHERE source_id = ? AND id IN (${marks(batch)})`, [sourceId, ...batch]);
308
+ }
309
+ return Number((await this.db.all(`SELECT COUNT(*) AS n FROM ${this.tables.chunks} WHERE source_id = ? AND retired_at IS NOT NULL`, [sourceId]))[0]?.['n'] ?? 0);
310
+ }
311
+ /** The visibility predicate for a reader, over a table aliased `alias` carrying the scope columns. */
312
+ visible(alias, reader) {
313
+ return [
314
+ `${alias}.tenant_id = ?`
315
+ + ` AND (${alias}.owner_id IS NULL OR ${alias}.owner_id = ?)`
316
+ + ` AND (${alias}.agent_id IS NULL OR ${alias}.agent_id = ?)`
317
+ + ` AND (${alias}.thread_id IS NULL OR ${alias}.thread_id = ?)`,
318
+ reader.toRow(),
319
+ ];
320
+ }
321
+ async toFact(row) {
322
+ const evidence = (await this.db.all(`SELECT chunk_id FROM ${this.tables.evidence} WHERE fact_id = ? ORDER BY chunk_id`, [String(row['id'])]))
323
+ .map((r) => String(r['chunk_id']));
324
+ const opt = (v) => (v == null ? null : String(v));
325
+ return {
326
+ id: String(row['fact_key']), subject: String(row['subject']), predicate: String(row['predicate']), object: String(row['object']),
327
+ evidence, validFrom: String(row['valid_from']), validTo: opt(row['valid_to']), supersededBy: opt(row['superseded_by']),
328
+ status: String(row['status']), recordedAt: String(row['recorded_at']), expiredAt: opt(row['expired_at']),
329
+ };
330
+ }
331
+ now() {
332
+ return normaliseTime(this.clock());
333
+ }
334
+ }
335
+ /**
336
+ * An FTS5 MATCH expression in which every term is a quoted string (inner quotes
337
+ * doubled): a literal, never an operator, column filter or prefix. ORed, so
338
+ * BM25 ranks partial matches instead of dropping them.
339
+ */
340
+ export function ftsMatch(terms) {
341
+ return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(' OR ');
342
+ }
343
+ /**
344
+ * Validate a UTC ISO 8601 time and return it in ONE fixed-width form
345
+ * (microseconds, Z), identical to the PHP reference, so stored times compare
346
+ * correctly as strings whatever precision they were given in.
347
+ */
348
+ export function normaliseTime(value) {
349
+ const match = TIME.exec(value);
350
+ if (match === null) {
351
+ throw new InvalidArgumentError('Times must be UTC ISO 8601, e.g. 2026-11-14T00:00:00Z.');
352
+ }
353
+ const base = match[1];
354
+ const parsed = new Date(`${base}Z`);
355
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 19) !== base) {
356
+ throw new InvalidArgumentError('Times must name a real UTC date and time.');
357
+ }
358
+ return `${base}.${(match[2] ?? '.').slice(1).padEnd(6, '0')}Z`;
359
+ }
360
+ function assertKey(key) {
361
+ if (!KEY.test(key)) {
362
+ throw new InvalidArgumentError('Keys are 1-191 printable ASCII characters with no spaces.');
363
+ }
364
+ }
365
+ function marks(values) {
366
+ return values.map(() => '?').join(',');
367
+ }
368
+ function batches(values, size) {
369
+ const out = [];
370
+ for (let i = 0; i < values.length; i += size) {
371
+ out.push(values.slice(i, i + size));
372
+ }
373
+ return out;
374
+ }
375
+ function toChunk(row) {
376
+ const trust = row['trust'] == null ? null : String(row['trust']);
377
+ return {
378
+ id: String(row['id']),
379
+ sourceId: String(row['source_id']),
380
+ text: String(row['text']),
381
+ byteStart: Number(row['byte_start']),
382
+ byteEnd: Number(row['byte_end']),
383
+ lineStart: Number(row['line_start']),
384
+ lineEnd: Number(row['line_end']),
385
+ headingPath: JSON.parse(String(row['heading_path'])),
386
+ recipe: String(row['recipe']),
387
+ retiredAt: row['retired_at'] == null ? null : String(row['retired_at']),
388
+ sourceKey: row['source_key'] == null ? null : String(row['source_key']),
389
+ trust: trust !== null && isTrust(trust) ? trust : null,
390
+ };
391
+ }
@@ -0,0 +1,28 @@
1
+ import type { ScopedGraph } from './graph.js';
2
+ /**
3
+ * A tool any MCP server can register: plain data plus an async call. No MCP
4
+ * SDK dependency, so it mounts on prism-mcp-server, the official SDK's
5
+ * low-level Server, or an app's own server alike.
6
+ */
7
+ export interface MemoryToolDefinition {
8
+ readonly name: string;
9
+ readonly description: string;
10
+ /** JSON Schema for the arguments object. */
11
+ readonly inputSchema: Record<string, unknown>;
12
+ /** Runs the tool. Always resolves to a JSON string; never rejects at the model. */
13
+ call(args: unknown): Promise<string>;
14
+ }
15
+ export declare const MAX_RESULTS = 20;
16
+ export declare const MAX_IDS = 50;
17
+ export declare const MAX_NOTE_BYTES = 8192;
18
+ /**
19
+ * Agent tools over ONE scope of memory: search, get, facts, timeline, and note.
20
+ * The scope and the writer are bound here, by the app, never taken from a
21
+ * model's arguments. A note is stored with trust "agent": searchable, but it
22
+ * never becomes an accepted fact by being written. No tool asserts, accepts,
23
+ * supersedes or erases: those are app decisions.
24
+ */
25
+ export declare function memoryTools(memory: ScopedGraph, writer: string, options?: {
26
+ prefix?: string;
27
+ withNote?: boolean;
28
+ }): MemoryToolDefinition[];