mover-os 4.7.6 → 4.7.8
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/install.js +763 -70
- package/package.json +15 -3
- package/src/dashboard/build.js +1503 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +177 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +462 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +211 -0
- package/src/dashboard/lib/date-utils.js +35 -0
- package/src/dashboard/lib/distribution-parser.js +78 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +83 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-health.js +170 -0
- package/src/dashboard/lib/engine-writer.js +1685 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +304 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +212 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +470 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +226 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/strategy-parser.js +245 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +331 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2024 -0
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// memory-index.js — Mover memory engine, Layer 1 (the index).
|
|
4
|
+
// Zero-dep, pure-JS inverted index + BM25-lite, SHARDED by month so "index everything"
|
|
5
|
+
// never becomes one >512MB string (Node's max-string wall the proof already hit).
|
|
6
|
+
//
|
|
7
|
+
// Layout under ~/.mover/memory/:
|
|
8
|
+
// manifest.json global stats: docFreq (for IDF), totalDocs, avgDocLen,
|
|
9
|
+
// indexedSessions (incremental skip), linkFreq (gardener/IDF demo)
|
|
10
|
+
// segments/curated.json curated docs (session-log arcs, Library, Engine, Auto_Learnings) — always loaded
|
|
11
|
+
// segments/raw-YYYY-MM.json raw session digests by month — lazy-loaded newest-first
|
|
12
|
+
//
|
|
13
|
+
// Scoring = BM25-lite × type-boost (curated-first) × trust. IDF buries ubiquitous terms;
|
|
14
|
+
// type-boost lifts the curated tier above raw transcripts.
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const { tokenize, termFreq, buildVocab } = require("./memory-text");
|
|
19
|
+
const { enumerateSessions, parseTranscript, deriveProject } = require("./transcript-parser");
|
|
20
|
+
const { parseSessionLogs } = require("./session-log-parser");
|
|
21
|
+
const { moverDir, safeRead, safeReadJson, engineDir } = require("./paths");
|
|
22
|
+
|
|
23
|
+
// MOVER_MEMORY_DIR override exists so tests can build a hermetic index in a temp dir.
|
|
24
|
+
const MEM_DIR = process.env.MOVER_MEMORY_DIR || path.join(moverDir(), "memory");
|
|
25
|
+
const SEG_DIR = path.join(MEM_DIR, "segments");
|
|
26
|
+
const MANIFEST = path.join(MEM_DIR, "manifest.json");
|
|
27
|
+
const VERSION = 1;
|
|
28
|
+
|
|
29
|
+
const K1 = 1.2, B = 0.75;
|
|
30
|
+
// Curated-first: the distilled tiers outrank raw transcripts at equal term match.
|
|
31
|
+
const TYPE_BOOST = { sessionlog: 1.5, autolearning: 1.4, library: 1.3, engine: 1.1, session: 1.0 };
|
|
32
|
+
// A doc TITLED about a query term is strongly relevant — lift it over body-only mentions so
|
|
33
|
+
// common topics (low IDF, e.g. "dashboard") still clear the recall gate when titled for them.
|
|
34
|
+
const TITLE_BOOST = 2.5;
|
|
35
|
+
|
|
36
|
+
const ENGINE_FILES = [
|
|
37
|
+
"Identity_Prime.md", "Strategy.md", "Active_Context.md", "Goals.md",
|
|
38
|
+
"Mover_Dossier.md", "Architect_Dossier.md", "Auto_Learnings.md",
|
|
39
|
+
"Metrics_Log.md", "Someday_Maybe.md",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// ── helpers ──
|
|
43
|
+
|
|
44
|
+
function ensureDirs() {
|
|
45
|
+
try { fs.mkdirSync(SEG_DIR, { recursive: true }); } catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function atomicWrite(file, obj) {
|
|
49
|
+
const tmp = file + ".tmp";
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
51
|
+
fs.renameSync(tmp, file);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function monthOf(ts, fallbackMs) {
|
|
55
|
+
let d = ts ? Date.parse(ts) : NaN;
|
|
56
|
+
if (!Number.isFinite(d)) d = fallbackMs || Date.now();
|
|
57
|
+
const dt = new Date(d);
|
|
58
|
+
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function newSeg() { return { docs: Object.create(null), postings: Object.create(null) }; }
|
|
62
|
+
|
|
63
|
+
// Add one doc {id,type,text,title,...} into a segment, updating global docFreq + length stats.
|
|
64
|
+
function addDoc(d, seg, df, lens) {
|
|
65
|
+
const toks = tokenize(d.text);
|
|
66
|
+
if (!toks.length) return;
|
|
67
|
+
const tf = termFreq(toks);
|
|
68
|
+
const len = toks.length;
|
|
69
|
+
lens.total += len; lens.count++;
|
|
70
|
+
const title = (d.title || "").slice(0, 160);
|
|
71
|
+
// Normalize ts to an ISO string — library/auto-learnings parsers pass mtime NUMBERS.
|
|
72
|
+
const ts = d.ts == null ? null : (typeof d.ts === "number" ? new Date(d.ts).toISOString() : String(d.ts));
|
|
73
|
+
// Strip a leading echo of the title from the snippet (session-log text starts with its title).
|
|
74
|
+
let body = d.snippet || d.text;
|
|
75
|
+
if (d.title && body.startsWith(d.title)) body = body.slice(d.title.length);
|
|
76
|
+
seg.docs[d.id] = {
|
|
77
|
+
id: d.id, type: d.type, title,
|
|
78
|
+
project: d.project || null, ts, len,
|
|
79
|
+
snippet: body.replace(/\s+/g, " ").trim().slice(0, 220),
|
|
80
|
+
meta: d.meta || {},
|
|
81
|
+
};
|
|
82
|
+
for (const term in tf) {
|
|
83
|
+
(seg.postings[term] || (seg.postings[term] = Object.create(null)))[d.id] = tf[term];
|
|
84
|
+
df[term] = (df[term] || 0) + 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Remove ONE doc's contribution from a segment + the global manifest stats (postings, docFreq,
|
|
89
|
+
// totalDocs/Len, linkFreq). Used on re-index AND on the cross-month cleanup (H1). Defensive: clamps
|
|
90
|
+
// docFreq/linkFreq at 0 so a previously-inconsistent index can't drive a term's df negative (which
|
|
91
|
+
// would invert its IDF — M2). Caller persists the mutated segment.
|
|
92
|
+
function removeDocContribution(seg, id, man) {
|
|
93
|
+
const old = seg.docs[id];
|
|
94
|
+
if (!old) return;
|
|
95
|
+
for (const term in seg.postings) {
|
|
96
|
+
if (seg.postings[term][id] != null) {
|
|
97
|
+
delete seg.postings[term][id];
|
|
98
|
+
man.docFreq[term] = Math.max(0, (man.docFreq[term] || 0) - 1);
|
|
99
|
+
if (man.docFreq[term] <= 0) delete man.docFreq[term];
|
|
100
|
+
if (!Object.keys(seg.postings[term]).length) delete seg.postings[term];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
man.totalDocs = Math.max(0, (man.totalDocs || 1) - 1);
|
|
104
|
+
man.totalLen = Math.max(0, (man.totalLen || old.len || 0) - (old.len || 0));
|
|
105
|
+
for (const note of new Set((old.meta && old.meta.candidateLinks) || [])) {
|
|
106
|
+
man.linkFreq[note] = Math.max(0, (man.linkFreq[note] || 0) - 1);
|
|
107
|
+
if (man.linkFreq[note] <= 0) delete man.linkFreq[note];
|
|
108
|
+
}
|
|
109
|
+
delete seg.docs[id];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── curated docs ──
|
|
113
|
+
|
|
114
|
+
function curatedDocs(vault) {
|
|
115
|
+
const docs = [];
|
|
116
|
+
|
|
117
|
+
// 1. Session-log arcs (highest signal — /log already distilled them).
|
|
118
|
+
try {
|
|
119
|
+
for (const a of parseSessionLogs(vault).docs) {
|
|
120
|
+
docs.push({ id: a.id, type: "sessionlog", title: a.title, project: a.project, ts: a.date, text: a.text,
|
|
121
|
+
meta: { date: a.date, time: a.time, commits: a.commits } });
|
|
122
|
+
}
|
|
123
|
+
} catch (e) { /* honest-null: missing dailies just means no arcs */ }
|
|
124
|
+
|
|
125
|
+
// 2. Library files (name + first paragraph + wikilinks).
|
|
126
|
+
try {
|
|
127
|
+
const { indexLibraryV2 } = require("./library-indexer-v2");
|
|
128
|
+
const lib = indexLibraryV2(vault);
|
|
129
|
+
const sections = (lib && lib.sections) || {};
|
|
130
|
+
for (const key of Object.keys(sections)) {
|
|
131
|
+
const sec = sections[key];
|
|
132
|
+
// Sections vary: most have items[]; inputs has byType{}; entities has people/orgs/places/decisions[].
|
|
133
|
+
let items = [];
|
|
134
|
+
if (Array.isArray(sec.items)) items = sec.items;
|
|
135
|
+
else if (sec.byType) items = [].concat(...Object.values(sec.byType));
|
|
136
|
+
else if (sec.people || sec.orgs || sec.places || sec.decisions)
|
|
137
|
+
items = [].concat(sec.people || [], sec.orgs || [], sec.places || [], sec.decisions || []);
|
|
138
|
+
for (const it of items) {
|
|
139
|
+
if (!it || !it.name) continue;
|
|
140
|
+
docs.push({ id: `lib:${it.path || it.name}`, type: "library", title: it.name, ts: it.mtime || null,
|
|
141
|
+
text: [it.name, it.firstParagraph || "", (it.wikilinks || []).join(" ")].join("\n"),
|
|
142
|
+
meta: { path: it.path, section: key } });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (e) { /* library indexer optional */ }
|
|
146
|
+
|
|
147
|
+
// 3. Engine files (whole-file; ubiquitous so low boost, but searchable for "what's my X").
|
|
148
|
+
for (const name of ENGINE_FILES) {
|
|
149
|
+
const p = path.join(engineDir(vault), name);
|
|
150
|
+
const raw = safeRead(p);
|
|
151
|
+
if (!raw) continue;
|
|
152
|
+
docs.push({ id: `engine:${name}`, type: "engine", title: name.replace(/\.md$/, ""), ts: null,
|
|
153
|
+
text: raw.slice(0, 40000), meta: { path: p } });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 4. Auto_Learnings entries (behavioral patterns w/ confidence).
|
|
157
|
+
try {
|
|
158
|
+
const { parseAutoLearnings } = require("./auto-learnings-parser");
|
|
159
|
+
const al = parseAutoLearnings(vault);
|
|
160
|
+
for (const e of (al.entries || [])) {
|
|
161
|
+
if (!e || !e.name) continue;
|
|
162
|
+
docs.push({ id: `al:${e.type || "x"}:${e.name}`, type: "autolearning", title: e.name, ts: e.lastUpdated || null,
|
|
163
|
+
text: [e.type, e.name, e.body || ""].join("\n"), meta: { confidence: e.confidence, status: e.status } });
|
|
164
|
+
}
|
|
165
|
+
} catch (e) { /* optional */ }
|
|
166
|
+
|
|
167
|
+
return docs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── full backfill ──
|
|
171
|
+
|
|
172
|
+
async function buildIndex(vault, opts = {}) {
|
|
173
|
+
ensureDirs();
|
|
174
|
+
const log = opts.log || (() => {});
|
|
175
|
+
const t0 = Date.now();
|
|
176
|
+
const vocab = buildVocab(vault);
|
|
177
|
+
log(`vocab: ${vocab.entries.length} note names`);
|
|
178
|
+
|
|
179
|
+
const df = Object.create(null);
|
|
180
|
+
const lens = { total: 0, count: 0 };
|
|
181
|
+
const linkFreq = Object.create(null);
|
|
182
|
+
const indexedSessions = Object.create(null);
|
|
183
|
+
const segs = Object.create(null); // segName -> seg
|
|
184
|
+
const getSeg = (name) => segs[name] || (segs[name] = newSeg());
|
|
185
|
+
|
|
186
|
+
// curated segment
|
|
187
|
+
const cur = getSeg("curated");
|
|
188
|
+
const cdocs = curatedDocs(vault);
|
|
189
|
+
for (const d of cdocs) addDoc(d, cur, df, lens);
|
|
190
|
+
log(`curated: ${cdocs.length} docs`);
|
|
191
|
+
|
|
192
|
+
// raw session segments (streamed, one session at a time → bounded memory)
|
|
193
|
+
const sessions = enumerateSessions(opts.projectsDir);
|
|
194
|
+
log(`raw: ${sessions.length} unique sessions to index`);
|
|
195
|
+
let n = 0;
|
|
196
|
+
for (const s of sessions) {
|
|
197
|
+
const r = await parseTranscript(s.file, { vocab, sessionId: s.sessionId, project: s.project, agent: s.agent });
|
|
198
|
+
n++;
|
|
199
|
+
if (r.error) { log(` [${n}/${sessions.length}] SKIP (read error) ${s.sessionId}`); continue; }
|
|
200
|
+
const month = monthOf(r.firstTs, s.mtime);
|
|
201
|
+
const seg = getSeg("raw-" + month);
|
|
202
|
+
const title = `Session · ${s.project} · ${(r.firstTs || "").slice(0, 10) || month}`;
|
|
203
|
+
addDoc({
|
|
204
|
+
id: `session:${s.sessionId}`, type: "session", title, project: s.project, ts: r.firstTs,
|
|
205
|
+
text: r.sampleText,
|
|
206
|
+
snippet: r.snippet || r.decisions[0] || r.sampleText,
|
|
207
|
+
meta: {
|
|
208
|
+
sessionId: s.sessionId, file: s.file, month, agent: s.agent,
|
|
209
|
+
turns: r.turns, durationMin: r.durationMin, sizeMB: +(s.size / 1e6).toFixed(1),
|
|
210
|
+
commits: r.artifacts.commits, commands: r.artifacts.commands, errors: r.artifacts.errors,
|
|
211
|
+
topTools: Object.keys(r.artifacts.tools).slice(0, 6), candidateLinks: r.candidateLinks,
|
|
212
|
+
lastCommand: (r.meta && r.meta.lastCommand) || null, // C1: workflow active at the last error (GEPA attribution)
|
|
213
|
+
},
|
|
214
|
+
}, seg, df, lens);
|
|
215
|
+
indexedSessions[s.sessionId] = { size: s.size, mtime: s.mtime, month };
|
|
216
|
+
// linkFreq: how many sessions mention each note (drives link-IDF / the noise fix).
|
|
217
|
+
for (const note of new Set(r.candidateLinks)) linkFreq[note] = (linkFreq[note] || 0) + 1;
|
|
218
|
+
if (n % 25 === 0) log(` [${n}/${sessions.length}] indexed (${Math.round(process.memoryUsage().rss / 1e6)}MB rss)`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// write segments
|
|
222
|
+
let bytes = 0;
|
|
223
|
+
for (const name of Object.keys(segs)) {
|
|
224
|
+
const file = path.join(SEG_DIR, name + ".json");
|
|
225
|
+
atomicWrite(file, segs[name]);
|
|
226
|
+
bytes += fs.statSync(file).size;
|
|
227
|
+
}
|
|
228
|
+
// Proactive-recall triggers: entities/projects worth surfacing when merely MENTIONED
|
|
229
|
+
// (signal only — drop ubiquitous notes and sub-4-char names). Plain newline text so the
|
|
230
|
+
// bash pre-filter can `grep -wFf` it in ~ms.
|
|
231
|
+
try {
|
|
232
|
+
const N = sessions.length || 1;
|
|
233
|
+
const trig = Object.keys(linkFreq)
|
|
234
|
+
.filter((n) => n.length >= 4 && linkFreq[n] / N <= 0.5)
|
|
235
|
+
.sort((a, b) => linkFreq[a] - linkFreq[b]);
|
|
236
|
+
fs.writeFileSync(path.join(MEM_DIR, "recall-triggers.txt"), trig.join("\n") + (trig.length ? "\n" : ""));
|
|
237
|
+
} catch {}
|
|
238
|
+
const manifest = {
|
|
239
|
+
version: VERSION, builtAt: new Date(t0).toISOString(), vault,
|
|
240
|
+
totalDocs: lens.count, totalLen: lens.total, avgDocLen: lens.count ? lens.total / lens.count : 0,
|
|
241
|
+
docFreq: df, linkFreq, indexedSessions,
|
|
242
|
+
segments: Object.keys(segs), trust: {},
|
|
243
|
+
};
|
|
244
|
+
atomicWrite(MANIFEST, manifest);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
ms: Date.now() - t0, totalDocs: lens.count, segments: Object.keys(segs).length,
|
|
248
|
+
indexBytes: bytes, curatedDocs: cdocs.length, sessions: sessions.length,
|
|
249
|
+
uniqueTerms: Object.keys(df).length, uniqueLinks: Object.keys(linkFreq).length,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── query ──
|
|
254
|
+
|
|
255
|
+
function loadManifest() { return safeReadJson(MANIFEST); }
|
|
256
|
+
function loadSeg(name) { return safeReadJson(path.join(SEG_DIR, name + ".json")); }
|
|
257
|
+
|
|
258
|
+
function idf(df, N, term) {
|
|
259
|
+
const n = df[term] || 0;
|
|
260
|
+
return Math.log(1 + (N - n + 0.5) / (n + 0.5));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Scope guard (precision-first PUSH): a project-tagged doc is in-scope only for its own project,
|
|
264
|
+
// so another project's session never bleeds into this one. Vault-global curated docs
|
|
265
|
+
// (engine/library/autolearning, project=null) are always in scope. Lenient on sub-path drift so a
|
|
266
|
+
// session started in a project subdir still matches its root. Opt-in via opts.project — the deep
|
|
267
|
+
// pull skill passes none and searches everything.
|
|
268
|
+
// Normalize away tag punctuation ("[Mover OS Bundle", "Mover OS Bundle + Belgium") so a legit
|
|
269
|
+
// current-project arc isn't filtered over a stray bracket — while a real other project still won't match.
|
|
270
|
+
function normProject(p) { return String(p || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); }
|
|
271
|
+
function inScope(docProject, currentProject) {
|
|
272
|
+
if (!docProject) return true; // vault-global curated → always in scope
|
|
273
|
+
if (!currentProject) return true; // unknown current project → don't filter (fail-open)
|
|
274
|
+
const d = normProject(docProject), c = normProject(currentProject);
|
|
275
|
+
// One-directional, word-boundary match (M1): in scope iff the doc IS the current project, or a
|
|
276
|
+
// sub-scope of it ("Mover OS Bundle + Belgium" → starts with "mover os bundle "). NOT the reverse:
|
|
277
|
+
// a shorter prefix-named OTHER project ("Mover") must never bleed into "Mover OS Bundle", and the
|
|
278
|
+
// trailing space stops a same-prefix sibling ("Mover OS Bundle2") from matching either.
|
|
279
|
+
return d === c || d.startsWith(c + " ");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function query(qStr, opts = {}) {
|
|
283
|
+
const man = loadManifest();
|
|
284
|
+
if (!man) return { available: false, hits: [] };
|
|
285
|
+
const qTerms = [...new Set(tokenize(qStr))];
|
|
286
|
+
if (!qTerms.length) return { available: true, hits: [], terms: [] };
|
|
287
|
+
|
|
288
|
+
const N = man.totalDocs || 1;
|
|
289
|
+
const avgdl = man.avgDocLen || 1;
|
|
290
|
+
const maxSegments = opts.maxSegments || 12;
|
|
291
|
+
const trust = man.trust || {};
|
|
292
|
+
|
|
293
|
+
// Always load curated; then raw segments newest-first up to the budget.
|
|
294
|
+
const rawSegs = (man.segments || []).filter((s) => s.startsWith("raw-")).sort().reverse().slice(0, maxSegments);
|
|
295
|
+
const segNames = ["curated", ...rawSegs].filter((s) => (man.segments || []).includes(s));
|
|
296
|
+
|
|
297
|
+
const scores = Object.create(null);
|
|
298
|
+
const docRef = Object.create(null);
|
|
299
|
+
for (const name of segNames) {
|
|
300
|
+
const seg = loadSeg(name);
|
|
301
|
+
if (!seg) continue;
|
|
302
|
+
for (const term of qTerms) {
|
|
303
|
+
const post = seg.postings[term];
|
|
304
|
+
if (!post) continue;
|
|
305
|
+
const w = idf(man.docFreq, N, term);
|
|
306
|
+
for (const id in post) {
|
|
307
|
+
const d = seg.docs[id];
|
|
308
|
+
if (!d) continue;
|
|
309
|
+
const tf = post[id];
|
|
310
|
+
const denom = tf + K1 * (1 - B + B * (d.len / avgdl));
|
|
311
|
+
const s = w * (tf * (K1 + 1)) / denom;
|
|
312
|
+
scores[id] = (scores[id] || 0) + s;
|
|
313
|
+
docRef[id] = d;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const hits = Object.keys(scores).map((id) => {
|
|
319
|
+
const d = docRef[id];
|
|
320
|
+
const boost = TYPE_BOOST[d.type] || 1.0;
|
|
321
|
+
const tr = trust[id] != null ? trust[id] : 1.0;
|
|
322
|
+
const titleLc = (d.title || "").toLowerCase();
|
|
323
|
+
const tBoost = qTerms.some((t) => titleLc.includes(t)) ? TITLE_BOOST : 1.0;
|
|
324
|
+
return { id, type: d.type, title: d.title, project: d.project, ts: d.ts,
|
|
325
|
+
score: +(scores[id] * boost * tr * tBoost).toFixed(3), snippet: d.snippet, meta: d.meta };
|
|
326
|
+
});
|
|
327
|
+
hits.sort((a, b) => b.score - a.score);
|
|
328
|
+
// Cross-project scope guard — only when a current project is supplied (push path).
|
|
329
|
+
const scoped = opts.project ? hits.filter((h) => inScope(h.project, opts.project)) : hits;
|
|
330
|
+
return { available: true, terms: qTerms, total: scoped.length, hits: scoped.slice(0, opts.limit || 10) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── link-IDF report (the "watch the noise disappear" proof) ──
|
|
334
|
+
|
|
335
|
+
function linkReport(sessionId, opts = {}) {
|
|
336
|
+
const man = loadManifest();
|
|
337
|
+
if (!man) return null;
|
|
338
|
+
const N = Object.keys(man.indexedSessions || {}).length || 1;
|
|
339
|
+
// find the session doc (search its month segment, else scan raw segs)
|
|
340
|
+
let doc = null;
|
|
341
|
+
for (const name of (man.segments || []).filter((s) => s.startsWith("raw-")).sort().reverse()) {
|
|
342
|
+
const seg = loadSeg(name);
|
|
343
|
+
if (seg && seg.docs["session:" + sessionId]) { doc = seg.docs["session:" + sessionId]; break; }
|
|
344
|
+
}
|
|
345
|
+
if (!doc) return null;
|
|
346
|
+
const links = (doc.meta.candidateLinks || []).map((note) => {
|
|
347
|
+
const freq = man.linkFreq[note] || 1;
|
|
348
|
+
const lidf = Math.log(N / freq);
|
|
349
|
+
return { note, sessions: freq, idf: +lidf.toFixed(2), score: +lidf.toFixed(2) };
|
|
350
|
+
});
|
|
351
|
+
const rawOrder = [...links].sort((a, b) => b.sessions - a.sessions); // "naive" = most-mentioned first
|
|
352
|
+
const idfOrder = [...links].sort((a, b) => b.idf - a.idf); // IDF-weighted = rarest first
|
|
353
|
+
return { sessionId, total: links.length, N, naive: rawOrder.slice(0, 12), idf: idfOrder.slice(0, 12) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function stats() {
|
|
357
|
+
const man = loadManifest();
|
|
358
|
+
if (!man) return { available: false };
|
|
359
|
+
let bytes = 0;
|
|
360
|
+
for (const s of (man.segments || [])) {
|
|
361
|
+
try { bytes += fs.statSync(path.join(SEG_DIR, s + ".json")).size; } catch {}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
available: true, builtAt: man.builtAt, totalDocs: man.totalDocs,
|
|
365
|
+
avgDocLen: +man.avgDocLen.toFixed(1), uniqueTerms: Object.keys(man.docFreq || {}).length,
|
|
366
|
+
sessions: Object.keys(man.indexedSessions || {}).length, segments: man.segments,
|
|
367
|
+
uniqueLinks: Object.keys(man.linkFreq || {}).length, indexMB: +(bytes / 1e6).toFixed(1),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── incremental: index ONE session (the session-end path; no full rebuild) ──
|
|
372
|
+
// Idempotent: skips a session whose mtime is unchanged. Re-indexes (remove→add) if the
|
|
373
|
+
// file grew. Updates global docFreq/linkFreq/totals in the manifest directly so IDF stays
|
|
374
|
+
// correct without re-reading the whole corpus.
|
|
375
|
+
async function indexSession(file, opts = {}) {
|
|
376
|
+
const man = loadManifest();
|
|
377
|
+
if (!man) return { error: "no index — run --backfill first" };
|
|
378
|
+
man.docFreq = man.docFreq || {}; man.linkFreq = man.linkFreq || {}; man.indexedSessions = man.indexedSessions || {};
|
|
379
|
+
const vault = opts.vault || man.vault;
|
|
380
|
+
const sessionId = opts.sessionId || path.basename(file).replace(/\.jsonl$/, "");
|
|
381
|
+
// Derive the project from the transcript's parent dir (…/projects/<encoded-dir>/<uuid>.jsonl)
|
|
382
|
+
// when not passed — the SessionEnd hook calls indexSession(file, {}), and without this every
|
|
383
|
+
// live-indexed session would be project=null, making the cross-project scope guard a no-op.
|
|
384
|
+
const project = opts.project || deriveProject(path.basename(path.dirname(file))) || null;
|
|
385
|
+
let mtime = null, size = 0;
|
|
386
|
+
try { const st = fs.statSync(file); mtime = st.mtimeMs; size = st.size; } catch {}
|
|
387
|
+
const prev = man.indexedSessions[sessionId];
|
|
388
|
+
if (prev && mtime && prev.mtime === mtime && !opts.force) return { skipped: true, sessionId };
|
|
389
|
+
|
|
390
|
+
const r = await parseTranscript(file, { vocab: buildVocab(vault), sessionId, project });
|
|
391
|
+
if (r.error) return { error: "parse failed", sessionId };
|
|
392
|
+
|
|
393
|
+
const month = monthOf(r.firstTs, mtime);
|
|
394
|
+
const id = "session:" + sessionId;
|
|
395
|
+
|
|
396
|
+
// H1: a session's month can change between indexings (e.g. first indexed off mtime while its
|
|
397
|
+
// turns are still timestamp-less, then a real earlier/later timestamp appears). The old copy then
|
|
398
|
+
// lives in the PREVIOUSLY recorded month's segment — remove it from THERE too, else it orphans
|
|
399
|
+
// (ghost doc) and permanently inflates docFreq/totalDocs/linkFreq, degrading IDF until a full
|
|
400
|
+
// --backfill. Only fires on a genuine month change; the common same-month re-index skips it.
|
|
401
|
+
let reindexed = false;
|
|
402
|
+
const prevMonth = prev && prev.month;
|
|
403
|
+
if (prevMonth && prevMonth !== month) {
|
|
404
|
+
const prevSegFile = path.join(SEG_DIR, "raw-" + prevMonth + ".json");
|
|
405
|
+
const prevSeg = safeReadJson(prevSegFile);
|
|
406
|
+
if (prevSeg && prevSeg.docs[id]) {
|
|
407
|
+
removeDocContribution(prevSeg, id, man);
|
|
408
|
+
atomicWrite(prevSegFile, prevSeg);
|
|
409
|
+
reindexed = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const segFile = path.join(SEG_DIR, "raw-" + month + ".json");
|
|
414
|
+
const seg = safeReadJson(segFile) || newSeg();
|
|
415
|
+
|
|
416
|
+
// remove old contribution (same-month re-index case) — scan only this segment's postings.
|
|
417
|
+
if (seg.docs[id]) { removeDocContribution(seg, id, man); reindexed = true; }
|
|
418
|
+
|
|
419
|
+
// add the fresh doc + fold its terms into the global stats.
|
|
420
|
+
const tf = termFreq(tokenize(r.sampleText));
|
|
421
|
+
const len = Object.values(tf).reduce((a, b) => a + b, 0);
|
|
422
|
+
seg.docs[id] = {
|
|
423
|
+
id, type: "session", title: `Session · ${r.project || project || "?"} · ${(r.firstTs || "").slice(0, 10) || month}`,
|
|
424
|
+
project: r.project || project || null, ts: r.firstTs, len,
|
|
425
|
+
snippet: r.snippet || (r.decisions[0] || r.sampleText).slice(0, 220).replace(/\s+/g, " "),
|
|
426
|
+
meta: { sessionId, file, month, turns: r.turns, durationMin: r.durationMin,
|
|
427
|
+
commits: r.artifacts.commits, commands: r.artifacts.commands, errors: r.artifacts.errors,
|
|
428
|
+
topTools: Object.keys(r.artifacts.tools).slice(0, 6), candidateLinks: r.candidateLinks,
|
|
429
|
+
lastCommand: (r.meta && r.meta.lastCommand) || null }, // C1: workflow active at the last error (GEPA attribution)
|
|
430
|
+
};
|
|
431
|
+
for (const term in tf) {
|
|
432
|
+
(seg.postings[term] || (seg.postings[term] = Object.create(null)))[id] = tf[term];
|
|
433
|
+
man.docFreq[term] = (man.docFreq[term] || 0) + 1;
|
|
434
|
+
}
|
|
435
|
+
man.totalDocs = (man.totalDocs || 0) + 1;
|
|
436
|
+
man.totalLen = (man.totalLen || 0) + len;
|
|
437
|
+
man.avgDocLen = man.totalDocs ? man.totalLen / man.totalDocs : 0;
|
|
438
|
+
for (const note of new Set(r.candidateLinks)) man.linkFreq[note] = (man.linkFreq[note] || 0) + 1;
|
|
439
|
+
man.indexedSessions[sessionId] = { size, mtime, month };
|
|
440
|
+
if (!man.segments.includes("raw-" + month)) man.segments.push("raw-" + month);
|
|
441
|
+
|
|
442
|
+
ensureDirs();
|
|
443
|
+
atomicWrite(segFile, seg);
|
|
444
|
+
atomicWrite(MANIFEST, man);
|
|
445
|
+
return { ok: true, sessionId, month, len, totalDocs: man.totalDocs, reindexed };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
module.exports = { buildIndex, indexSession, query, linkReport, stats, inScope, MEM_DIR };
|
|
449
|
+
|
|
450
|
+
// ── CLI ──
|
|
451
|
+
if (require.main === module) {
|
|
452
|
+
(async () => {
|
|
453
|
+
const cmd = process.argv[2];
|
|
454
|
+
const vault = require("./paths").resolveVault();
|
|
455
|
+
if (cmd === "--backfill") {
|
|
456
|
+
const r = await buildIndex(vault, { log: (m) => console.log(m) });
|
|
457
|
+
console.log("\nDONE:", JSON.stringify(r, null, 2));
|
|
458
|
+
} else if (cmd === "--query") {
|
|
459
|
+
console.log(JSON.stringify(query(process.argv.slice(3).join(" "), { limit: 8 }), null, 2));
|
|
460
|
+
} else if (cmd === "--links") {
|
|
461
|
+
console.log(JSON.stringify(linkReport(process.argv[3]), null, 2));
|
|
462
|
+
} else if (cmd === "--index") {
|
|
463
|
+
console.log(JSON.stringify(await indexSession(process.argv[3], { force: process.argv.includes("--force") }), null, 2));
|
|
464
|
+
} else if (cmd === "--stats") {
|
|
465
|
+
console.log(JSON.stringify(stats(), null, 2));
|
|
466
|
+
} else {
|
|
467
|
+
console.log('usage: node memory-index.js --backfill | --query "..." | --links <sessionId> | --stats');
|
|
468
|
+
}
|
|
469
|
+
})();
|
|
470
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// memory-rerank.js — Mover memory engine, Layer 7 (optional, local semantic rerank).
|
|
4
|
+
//
|
|
5
|
+
// BM25-lite already retrieves good candidates. IF a local Ollama is running, this reranks the
|
|
6
|
+
// top candidates by embedding cosine — catching paraphrase matches keyword search misses.
|
|
7
|
+
// Strictly local (nomic-embed-text on localhost), $0, and GRACEFULLY DEGRADES to keyword
|
|
8
|
+
// when Ollama is absent. It is NEVER an embedding API — nothing leaves the machine. Uses Node's
|
|
9
|
+
// built-in fetch (zero deps).
|
|
10
|
+
|
|
11
|
+
const DEFAULT_HOST = process.env.OLLAMA_HOST || "http://127.0.0.1:11434";
|
|
12
|
+
const MODEL = process.env.MOVER_EMBED_MODEL || "nomic-embed-text";
|
|
13
|
+
|
|
14
|
+
function cosine(a, b) {
|
|
15
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
16
|
+
let dot = 0, na = 0, nb = 0;
|
|
17
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
18
|
+
if (!na || !nb) return 0;
|
|
19
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Embed one string via Ollama. Returns the vector, or null on any failure (absent/slow/error).
|
|
23
|
+
async function embed(text, opts = {}) {
|
|
24
|
+
const host = opts.host || DEFAULT_HOST;
|
|
25
|
+
const model = opts.model || MODEL;
|
|
26
|
+
const timeoutMs = opts.timeoutMs || 1500;
|
|
27
|
+
const ctrl = new AbortController();
|
|
28
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(host + "/api/embeddings", {
|
|
31
|
+
method: "POST", signal: ctrl.signal,
|
|
32
|
+
headers: { "content-type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ model, prompt: String(text || "").slice(0, 4000) }),
|
|
34
|
+
});
|
|
35
|
+
clearTimeout(t);
|
|
36
|
+
if (!res.ok) return null;
|
|
37
|
+
const j = await res.json();
|
|
38
|
+
return Array.isArray(j.embedding) ? j.embedding : null;
|
|
39
|
+
} catch { clearTimeout(t); return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Rerank BM25 hits by semantic similarity IF Ollama is up; otherwise return them untouched.
|
|
43
|
+
// Returns { hits, method: 'semantic' | 'keyword' }.
|
|
44
|
+
async function rerank(query, hits, opts = {}) {
|
|
45
|
+
if (!hits || !hits.length) return { hits: hits || [], method: "keyword" };
|
|
46
|
+
const topK = opts.topK || 20;
|
|
47
|
+
const cand = hits.slice(0, topK);
|
|
48
|
+
|
|
49
|
+
const qv = await embed(query, opts);
|
|
50
|
+
if (!qv) return { hits, method: "keyword" }; // graceful degrade — the common case
|
|
51
|
+
|
|
52
|
+
const scored = [];
|
|
53
|
+
for (const h of cand) {
|
|
54
|
+
const hv = await embed((h.title ? h.title + " " : "") + (h.snippet || ""), opts);
|
|
55
|
+
scored.push({ h, sim: hv ? cosine(qv, hv) : 0 });
|
|
56
|
+
}
|
|
57
|
+
scored.sort((a, b) => b.sim - a.sim);
|
|
58
|
+
const reranked = scored.map((s) => ({ ...s.h, semantic: +s.sim.toFixed(3) })).concat(hits.slice(topK));
|
|
59
|
+
return { hits: reranked, method: "semantic" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// C5 — the opt-in gate. Rerank is OFF by default: the caller passes `enabled` (the pull skill
|
|
63
|
+
// sets it from MOVER_OLLAMA). When disabled this is a pure keyword passthrough that NEVER opens
|
|
64
|
+
// a socket — the default path stays zero-network, zero-latency. When enabled, rerank() still
|
|
65
|
+
// gracefully degrades to keyword if Ollama happens to be down. The push hook deliberately does
|
|
66
|
+
// NOT use this (its 1.2s budget can't afford ~21 sequential embeds); it's pull-skill only.
|
|
67
|
+
async function maybeRerank(query, hits, opts = {}) {
|
|
68
|
+
if (!opts.enabled) return { hits: hits || [], method: "keyword" };
|
|
69
|
+
return rerank(query, hits, opts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { cosine, embed, rerank, maybeRerank, DEFAULT_HOST, MODEL };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// memory-text.js — shared text utilities for the Mover memory engine.
|
|
4
|
+
// Zero-dep (Node stdlib only). Two distinct tokenizers on purpose:
|
|
5
|
+
// - tokenize(): SEARCH terms — stopworded, len>=3. Feeds the BM25 index + queries.
|
|
6
|
+
// - words(): LINK matching — raw lowercased word sequence, NO stopword removal,
|
|
7
|
+
// so multi-word note names ("Launch Plan", "The Standing") survive as n-grams.
|
|
8
|
+
// Mixing the two would drop stopword-containing note names and pollute the index with "the".
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
// Compact English + Mover-chatter stopword set. Kept inline to stay zero-dep.
|
|
14
|
+
const STOPWORDS = new Set((
|
|
15
|
+
"a an and are as at be been being but by for from had has have he her hers him his i if in into is it its " +
|
|
16
|
+
"me my no nor not of off on once only or our ours out over own she so some such than that the their theirs them " +
|
|
17
|
+
"then there these they this those to too under until up very was we were what when where which while who whom why " +
|
|
18
|
+
"will with you your yours about above after again against all am any because before below between both did do does " +
|
|
19
|
+
"doing down during each few more most other same should through too will just dont cant wont youre ive im also get " +
|
|
20
|
+
"got like one two way thing things stuff lot really actually maybe okay yeah yep nope going want need make made " +
|
|
21
|
+
"now new use used using via per etc ok let lets going gonna wanna"
|
|
22
|
+
).split(/\s+/).filter(Boolean));
|
|
23
|
+
|
|
24
|
+
// Generic note-name tokens that are too ambiguous to be a useful wikilink target.
|
|
25
|
+
const VOCAB_STOP = new Set([
|
|
26
|
+
"index", "readme", "schema", "template", "_template", "misc", "notes", "untitled",
|
|
27
|
+
"home", "moc", "map", "tools", "people", "places", "scripts", "inbox", "archive",
|
|
28
|
+
"daily", "weekly", "monthly", "project", "projects", "library", "engine"
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// SEARCH tokens: lowercase, alphanumeric cores (keep internal . - _ for things like
|
|
32
|
+
// "v4.5.1", "engine-writer"), drop stopwords + sub-3-char noise + over-long blobs.
|
|
33
|
+
function tokenize(text) {
|
|
34
|
+
if (!text) return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
const re = /[a-z0-9][a-z0-9._-]{1,40}/gi;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = re.exec(text)) !== null) {
|
|
39
|
+
let t = m[0].toLowerCase().replace(/^[._-]+|[._-]+$/g, "");
|
|
40
|
+
if (t.length < 3 || t.length > 40) continue;
|
|
41
|
+
if (STOPWORDS.has(t)) continue;
|
|
42
|
+
out.push(t);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// LINK words: lowercase word sequence, light cleanup, NO stopword removal.
|
|
48
|
+
function words(text) {
|
|
49
|
+
if (!text) return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
const re = /[a-z0-9][a-z0-9'._-]*/gi;
|
|
52
|
+
let m;
|
|
53
|
+
while ((m = re.exec(text)) !== null) {
|
|
54
|
+
const w = m[0].toLowerCase().replace(/^['._-]+|['._-]+$/g, "");
|
|
55
|
+
if (w) out.push(w);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build uni/bi/tri-gram membership sets from a (capped) text blob, for O(vocab) link matching.
|
|
61
|
+
function ngramSets(text) {
|
|
62
|
+
const w = words(text);
|
|
63
|
+
const uni = new Set();
|
|
64
|
+
const bi = new Set();
|
|
65
|
+
const tri = new Set();
|
|
66
|
+
for (let i = 0; i < w.length; i++) {
|
|
67
|
+
uni.add(w[i]);
|
|
68
|
+
if (i + 1 < w.length) bi.add(w[i] + " " + w[i + 1]);
|
|
69
|
+
if (i + 2 < w.length) tri.add(w[i] + " " + w[i + 1] + " " + w[i + 2]);
|
|
70
|
+
}
|
|
71
|
+
return { 1: uni, 2: bi, 3: tri };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// term -> frequency map from search tokens.
|
|
75
|
+
function termFreq(tokens) {
|
|
76
|
+
const tf = Object.create(null);
|
|
77
|
+
for (const t of tokens) tf[t] = (tf[t] || 0) + 1;
|
|
78
|
+
return tf;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Recursively collect .md basenames (no extension) under a dir.
|
|
82
|
+
function listMd(dir, acc, depth) {
|
|
83
|
+
if (depth < 0) return acc;
|
|
84
|
+
let ents;
|
|
85
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
86
|
+
for (const e of ents) {
|
|
87
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
88
|
+
const p = path.join(dir, e.name);
|
|
89
|
+
if (e.isDirectory()) listMd(p, acc, depth - 1);
|
|
90
|
+
else if (e.name.endsWith(".md")) acc.push(e.name.replace(/\.md$/, ""));
|
|
91
|
+
}
|
|
92
|
+
return acc;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Vocab = the set of existing vault notes the gardener/recall can link into.
|
|
96
|
+
// Sourced from 01_Projects folder names + every .md basename under 03_Library + 02_Areas/Engine.
|
|
97
|
+
// Returns { entries:[{canonical,lower,nWords}], byN:{1:Map,2:Map,3:Map} }.
|
|
98
|
+
function buildVocab(vault) {
|
|
99
|
+
const raw = new Set();
|
|
100
|
+
try {
|
|
101
|
+
for (const e of fs.readdirSync(path.join(vault, "01_Projects"), { withFileTypes: true })) {
|
|
102
|
+
if (e.isDirectory()) raw.add(e.name);
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
listMd(path.join(vault, "03_Library"), [], 4).forEach((n) => raw.add(n));
|
|
106
|
+
listMd(path.join(vault, "02_Areas", "Engine"), [], 3).forEach((n) => raw.add(n));
|
|
107
|
+
|
|
108
|
+
const entries = [];
|
|
109
|
+
const byN = { 1: new Map(), 2: new Map(), 3: new Map() };
|
|
110
|
+
for (const name of raw) {
|
|
111
|
+
const w = words(name);
|
|
112
|
+
const nWords = w.length;
|
|
113
|
+
if (nWords < 1 || nWords > 3) continue;
|
|
114
|
+
const lower = w.join(" ");
|
|
115
|
+
// Single-word notes must be >=4 chars and not a generic term, or they match everything.
|
|
116
|
+
if (nWords === 1 && (lower.length < 4 || VOCAB_STOP.has(lower))) continue;
|
|
117
|
+
// Skip date-stamped dailies/weeklies AND numbered/ordinal note names ("7 Workflow",
|
|
118
|
+
// "4. Outbound") — a leading digit is never a useful wikilink target, only index noise.
|
|
119
|
+
if (/^daily|^weekly|^\d/.test(lower)) continue;
|
|
120
|
+
if (byN[nWords].has(lower)) continue;
|
|
121
|
+
byN[nWords].set(lower, name);
|
|
122
|
+
entries.push({ canonical: name, lower, nWords });
|
|
123
|
+
}
|
|
124
|
+
return { entries, byN };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Given precomputed ngramSets for a session and a vocab, return matched canonical note names.
|
|
128
|
+
function matchLinks(sets, vocab) {
|
|
129
|
+
const hits = [];
|
|
130
|
+
for (const e of vocab.entries) {
|
|
131
|
+
if (sets[e.nWords] && sets[e.nWords].has(e.lower)) hits.push(e.canonical);
|
|
132
|
+
}
|
|
133
|
+
return hits;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { tokenize, words, ngramSets, termFreq, buildVocab, matchLinks, STOPWORDS, VOCAB_STOP };
|