@4fu/arbor-notes-sqlite 0.0.0-stage → 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/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@4fu/arbor-notes-sqlite",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "The default NoteStore: one copy of every note, checked writes, revisions, anchors and the active set on SQLite beside the journal.",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "default": "./index.js"
10
+ }
11
+ },
12
+ "types": "./index.d.ts",
13
+ "main": "./index.js",
14
+ "files": [
15
+ "diff.d.ts",
16
+ "diff.js",
17
+ "index.d.ts",
18
+ "index.js",
19
+ "notes.d.ts",
20
+ "notes.js",
21
+ "refs.d.ts",
22
+ "refs.js",
23
+ "schema.d.ts",
24
+ "schema.js",
25
+ "score.d.ts",
26
+ "score.js",
27
+ "search.d.ts",
28
+ "search.js"
29
+ ],
30
+ "engines": {
31
+ "node": ">=24"
32
+ },
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/4fuu/arbor.git",
37
+ "directory": "packages/notes-sqlite"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@4fu/arbor-errors": "0.1.0",
44
+ "@4fu/arbor-interfaces": "0.1.0"
45
+ }
46
+ }
package/refs.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Note refs (docs/journal.md#refs).
3
+ *
4
+ * A note is `n012`; one of its revisions is `n012@3`. A bare `n012` means the
5
+ * latest revision. Everything else in the store reads refs through here, so a
6
+ * malformed one is refused in the core's error shape instead of reaching
7
+ * storage.
8
+ */
9
+ import type { EventRef, NoteId, NoteRef, Ref, WindowRef } from "@4fu/arbor-interfaces";
10
+ export declare function noteIdOf(id: number): NoteId;
11
+ export declare function noteRefOf(id: number, revision: number): NoteRef;
12
+ /** The note id a ref names, with or without a revision, or `null`. */
13
+ export declare function parseNoteId(ref: string): number | null;
14
+ /** The note id and the revision a ref names; a bare id means the latest. */
15
+ export declare function parseNoteRef(ref: string): {
16
+ id: number;
17
+ revision: number | null;
18
+ } | null;
19
+ /** The forms one note ref is looked up under: the revision, then the bare id. */
20
+ export declare function noteRefForms(ref: Ref): Ref[];
21
+ /** A window ref read back out of storage. */
22
+ export declare function storedWindowRef(value: string | null): WindowRef | null;
23
+ /** An event ref read back out of storage. */
24
+ export declare function storedEventRef(value: string | null): EventRef | null;
package/refs.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Note refs (docs/journal.md#refs).
3
+ *
4
+ * A note is `n012`; one of its revisions is `n012@3`. A bare `n012` means the
5
+ * latest revision. Everything else in the store reads refs through here, so a
6
+ * malformed one is refused in the core's error shape instead of reaching
7
+ * storage.
8
+ */
9
+ export function noteIdOf(id) {
10
+ return `n${id}`;
11
+ }
12
+ export function noteRefOf(id, revision) {
13
+ return `n${id}@${revision}`;
14
+ }
15
+ /** The note id a ref names, with or without a revision, or `null`. */
16
+ export function parseNoteId(ref) {
17
+ const match = /^n(\d+)(?:@(\d+))?$/.exec(ref);
18
+ if (match === null)
19
+ return null;
20
+ return Number(match[1]);
21
+ }
22
+ /** The note id and the revision a ref names; a bare id means the latest. */
23
+ export function parseNoteRef(ref) {
24
+ const match = /^n(\d+)(?:@(\d+))?$/.exec(ref);
25
+ if (match === null)
26
+ return null;
27
+ return { id: Number(match[1]), revision: match[2] === undefined ? null : Number(match[2]) };
28
+ }
29
+ /** The forms one note ref is looked up under: the revision, then the bare id. */
30
+ export function noteRefForms(ref) {
31
+ const match = /^(n\d+)@(\d+)$/.exec(ref);
32
+ return match === null ? [ref] : [ref, match[1]];
33
+ }
34
+ /** A window ref read back out of storage. */
35
+ export function storedWindowRef(value) {
36
+ if (value === null)
37
+ return null;
38
+ if (!/^c\d+$/.test(value))
39
+ throw new Error(`stored window ref ${value} is not a ref`);
40
+ return value;
41
+ }
42
+ /** An event ref read back out of storage. */
43
+ export function storedEventRef(value) {
44
+ if (value === null)
45
+ return null;
46
+ if (!/^r\d+$/.test(value))
47
+ throw new Error(`stored event ref ${value} is not a ref`);
48
+ return value;
49
+ }
package/schema.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The SQLite schema for notes (docs/memory.md).
3
+ *
4
+ * Notes are the long-term memory: one copy of each, shared by every window.
5
+ * The store keeps its own database beside the journal's, and records every
6
+ * write in the journal as a `note_revision` event, so the journal stays the one
7
+ * record of what happened while the store stays the one copy of the content.
8
+ *
9
+ * Layout:
10
+ *
11
+ * - `notes` - one row per note: its current state. A delete leaves the row and
12
+ * marks it `deleted`, so every ref to it keeps resolving.
13
+ * - `note_revisions` - one row per revision, append-only, in the one global
14
+ * write order `wseq`. The body of every revision that ever was stays here.
15
+ * - `note_usage` - what windows cite, use as evidence and read, decayed by age
16
+ * when it is scored.
17
+ * - `note_refs` - the refs every revision names, with the note id when the ref
18
+ * is a note: the backlinks one lookup reads, and the evidence links the
19
+ * credit walk follows.
20
+ * - `note_epochs` - one row per snapshot epoch, with the write order it was
21
+ * built at.
22
+ * - `note_epoch_items` - the active notes one epoch froze, with the summaries
23
+ * it froze them at, so a window's prefix never changes under it.
24
+ * - `notes_fts` - trigram index for exact substrings over title, summary and
25
+ * body.
26
+ */
27
+ import type { DatabaseSync } from "node:sqlite";
28
+ /** Bumped when a change makes an existing database unusable. */
29
+ export declare const SCHEMA_VERSION = 1;
30
+ /** The FTS5 table note search reads. */
31
+ export declare const FTS_TABLE = "notes_fts";
32
+ /** Create the schema when it is missing, and check the version when it is not. */
33
+ export declare function createSchema(db: DatabaseSync): void;
34
+ export declare function readMeta(db: DatabaseSync, key: string): string | null;
package/schema.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The SQLite schema for notes (docs/memory.md).
3
+ *
4
+ * Notes are the long-term memory: one copy of each, shared by every window.
5
+ * The store keeps its own database beside the journal's, and records every
6
+ * write in the journal as a `note_revision` event, so the journal stays the one
7
+ * record of what happened while the store stays the one copy of the content.
8
+ *
9
+ * Layout:
10
+ *
11
+ * - `notes` - one row per note: its current state. A delete leaves the row and
12
+ * marks it `deleted`, so every ref to it keeps resolving.
13
+ * - `note_revisions` - one row per revision, append-only, in the one global
14
+ * write order `wseq`. The body of every revision that ever was stays here.
15
+ * - `note_usage` - what windows cite, use as evidence and read, decayed by age
16
+ * when it is scored.
17
+ * - `note_refs` - the refs every revision names, with the note id when the ref
18
+ * is a note: the backlinks one lookup reads, and the evidence links the
19
+ * credit walk follows.
20
+ * - `note_epochs` - one row per snapshot epoch, with the write order it was
21
+ * built at.
22
+ * - `note_epoch_items` - the active notes one epoch froze, with the summaries
23
+ * it froze them at, so a window's prefix never changes under it.
24
+ * - `notes_fts` - trigram index for exact substrings over title, summary and
25
+ * body.
26
+ */
27
+ /** Bumped when a change makes an existing database unusable. */
28
+ export const SCHEMA_VERSION = 1;
29
+ /** The FTS5 table note search reads. */
30
+ export const FTS_TABLE = "notes_fts";
31
+ const STATEMENTS = [
32
+ `PRAGMA journal_mode = WAL`,
33
+ `PRAGMA foreign_keys = ON`,
34
+ `PRAGMA busy_timeout = 5000`,
35
+ `CREATE TABLE IF NOT EXISTS meta (
36
+ key TEXT PRIMARY KEY,
37
+ value TEXT NOT NULL
38
+ )`,
39
+ // The note's current state. The revisions are the history; this row is what
40
+ // a list, a search and the active set read.
41
+ `CREATE TABLE IF NOT EXISTS notes (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ created INTEGER NOT NULL,
44
+ updated INTEGER NOT NULL,
45
+ revision INTEGER NOT NULL,
46
+ title TEXT NOT NULL,
47
+ summary TEXT NOT NULL,
48
+ body TEXT NOT NULL,
49
+ status TEXT NOT NULL,
50
+ window TEXT,
51
+ anchor TEXT,
52
+ event TEXT
53
+ )`,
54
+ `CREATE INDEX IF NOT EXISTS notes_status ON notes (status, id)`,
55
+ // One row per revision, append-only. `wseq` is the one global write order
56
+ // the epoch's delta is measured from; one note has one row per revision.
57
+ `CREATE TABLE IF NOT EXISTS note_revisions (
58
+ wseq INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ note INTEGER NOT NULL,
60
+ revision INTEGER NOT NULL,
61
+ title TEXT NOT NULL,
62
+ summary TEXT NOT NULL,
63
+ body TEXT NOT NULL,
64
+ deleted INTEGER NOT NULL DEFAULT 0,
65
+ time INTEGER NOT NULL,
66
+ window TEXT,
67
+ event TEXT,
68
+ anchor TEXT,
69
+ refs TEXT,
70
+ UNIQUE (note, revision)
71
+ )`,
72
+ // What windows cite, use as evidence and read. One event is recorded once,
73
+ // so the unique index leaves the reads - which name no event - alone.
74
+ `CREATE TABLE IF NOT EXISTS note_usage (
75
+ note INTEGER NOT NULL,
76
+ kind TEXT NOT NULL,
77
+ weight REAL NOT NULL,
78
+ window TEXT,
79
+ event TEXT,
80
+ time INTEGER NOT NULL
81
+ )`,
82
+ `CREATE UNIQUE INDEX IF NOT EXISTS note_usage_event
83
+ ON note_usage (note, kind, event) WHERE event IS NOT NULL`,
84
+ `CREATE INDEX IF NOT EXISTS note_usage_time ON note_usage (time)`,
85
+ // The refs every revision names: what it cites and what it holds as
86
+ // evidence. `to_note` is set when the ref is a note, which is what makes the
87
+ // evidence links between notes - the ones the credit walk follows.
88
+ `CREATE TABLE IF NOT EXISTS note_refs (
89
+ note INTEGER NOT NULL,
90
+ revision INTEGER NOT NULL,
91
+ kind TEXT NOT NULL,
92
+ ref TEXT NOT NULL,
93
+ to_note INTEGER,
94
+ weight REAL NOT NULL,
95
+ time INTEGER NOT NULL,
96
+ PRIMARY KEY (note, revision, kind, ref)
97
+ )`,
98
+ `CREATE INDEX IF NOT EXISTS note_refs_ref ON note_refs (ref, weight DESC)`,
99
+ `CREATE TABLE IF NOT EXISTS note_epochs (
100
+ epoch INTEGER PRIMARY KEY AUTOINCREMENT,
101
+ time INTEGER NOT NULL,
102
+ wseq INTEGER NOT NULL,
103
+ budget_count INTEGER NOT NULL,
104
+ budget_tokens INTEGER NOT NULL
105
+ )`,
106
+ // The active notes one epoch froze, with the summaries it froze them at.
107
+ `CREATE TABLE IF NOT EXISTS note_epoch_items (
108
+ epoch INTEGER NOT NULL,
109
+ note INTEGER NOT NULL,
110
+ revision INTEGER NOT NULL,
111
+ title TEXT NOT NULL,
112
+ summary TEXT NOT NULL,
113
+ tokens INTEGER NOT NULL,
114
+ time INTEGER NOT NULL,
115
+ PRIMARY KEY (epoch, note)
116
+ )`,
117
+ // Exact substrings, for Chinese and mixed text.
118
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${FTS_TABLE}
119
+ USING fts5(title, summary, body, tokenize = 'trigram')`,
120
+ ];
121
+ /** Create the schema when it is missing, and check the version when it is not. */
122
+ export function createSchema(db) {
123
+ // The version lives in the one table that has to exist before anything else.
124
+ db.exec(`CREATE TABLE IF NOT EXISTS meta (
125
+ key TEXT PRIMARY KEY,
126
+ value TEXT NOT NULL
127
+ )`);
128
+ const version = readMeta(db, "schema_version");
129
+ if (version !== null && Number(version) !== SCHEMA_VERSION) {
130
+ throw new Error(`notes schema ${version} is not supported by this build (${SCHEMA_VERSION}); migrate or rebuild the database`);
131
+ }
132
+ for (const statement of STATEMENTS)
133
+ db.exec(statement);
134
+ if (version === null) {
135
+ db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
136
+ }
137
+ }
138
+ export function readMeta(db, key) {
139
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
140
+ return row === undefined ? null : row.value;
141
+ }
package/score.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The active set's usage score (docs/memory.md#the-active-set).
3
+ *
4
+ * At each snapshot epoch the host picks the active set by usage score - what
5
+ * windows cite, use as evidence and read, decayed by age - within a count and
6
+ * token budget. Credit for the active set flows through evidence links up to
7
+ * three hops, decaying 50/25/12.5, with a visited set so a loop is walked once.
8
+ * A note gets its direct credit or its linked share, whichever is larger.
9
+ *
10
+ * The walk follows the evidence links of a note's current revision, in both
11
+ * directions, and a deleted note earns nothing and passes nothing on. A range
12
+ * link is not walked: `Anchor.range` is a string, not the refs of the two ends
13
+ * of the range, so arbor has no range link to follow.
14
+ */
15
+ import type { DatabaseSync } from "node:sqlite";
16
+ import type { ActiveSetBudget } from "@4fu/arbor-interfaces";
17
+ /** What a window's own use of a note is worth against a bare cite. */
18
+ export declare const USE_WEIGHTS: {
19
+ readonly cite: 1;
20
+ readonly evidence: 1;
21
+ readonly read: 0.25;
22
+ };
23
+ /** How far credit flows, and what each hop is worth: 50, 25, 12.5. */
24
+ export declare const MAX_CREDIT_HOPS = 3;
25
+ /** One note, scored for the active set. */
26
+ export interface ScoredNote {
27
+ note: number;
28
+ score: number;
29
+ /** The token estimate of the note's summary line, for the budget. */
30
+ tokens: number;
31
+ }
32
+ /** What is left of one use after its age: half of it every half-life. */
33
+ export declare function decay(ageMs: number, halfLifeMs: number): number;
34
+ /**
35
+ * A rough token estimate of one rendered line: a Chinese, Japanese or Korean
36
+ * character is one token, and every other script about four characters. It is
37
+ * an estimate, and it is the same one for every note, which is what the budget
38
+ * needs.
39
+ */
40
+ export declare function estimateTokens(text: string): number;
41
+ /** Every use still worth something, as decayed credit per note. */
42
+ export declare function loadCredit(db: DatabaseSync, now: number, halfLifeMs: number): Map<number, number>;
43
+ /**
44
+ * The evidence links between notes, in both directions. Only a note's current
45
+ * revision carries links, so an edge from an older revision is not there.
46
+ */
47
+ export declare function loadEdges(db: DatabaseSync): Map<number, Set<number>>;
48
+ /**
49
+ * The share of other notes' credit that reaches one note: a breadth-first walk
50
+ * over the evidence links, each note credited once at the shortest distance
51
+ * the walk reaches it, so a loop is walked once.
52
+ */
53
+ export declare function linkedCredit(note: number, credit: ReadonlyMap<number, number>, edges: ReadonlyMap<number, Set<number>>): number;
54
+ /** Every note that is not deleted, with its score, strongest first. */
55
+ export declare function scoreNotes(db: DatabaseSync, now: number, halfLifeMs: number): ScoredNote[];
56
+ /**
57
+ * The rule pick: the strongest notes first, ties by id, within the count and
58
+ * the token budget. A note with no use is not picked; freshness reaches a
59
+ * window through the delta, not through the snapshot.
60
+ */
61
+ export declare function pickByBudget(scored: readonly ScoredNote[], budget: ActiveSetBudget): ScoredNote[];
62
+ /**
63
+ * The judge's bounded share: at most a share of the budget, additive only. The
64
+ * judge adds notes the rule score did not pick and never drops one it did,
65
+ * because it never outweighs the model's own use.
66
+ */
67
+ export declare function applyJudgeShare(picked: readonly ScoredNote[], scored: ReadonlyMap<number, ScoredNote>, judgeKeep: readonly string[], judgeShare: number, budget: ActiveSetBudget): ScoredNote[];
package/score.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The active set's usage score (docs/memory.md#the-active-set).
3
+ *
4
+ * At each snapshot epoch the host picks the active set by usage score - what
5
+ * windows cite, use as evidence and read, decayed by age - within a count and
6
+ * token budget. Credit for the active set flows through evidence links up to
7
+ * three hops, decaying 50/25/12.5, with a visited set so a loop is walked once.
8
+ * A note gets its direct credit or its linked share, whichever is larger.
9
+ *
10
+ * The walk follows the evidence links of a note's current revision, in both
11
+ * directions, and a deleted note earns nothing and passes nothing on. A range
12
+ * link is not walked: `Anchor.range` is a string, not the refs of the two ends
13
+ * of the range, so arbor has no range link to follow.
14
+ */
15
+ /** What a window's own use of a note is worth against a bare cite. */
16
+ export const USE_WEIGHTS = { cite: 1, evidence: 1, read: 0.25 };
17
+ /** How far credit flows, and what each hop is worth: 50, 25, 12.5. */
18
+ export const MAX_CREDIT_HOPS = 3;
19
+ /** Usage older than this many half-lives has decayed to nothing worth. */
20
+ const USAGE_HALF_LIVES_LOADED = 10;
21
+ function toNumber(value) {
22
+ if (typeof value === "number")
23
+ return value;
24
+ if (typeof value === "bigint")
25
+ return Number(value);
26
+ throw new Error(`notes: storage returned a value that is not a number: ${String(value)}`);
27
+ }
28
+ /** What is left of one use after its age: half of it every half-life. */
29
+ export function decay(ageMs, halfLifeMs) {
30
+ if (ageMs <= 0)
31
+ return 1;
32
+ return Math.pow(0.5, ageMs / halfLifeMs);
33
+ }
34
+ /**
35
+ * A rough token estimate of one rendered line: a Chinese, Japanese or Korean
36
+ * character is one token, and every other script about four characters. It is
37
+ * an estimate, and it is the same one for every note, which is what the budget
38
+ * needs.
39
+ */
40
+ export function estimateTokens(text) {
41
+ let tokens = 0;
42
+ let other = 0;
43
+ for (const character of text) {
44
+ const codePoint = character.codePointAt(0);
45
+ if (isCjk(codePoint)) {
46
+ tokens += 1;
47
+ continue;
48
+ }
49
+ other += 1;
50
+ if (other === 4) {
51
+ tokens += 1;
52
+ other = 0;
53
+ }
54
+ }
55
+ return tokens + (other > 0 ? 1 : 0);
56
+ }
57
+ function isCjk(codePoint) {
58
+ return ((codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
59
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
60
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
61
+ (codePoint >= 0x3040 && codePoint <= 0x30ff) ||
62
+ (codePoint >= 0xac00 && codePoint <= 0xd7af) ||
63
+ (codePoint >= 0x20000 && codePoint <= 0x2a6df) ||
64
+ (codePoint >= 0x2a700 && codePoint <= 0x2ebef));
65
+ }
66
+ /** Every use still worth something, as decayed credit per note. */
67
+ export function loadCredit(db, now, halfLifeMs) {
68
+ const since = now - halfLifeMs * USAGE_HALF_LIVES_LOADED;
69
+ const rows = db
70
+ .prepare("SELECT note, weight, time FROM note_usage WHERE time > ?")
71
+ .all(since);
72
+ const credit = new Map();
73
+ for (const row of rows) {
74
+ const note = toNumber(row.note);
75
+ const weight = toNumber(row.weight);
76
+ const value = weight * decay(now - toNumber(row.time), halfLifeMs);
77
+ credit.set(note, (credit.get(note) ?? 0) + value);
78
+ }
79
+ return credit;
80
+ }
81
+ /**
82
+ * The evidence links between notes, in both directions. Only a note's current
83
+ * revision carries links, so an edge from an older revision is not there.
84
+ */
85
+ export function loadEdges(db) {
86
+ const rows = db
87
+ .prepare(`SELECT e.note AS from_note, e.to_note AS to_note
88
+ FROM note_refs e
89
+ JOIN notes f ON f.id = e.note AND f.revision = e.revision AND f.status != 'deleted'
90
+ JOIN notes t ON t.id = e.to_note AND t.status != 'deleted'
91
+ WHERE e.kind = 'evidence' AND e.to_note IS NOT NULL`)
92
+ .all();
93
+ const edges = new Map();
94
+ const link = (from, to) => {
95
+ const neighbours = edges.get(from) ?? new Set();
96
+ neighbours.add(to);
97
+ edges.set(from, neighbours);
98
+ };
99
+ for (const row of rows) {
100
+ const from = toNumber(row.from_note);
101
+ const to = toNumber(row.to_note);
102
+ link(from, to);
103
+ link(to, from);
104
+ }
105
+ return edges;
106
+ }
107
+ /**
108
+ * The share of other notes' credit that reaches one note: a breadth-first walk
109
+ * over the evidence links, each note credited once at the shortest distance
110
+ * the walk reaches it, so a loop is walked once.
111
+ */
112
+ export function linkedCredit(note, credit, edges) {
113
+ const visited = new Set([note]);
114
+ let frontier = [note];
115
+ let total = 0;
116
+ for (let hop = 1; hop <= MAX_CREDIT_HOPS && frontier.length > 0; hop += 1) {
117
+ const next = [];
118
+ for (const current of frontier) {
119
+ for (const neighbour of edges.get(current) ?? []) {
120
+ if (visited.has(neighbour))
121
+ continue;
122
+ visited.add(neighbour);
123
+ next.push(neighbour);
124
+ }
125
+ }
126
+ if (next.length === 0)
127
+ break;
128
+ const share = Math.pow(0.5, hop);
129
+ for (const reached of next) {
130
+ total += (credit.get(reached) ?? 0) * share;
131
+ }
132
+ frontier = next;
133
+ }
134
+ return total;
135
+ }
136
+ /** Every note that is not deleted, with its score, strongest first. */
137
+ export function scoreNotes(db, now, halfLifeMs) {
138
+ const credit = loadCredit(db, now, halfLifeMs);
139
+ const edges = loadEdges(db);
140
+ const rows = db
141
+ .prepare("SELECT id, title, summary FROM notes WHERE status != 'deleted'")
142
+ .all();
143
+ const scored = rows.map((row) => {
144
+ const note = toNumber(row.id);
145
+ const direct = credit.get(note) ?? 0;
146
+ return {
147
+ note,
148
+ score: Math.max(direct, linkedCredit(note, credit, edges)),
149
+ tokens: estimateTokens(`${row.title}\n${row.summary}`),
150
+ };
151
+ });
152
+ scored.sort((a, b) => b.score - a.score || a.note - b.note);
153
+ return scored;
154
+ }
155
+ /**
156
+ * The rule pick: the strongest notes first, ties by id, within the count and
157
+ * the token budget. A note with no use is not picked; freshness reaches a
158
+ * window through the delta, not through the snapshot.
159
+ */
160
+ export function pickByBudget(scored, budget) {
161
+ const picked = [];
162
+ let tokens = 0;
163
+ for (const item of scored) {
164
+ if (item.score <= 0)
165
+ break;
166
+ if (picked.length >= budget.count)
167
+ break;
168
+ if (tokens + item.tokens > budget.tokens)
169
+ continue;
170
+ picked.push(item);
171
+ tokens += item.tokens;
172
+ }
173
+ return picked;
174
+ }
175
+ /**
176
+ * The judge's bounded share: at most a share of the budget, additive only. The
177
+ * judge adds notes the rule score did not pick and never drops one it did,
178
+ * because it never outweighs the model's own use.
179
+ */
180
+ export function applyJudgeShare(picked, scored, judgeKeep, judgeShare, budget) {
181
+ const slots = Math.max(0, Math.floor(judgeShare * budget.count));
182
+ if (slots === 0 || judgeKeep.length === 0)
183
+ return [...picked];
184
+ const chosen = new Set(picked.map((item) => item.note));
185
+ let tokens = picked.reduce((total, item) => total + item.tokens, 0);
186
+ const added = [];
187
+ for (const ref of judgeKeep) {
188
+ if (added.length >= slots || chosen.size >= budget.count)
189
+ break;
190
+ const match = /^n(\d+)(?:@(\d+))?$/.exec(ref);
191
+ if (match === null)
192
+ continue;
193
+ const item = scored.get(Number(match[1]));
194
+ if (item === undefined || chosen.has(item.note))
195
+ continue;
196
+ if (tokens + item.tokens > budget.tokens)
197
+ continue;
198
+ added.push(item);
199
+ chosen.add(item.note);
200
+ tokens += item.tokens;
201
+ }
202
+ return [...picked, ...added];
203
+ }
package/search.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Lexical search over notes (docs/journal.md#search, docs/memory.md).
3
+ *
4
+ * Lists show summaries and never bodies, so note search is a lookup of the
5
+ * notes whose title, summary or body holds the query as an exact substring:
6
+ * the trigram index answers three characters and more, which is what makes a
7
+ * Chinese substring findable, and a `LIKE` scan answers a one- or
8
+ * two-character query, which has no trigram of its own. Results come back in id
9
+ * order, so a page is byte-stable however it is paged.
10
+ */
11
+ import type { DatabaseSync, SQLInputValue } from "node:sqlite";
12
+ /** A query shorter than this cannot use the trigram index. */
13
+ export declare const MIN_TRIGRAM_CHARS = 3;
14
+ /** One condition over `notes n`, and the values it is bound to. */
15
+ export interface SubstringClause {
16
+ sql: string;
17
+ params: readonly SQLInputValue[];
18
+ }
19
+ /** The notes holding the query, as a condition over `notes n`. */
20
+ export declare function substringClause(query: string): SubstringClause;
21
+ /** The ids of several notes that hold the query, by id. */
22
+ export declare function holdingIds(db: DatabaseSync, query: string, ids: readonly number[]): number[];
23
+ /** Escape a literal for `LIKE ... ESCAPE '\'`. */
24
+ export declare function escapeLike(value: string): string;
25
+ export declare function likePattern(query: string): string;
26
+ /** Quote one phrase for an FTS5 `MATCH`, so outside text is never syntax. */
27
+ export declare function quoteFts(value: string): string;
package/search.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Lexical search over notes (docs/journal.md#search, docs/memory.md).
3
+ *
4
+ * Lists show summaries and never bodies, so note search is a lookup of the
5
+ * notes whose title, summary or body holds the query as an exact substring:
6
+ * the trigram index answers three characters and more, which is what makes a
7
+ * Chinese substring findable, and a `LIKE` scan answers a one- or
8
+ * two-character query, which has no trigram of its own. Results come back in id
9
+ * order, so a page is byte-stable however it is paged.
10
+ */
11
+ import { FTS_TABLE } from "./schema.js";
12
+ /** A query shorter than this cannot use the trigram index. */
13
+ export const MIN_TRIGRAM_CHARS = 3;
14
+ /** The notes holding the query, as a condition over `notes n`. */
15
+ export function substringClause(query) {
16
+ if (Array.from(query).length >= MIN_TRIGRAM_CHARS) {
17
+ return {
18
+ sql: `n.id IN (SELECT rowid FROM ${FTS_TABLE} WHERE ${FTS_TABLE} MATCH ?)`,
19
+ params: [quoteFts(query)],
20
+ };
21
+ }
22
+ const pattern = likePattern(query);
23
+ return {
24
+ sql: `(n.title LIKE ? ESCAPE '\\' OR n.summary LIKE ? ESCAPE '\\' OR n.body LIKE ? ESCAPE '\\')`,
25
+ params: [pattern, pattern, pattern],
26
+ };
27
+ }
28
+ /** The ids of several notes that hold the query, by id. */
29
+ export function holdingIds(db, query, ids) {
30
+ if (ids.length === 0)
31
+ return [];
32
+ const clause = substringClause(query);
33
+ const rows = db
34
+ .prepare(`SELECT n.id AS id FROM notes n
35
+ WHERE ${clause.sql} AND n.id IN (${ids.map(() => "?").join(", ")})
36
+ ORDER BY n.id`)
37
+ .all(...clause.params, ...ids);
38
+ return rows.map((row) => Number(row.id));
39
+ }
40
+ /** Escape a literal for `LIKE ... ESCAPE '\'`. */
41
+ export function escapeLike(value) {
42
+ return value.replace(/[\\%_]/g, (character) => `\\${character}`);
43
+ }
44
+ export function likePattern(query) {
45
+ return `%${escapeLike(query)}%`;
46
+ }
47
+ /** Quote one phrase for an FTS5 `MATCH`, so outside text is never syntax. */
48
+ export function quoteFts(value) {
49
+ return `"${value.replace(/"/g, '""')}"`;
50
+ }