@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.
- package/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- 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)');
|