@ijfw/memory-server 1.3.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.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Memory reader -- unified 5-tier reader.
3
+ * Tier 1: Claude auto-memory ~/.claude/projects/<slug>/memory/**\/*.md
4
+ * Tier 2: Project .ijfw/memory <cwd>/.ijfw/memory/**\/*.md
5
+ * Tier 3: Session records <cwd>/.ijfw/sessions/*.md
6
+ * Tier 4: Global observations ~/.ijfw/observations.jsonl (summarized)
7
+ * Tier 5: Global handoff ~/.ijfw/HANDOFF.md if exists
8
+ *
9
+ * Each file gets a `tier` field: "Auto-memory" / "Project" / "Sessions" / "Global" / "Handoff"
10
+ * Zero deps: node:fs, node:path, node:os only.
11
+ */
12
+
13
+ import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
14
+ import { join, relative, basename } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+
17
+ const HOME = homedir();
18
+ const IJFW_DIR = join(HOME, '.ijfw');
19
+ const CLAUDE_PROJS = join(HOME, '.claude', 'projects');
20
+ const PREVIEW_CHARS = 300;
21
+
22
+ /** Parse YAML-style frontmatter (key: value lines between --- fences). */
23
+ function parseFrontmatter(raw) {
24
+ const fm = { title: null, description: null, type: null };
25
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
26
+ if (!m) return fm;
27
+ for (const line of m[1].split('\n')) {
28
+ const kv = line.match(/^(\w+):\s*(.+)/);
29
+ if (!kv) continue;
30
+ const key = kv[1].toLowerCase();
31
+ if (key === 'title') fm.title = kv[2].trim();
32
+ if (key === 'description') fm.description = kv[2].trim();
33
+ if (key === 'type') fm.type = kv[2].trim();
34
+ }
35
+ return fm;
36
+ }
37
+
38
+ /** Walk dir recursively collecting .md files, max depth 4. */
39
+ function walkMd(dir, base, depth = 0) {
40
+ if (depth > 4) return [];
41
+ let entries;
42
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
43
+ const results = [];
44
+ for (const e of entries) {
45
+ const full = join(dir, e.name);
46
+ if (e.isDirectory()) {
47
+ results.push(...walkMd(full, base, depth + 1));
48
+ } else if (e.isFile() && e.name.endsWith('.md')) {
49
+ results.push({ full, rel: relative(base, full) });
50
+ }
51
+ }
52
+ return results;
53
+ }
54
+
55
+ /** Build a file entry from a path + tier label. */
56
+ function buildEntry(full, rel, tier) {
57
+ try {
58
+ const st = statSync(full);
59
+ const raw = readFileSync(full, 'utf8');
60
+ const fm = parseFrontmatter(raw);
61
+
62
+ let title = fm.title;
63
+ if (!title) {
64
+ const hm = raw.match(/^#\s+(.+)/m);
65
+ title = hm ? hm[1].trim() : basename(full, '.md');
66
+ }
67
+
68
+ const body = raw.replace(/^---[\s\S]*?---\r?\n/, '').trimStart();
69
+ const preview = body.slice(0, PREVIEW_CHARS).replace(/\s+/g, ' ').trim();
70
+
71
+ return {
72
+ path: full,
73
+ relpath: rel,
74
+ title,
75
+ description: fm.description || null,
76
+ type: fm.type || null,
77
+ preview,
78
+ last_modified: st.mtimeMs,
79
+ size: st.size,
80
+ tier,
81
+ };
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /** Map a project dir path to its Claude project slug. */
88
+ function pathToSlug(projectPath) {
89
+ // Claude uses the absolute path with / replaced by -. On Windows the path
90
+ // is C:\\Users\\... -- strip the drive letter and replace both separator
91
+ // styles so the slug is a flat dir name that round-trips with mkdirSync.
92
+ return projectPath.replace(/^[A-Z]:/i, '').replace(/[\\/]/g, '-');
93
+ }
94
+
95
+ /** Find the Claude project slug for a repo root by matching path-based slug. */
96
+ function findClaudeSlug(repoRoot) {
97
+ if (!repoRoot || !existsSync(CLAUDE_PROJS)) return null;
98
+ const slug = pathToSlug(repoRoot);
99
+ const candidate = join(CLAUDE_PROJS, slug);
100
+ if (existsSync(candidate)) return slug;
101
+ // Try reading all slugs for a suffix match
102
+ try {
103
+ const slugs = readdirSync(CLAUDE_PROJS, { withFileTypes: true })
104
+ .filter(e => e.isDirectory())
105
+ .map(e => e.name);
106
+ // Match by resolved path suffix
107
+ const normalized = repoRoot.replace(/\/$/, '');
108
+ for (const s of slugs) {
109
+ if (normalized.endsWith(s.replace(/-/g, '/').replace(/^\//, ''))) return s;
110
+ }
111
+ return slugs.find(s => s === slug) || null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Tier 1: Claude auto-memory files for the current project.
119
+ */
120
+ function readTier1(repoRoot) {
121
+ const slug = findClaudeSlug(repoRoot);
122
+ if (!slug) return [];
123
+ const memDir = join(CLAUDE_PROJS, slug, 'memory');
124
+ if (!existsSync(memDir)) return [];
125
+ return walkMd(memDir, memDir)
126
+ .map(({ full, rel }) => buildEntry(full, rel, 'Auto-memory'))
127
+ .filter(Boolean);
128
+ }
129
+
130
+ /**
131
+ * Tier 2: Project .ijfw/memory files.
132
+ * Reads both ~/.ijfw/memory (global project memory) and <repoRoot>/.ijfw/memory.
133
+ */
134
+ function readTier2(repoRoot) {
135
+ const dirs = [];
136
+ if (repoRoot) dirs.push(join(repoRoot, '.ijfw', 'memory'));
137
+ const globalMem = join(IJFW_DIR, 'memory');
138
+ if (existsSync(globalMem)) dirs.push(globalMem);
139
+
140
+ const files = [];
141
+ for (const dir of dirs) {
142
+ if (!existsSync(dir)) continue;
143
+ const entries = walkMd(dir, dir)
144
+ .map(({ full, rel }) => buildEntry(full, rel, 'Project'))
145
+ .filter(Boolean);
146
+ // Deduplicate by path
147
+ for (const e of entries) {
148
+ if (!files.find(f => f.path === e.path)) files.push(e);
149
+ }
150
+ }
151
+ return files;
152
+ }
153
+
154
+ /**
155
+ * Tier 3: Session records -- .md files in <repoRoot>/.ijfw/sessions/.
156
+ */
157
+ function readTier3(repoRoot) {
158
+ if (!repoRoot) return [];
159
+ const sessDir = join(repoRoot, '.ijfw', 'sessions');
160
+ if (!existsSync(sessDir)) return [];
161
+ return walkMd(sessDir, sessDir)
162
+ .map(({ full, rel }) => buildEntry(full, rel, 'Sessions'))
163
+ .filter(Boolean);
164
+ }
165
+
166
+ /**
167
+ * Tier 4: Global observations -- summarizes ~/.ijfw/observations.jsonl.
168
+ * Returns synthetic entries grouped by platform.
169
+ */
170
+ function readTier4() {
171
+ const obsPath = join(IJFW_DIR, 'observations.jsonl');
172
+ if (!existsSync(obsPath)) return [];
173
+ try {
174
+ const lines = readFileSync(obsPath, 'utf8').split('\n').filter(Boolean);
175
+ const total = lines.length;
176
+ if (!total) return [];
177
+ const st = statSync(obsPath);
178
+ // Count by platform for recall counts
179
+ const platformCounts = {};
180
+ for (const line of lines) {
181
+ try {
182
+ const obj = JSON.parse(line);
183
+ const p = obj.platform || 'unknown';
184
+ platformCounts[p] = (platformCounts[p] || 0) + 1;
185
+ } catch {}
186
+ }
187
+ const platformSummary = Object.entries(platformCounts)
188
+ .map(([p, c]) => `${p}: ${c}`)
189
+ .join(', ');
190
+ return [{
191
+ path: obsPath,
192
+ relpath: 'observations.jsonl',
193
+ title: `Global observations (${total} events)`,
194
+ description: platformSummary || null,
195
+ type: 'observations',
196
+ preview: `${total} observation events across ${Object.keys(platformCounts).length} platforms. ${platformSummary}`,
197
+ last_modified: st.mtimeMs,
198
+ size: st.size,
199
+ tier: 'Global',
200
+ count: total,
201
+ }];
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Tier 5: Global HANDOFF.md.
209
+ */
210
+ function readTier5() {
211
+ const handoffPath = join(IJFW_DIR, 'HANDOFF.md');
212
+ if (!existsSync(handoffPath)) return [];
213
+ const entry = buildEntry(handoffPath, 'HANDOFF.md', 'Handoff');
214
+ return entry ? [entry] : [];
215
+ }
216
+
217
+ /**
218
+ * List all memory files across all 5 tiers.
219
+ * @param {string|null} repoRoot
220
+ * @param {string|null} tierFilter - filter to one tier label (optional)
221
+ * @returns {{ files: Array, total: number, root: string|null, tiers: Object }}
222
+ */
223
+ export function listMemoryFiles(repoRoot, tierFilter = null) {
224
+ const t1 = readTier1(repoRoot);
225
+ const t2 = readTier2(repoRoot);
226
+ const t3 = readTier3(repoRoot);
227
+ const t4 = readTier4();
228
+ const t5 = readTier5();
229
+
230
+ const all = [...t1, ...t2, ...t3, ...t4, ...t5];
231
+
232
+ // Compute per-tier counts before filtering
233
+ const tiers = {
234
+ 'Auto-memory': t1.length,
235
+ 'Project': t2.length,
236
+ 'Sessions': t3.length,
237
+ 'Global': t4.length,
238
+ 'Handoff': t5.length,
239
+ };
240
+
241
+ let files = all;
242
+ if (tierFilter) {
243
+ files = all.filter(f => f.tier === tierFilter);
244
+ }
245
+
246
+ // Sort by most recently modified within each tier grouping
247
+ files.sort((a, b) => b.last_modified - a.last_modified);
248
+
249
+ // Use the first non-null path as the security root for /api/memory/file
250
+ const root = repoRoot || IJFW_DIR;
251
+
252
+ return { files, total: files.length, root, tiers };
253
+ }
254
+
255
+ /** List all known projects by scanning ~/.claude/projects/. */
256
+ export function listKnownProjects() {
257
+ if (!existsSync(CLAUDE_PROJS)) return [];
258
+ try {
259
+ const entries = readdirSync(CLAUDE_PROJS, { withFileTypes: true })
260
+ .filter(e => e.isDirectory());
261
+ return entries.map(e => {
262
+ const slug = e.name;
263
+ const memDir = join(CLAUDE_PROJS, slug, 'memory');
264
+ let lastActivity = null;
265
+ let memCount = 0;
266
+ try {
267
+ const st = statSync(join(CLAUDE_PROJS, slug));
268
+ lastActivity = new Date(st.mtimeMs).toISOString();
269
+ } catch {}
270
+ if (existsSync(memDir)) {
271
+ try { memCount = readdirSync(memDir).filter(n => n.endsWith('.md')).length; } catch {}
272
+ }
273
+ // Convert slug back to path (best-effort)
274
+ const projectPath = slug.replace(/-/g, '/').replace(/^\//, '/');
275
+ return { slug, projectPath, lastActivity, memCount };
276
+ }).sort((a, b) => (b.lastActivity || '') > (a.lastActivity || '') ? 1 : -1);
277
+ } catch {
278
+ return [];
279
+ }
280
+ }
281
+
282
+ /** Resolve a "legacy" single-root for backward compat with /api/memory/file security check. */
283
+ export function resolveMemoryRoot(repoRoot) {
284
+ if (repoRoot) {
285
+ const local = join(repoRoot, '.ijfw', 'memory');
286
+ if (existsSync(local)) return local;
287
+ }
288
+ const global = join(IJFW_DIR, 'memory');
289
+ if (existsSync(global)) return global;
290
+ return null;
291
+ }
292
+
293
+ /** Read raw body of a single memory file. Returns null if unreadable. */
294
+ export function readMemoryFile(filePath) {
295
+ try {
296
+ return readFileSync(filePath, 'utf8');
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Recall counter -- counts per-file recall events from observations.jsonl.
3
+ * A "memory-recall" observation has {type:"memory-recall", file_path:<str>}.
4
+ * Also accepts legacy observations where tool_name === "ijfw_memory_recall"
5
+ * and files[0] is the memory file path.
6
+ */
7
+
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+
10
+ const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
11
+
12
+ /**
13
+ * @param {string} ledgerPath path to observations.jsonl
14
+ * @returns {{ counts: Map<string,number>, totalThisWeek: number, allTime: Map<string,number> }}
15
+ */
16
+ export function buildRecallCounts(ledgerPath) {
17
+ const counts = new Map(); // all-time per file
18
+ const weekCounts = new Map(); // this-week per file
19
+ let totalThisWeek = 0;
20
+
21
+ if (!existsSync(ledgerPath)) return { counts, weekCounts, totalThisWeek };
22
+
23
+ const cutoff = Date.now() - MS_PER_WEEK;
24
+ let raw;
25
+ try { raw = readFileSync(ledgerPath, 'utf8'); } catch { return { counts, weekCounts, totalThisWeek }; }
26
+
27
+ for (const line of raw.split('\n')) {
28
+ if (!line.trim()) continue;
29
+ let obs;
30
+ try { obs = JSON.parse(line); } catch { continue; }
31
+
32
+ // Detect memory-recall observation by type or tool_name
33
+ const isRecall = obs.type === 'memory-recall' ||
34
+ (obs.tool_name === 'ijfw_memory_recall');
35
+ if (!isRecall) continue;
36
+
37
+ const fp = obs.file_path || (obs.files && obs.files[0]) || null;
38
+ if (!fp) { totalThisWeek++; continue; } // count global recalls without file
39
+
40
+ counts.set(fp, (counts.get(fp) || 0) + 1);
41
+
42
+ const ts = obs.ts ? new Date(obs.ts).getTime() : 0;
43
+ if (ts >= cutoff) {
44
+ weekCounts.set(fp, (weekCounts.get(fp) || 0) + 1);
45
+ totalThisWeek++;
46
+ }
47
+ }
48
+
49
+ return { counts, weekCounts, totalThisWeek };
50
+ }
51
+
52
+ /**
53
+ * Merge recall counts into file list.
54
+ * @param {Array} files from listMemoryFiles()
55
+ * @param {Map<string,number>} counts
56
+ * @param {Map<string,number>} weekCounts
57
+ * @returns {Array} files with recall_count + recall_count_week added
58
+ */
59
+ export function mergeRecallCounts(files, counts, weekCounts) {
60
+ return files.map(f => ({
61
+ ...f,
62
+ recall_count: counts.get(f.path) || 0,
63
+ recall_count_week: weekCounts.get(f.path) || 0,
64
+ }));
65
+ }
66
+
67
+ /**
68
+ * Top N recalled files (all-time).
69
+ */
70
+ export function topRecalled(files, n = 5) {
71
+ return [...files]
72
+ .filter(f => f.recall_count > 0)
73
+ .sort((a, b) => b.recall_count - a.recall_count)
74
+ .slice(0, n)
75
+ .map(f => ({ path: f.path, relpath: f.relpath, title: f.title, count: f.recall_count }));
76
+ }
@@ -0,0 +1,79 @@
1
+ -- IJFW v1.3.0 -- D-Pillar / D0 memory FTS5 schema.
2
+ -- Source of truth: .planning/1.3.0/D-PILLAR-SPEC.md (D0)
3
+ --
4
+ -- Memory tier of the dual-tier index. Compute tier already ships its own
5
+ -- FTS5 schema in ../compute/schema.sql (V3-B4). This is the parallel
6
+ -- structure for the warm tier of the memory pipeline:
7
+ --
8
+ -- Hot -- markdown files on disk (existing readers/walkers, unchanged)
9
+ -- Warm -- this FTS5 index (rebuilt-on-demand from the hot tier)
10
+ -- Cold -- optional vector layer (deferred, future milestone)
11
+ --
12
+ -- Tokenizer: porter unicode61. Same as compute/schema.sql v2 -- morphological
13
+ -- variants ("authenticate"/"authenticating"/"running") collapse to a shared
14
+ -- stem so memory recall isn't brittle to verb form. Porter does NOT handle
15
+ -- irregular verbs ("ran" stays "ran"); documented limitation.
16
+ --
17
+ -- Schema is ADD-ONLY. user_version starts at 1 (this file = migration 001).
18
+
19
+ PRAGMA user_version = 1;
20
+
21
+ -- Content table: indexed memory entries.
22
+ -- id -- autoinc primary key
23
+ -- body -- the indexed text (full markdown body or excerpt)
24
+ -- source -- provenance pointer: file path, "session:N", "tier:auto",
25
+ -- etc. Nullable for free-form inserts.
26
+ -- session_id -- caller's session correlation id. Nullable; populated when
27
+ -- indexEntry is called from a session-aware path.
28
+ -- created_at -- unix ms; set by indexEntry when row is written.
29
+ CREATE TABLE IF NOT EXISTS memory_entries (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ body TEXT NOT NULL,
32
+ source TEXT,
33
+ session_id TEXT,
34
+ created_at INTEGER NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS memory_entries_source_idx
37
+ ON memory_entries(source) WHERE source IS NOT NULL;
38
+ CREATE INDEX IF NOT EXISTS memory_entries_session_idx
39
+ ON memory_entries(session_id) WHERE session_id IS NOT NULL;
40
+ CREATE INDEX IF NOT EXISTS memory_entries_created_idx
41
+ ON memory_entries(created_at);
42
+
43
+ -- FTS5 mirror over the body column. External-content table so the FTS5
44
+ -- index stays a thin view over memory_entries; updates flow through the
45
+ -- AI/AD/AU triggers below.
46
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_entries_fts USING fts5(
47
+ body,
48
+ content='memory_entries',
49
+ content_rowid='id',
50
+ tokenize='porter unicode61'
51
+ );
52
+
53
+ -- External-content sync triggers (FTS5 standard pattern).
54
+ CREATE TRIGGER IF NOT EXISTS memory_entries_ai
55
+ AFTER INSERT ON memory_entries BEGIN
56
+ INSERT INTO memory_entries_fts(rowid, body) VALUES (new.id, new.body);
57
+ END;
58
+ CREATE TRIGGER IF NOT EXISTS memory_entries_ad
59
+ AFTER DELETE ON memory_entries BEGIN
60
+ INSERT INTO memory_entries_fts(memory_entries_fts, rowid, body)
61
+ VALUES('delete', old.id, old.body);
62
+ END;
63
+ CREATE TRIGGER IF NOT EXISTS memory_entries_au
64
+ AFTER UPDATE ON memory_entries BEGIN
65
+ INSERT INTO memory_entries_fts(memory_entries_fts, rowid, body)
66
+ VALUES('delete', old.id, old.body);
67
+ INSERT INTO memory_entries_fts(rowid, body) VALUES (new.id, new.body);
68
+ END;
69
+
70
+ -- Schema version table -- mirrors compute/schema.sql shape so a future
71
+ -- merger of the two dbs (if ever attempted) lines up.
72
+ CREATE TABLE IF NOT EXISTS schema_meta (
73
+ version INTEGER PRIMARY KEY,
74
+ applied_at INTEGER NOT NULL,
75
+ description TEXT
76
+ );
77
+ INSERT OR IGNORE INTO schema_meta(version, applied_at, description)
78
+ VALUES (1, CAST(strftime('%s','now') AS INTEGER) * 1000,
79
+ 'memory v1.3.0 -- memory_entries + memory_entries_fts (porter unicode61)');