mover-os 4.7.7 → 4.7.9

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 (119) hide show
  1. package/README.md +34 -24
  2. package/install.js +2868 -251
  3. package/package.json +15 -3
  4. package/src/dashboard/build.js +1541 -0
  5. package/src/dashboard/dashboard.js +276 -0
  6. package/src/dashboard/index.js +319 -0
  7. package/src/dashboard/lib/activation-log.js +297 -0
  8. package/src/dashboard/lib/active-context-parser.js +189 -0
  9. package/src/dashboard/lib/agent-command.js +93 -0
  10. package/src/dashboard/lib/agent-detect.js +255 -0
  11. package/src/dashboard/lib/agent-detector.js +92 -0
  12. package/src/dashboard/lib/agent-session.js +483 -0
  13. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  14. package/src/dashboard/lib/approval-registry.js +170 -0
  15. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  16. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  17. package/src/dashboard/lib/config-parser.js +109 -0
  18. package/src/dashboard/lib/connect-recommender.js +131 -0
  19. package/src/dashboard/lib/correlations-parser.js +231 -0
  20. package/src/dashboard/lib/daily-note-resolver.js +228 -0
  21. package/src/dashboard/lib/date-utils.js +43 -0
  22. package/src/dashboard/lib/distribution-parser.js +137 -0
  23. package/src/dashboard/lib/dossier-parser.js +64 -0
  24. package/src/dashboard/lib/drift-history.js +88 -0
  25. package/src/dashboard/lib/drift-score.js +119 -0
  26. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  27. package/src/dashboard/lib/engine-health.js +173 -0
  28. package/src/dashboard/lib/engine-writer.js +1831 -0
  29. package/src/dashboard/lib/execution-plan.js +125 -0
  30. package/src/dashboard/lib/experiments-parser.js +429 -0
  31. package/src/dashboard/lib/feed-parser.js +294 -0
  32. package/src/dashboard/lib/forked-future.js +60 -0
  33. package/src/dashboard/lib/goal-forecast.js +427 -0
  34. package/src/dashboard/lib/goals-parser.js +67 -0
  35. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  36. package/src/dashboard/lib/hook-activity.js +48 -0
  37. package/src/dashboard/lib/hook-indexer.js +169 -0
  38. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  39. package/src/dashboard/lib/identity-parser.js +85 -0
  40. package/src/dashboard/lib/ingestion.js +418 -0
  41. package/src/dashboard/lib/library-indexer-v2.js +226 -0
  42. package/src/dashboard/lib/library-indexer.js +105 -0
  43. package/src/dashboard/lib/library-search.js +290 -0
  44. package/src/dashboard/lib/log-activation.sh +61 -0
  45. package/src/dashboard/lib/memory-curator.js +97 -0
  46. package/src/dashboard/lib/memory-gardener.js +177 -0
  47. package/src/dashboard/lib/memory-gepa.js +102 -0
  48. package/src/dashboard/lib/memory-index.js +519 -0
  49. package/src/dashboard/lib/memory-rerank.js +72 -0
  50. package/src/dashboard/lib/memory-text.js +136 -0
  51. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  52. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  53. package/src/dashboard/lib/onboarding-forge.js +70 -0
  54. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  55. package/src/dashboard/lib/override-summary.js +73 -0
  56. package/src/dashboard/lib/paths.js +192 -0
  57. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  58. package/src/dashboard/lib/phantom-strategy.js +129 -0
  59. package/src/dashboard/lib/pid-markers.js +80 -0
  60. package/src/dashboard/lib/project-scanner.js +121 -0
  61. package/src/dashboard/lib/promise-wall.js +88 -0
  62. package/src/dashboard/lib/record-score.js +173 -0
  63. package/src/dashboard/lib/redaction.js +140 -0
  64. package/src/dashboard/lib/refusal-parser.js +44 -0
  65. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  66. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  67. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  68. package/src/dashboard/lib/run-registry.js +286 -0
  69. package/src/dashboard/lib/safe-write.js +63 -0
  70. package/src/dashboard/lib/session-log-parser.js +145 -0
  71. package/src/dashboard/lib/session-time-parser.js +158 -0
  72. package/src/dashboard/lib/skill-index.js +171 -0
  73. package/src/dashboard/lib/skill-indexer.js +118 -0
  74. package/src/dashboard/lib/skill-recommender.js +689 -0
  75. package/src/dashboard/lib/state-core/backfill.js +298 -0
  76. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  77. package/src/dashboard/lib/state-core/event-log.js +615 -0
  78. package/src/dashboard/lib/state-core/events.js +265 -0
  79. package/src/dashboard/lib/state-core/projections.js +376 -0
  80. package/src/dashboard/lib/state-core/start-close.js +162 -0
  81. package/src/dashboard/lib/state-core/trial.js +96 -0
  82. package/src/dashboard/lib/strategy-parser.js +248 -0
  83. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  84. package/src/dashboard/lib/streak-parser.js +95 -0
  85. package/src/dashboard/lib/suggested-now.js +254 -0
  86. package/src/dashboard/lib/tool-awareness.js +125 -0
  87. package/src/dashboard/lib/transcript-parser.js +371 -0
  88. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  89. package/src/dashboard/lib/view-generator.js +163 -0
  90. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  91. package/src/dashboard/lib/walkthrough-script.js +140 -0
  92. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  93. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  94. package/src/dashboard/server.js +2402 -0
  95. package/src/dashboard/shortcut.js +284 -0
  96. package/src/dashboard/static/setup-poc.html +306 -0
  97. package/src/dashboard/static/walkthrough-poc.html +580 -0
  98. package/src/dashboard/styles.css +1201 -0
  99. package/src/dashboard/templates/index.html +278 -0
  100. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  101. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  102. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  103. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  104. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  105. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  106. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  107. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  113. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  114. package/src/dashboard/ui/dist/icon.svg +4 -0
  115. package/src/dashboard/ui/dist/index.html +18 -0
  116. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  117. package/src/dashboard/ui/dist/registerSW.js +1 -0
  118. package/src/dashboard/ui/dist/sw.js +1 -0
  119. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,519 @@
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 crypto = require("crypto");
19
+ const { tokenize, termFreq, buildVocab } = require("./memory-text");
20
+ const { enumerateSessions, parseTranscript, deriveProject } = require("./transcript-parser");
21
+ const { parseSessionLogs } = require("./session-log-parser");
22
+ const { moverDir, safeRead, safeReadJson, engineDir } = require("./paths");
23
+
24
+ // MOVER_MEMORY_DIR override exists so tests can build a hermetic index in a temp dir.
25
+ const MEM_DIR = process.env.MOVER_MEMORY_DIR || path.join(moverDir(), "memory");
26
+ const SEG_DIR = path.join(MEM_DIR, "segments");
27
+ const MANIFEST = path.join(MEM_DIR, "manifest.json");
28
+ const VERSION = 1;
29
+
30
+ const K1 = 1.2, B = 0.75;
31
+ // Curated-first: the distilled tiers outrank raw transcripts at equal term match.
32
+ const TYPE_BOOST = { sessionlog: 1.5, autolearning: 1.4, library: 1.3, engine: 1.1, session: 1.0 };
33
+ // A doc TITLED about a query term is strongly relevant — lift it over body-only mentions so
34
+ // common topics (low IDF, e.g. "dashboard") still clear the recall gate when titled for them.
35
+ const TITLE_BOOST = 2.5;
36
+
37
+ const ENGINE_FILES = [
38
+ "Identity_Prime.md", "Strategy.md", "Active_Context.md", "Goals.md",
39
+ "Mover_Dossier.md", "Architect_Dossier.md", "Auto_Learnings.md",
40
+ "Metrics_Log.md", "Someday_Maybe.md",
41
+ ];
42
+
43
+ // ── helpers ──
44
+
45
+ function ensureDirs() {
46
+ try { fs.mkdirSync(SEG_DIR, { recursive: true }); } catch {}
47
+ }
48
+
49
+ function atomicWrite(file, obj) {
50
+ // terra T-MH-2: a fixed `file + ".tmp"` name means two concurrent SessionEnd
51
+ // hooks writing the same segment/manifest share one temp path and can interleave
52
+ // into a torn file. A per-write unique temp name makes each write's rename
53
+ // independent (the last full write wins cleanly; no half-written temp survives).
54
+ const tmp = file + "." + process.pid + "-" + crypto.randomBytes(4).toString("hex") + ".tmp";
55
+ fs.writeFileSync(tmp, JSON.stringify(obj));
56
+ try { fs.renameSync(tmp, file); }
57
+ catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
58
+ }
59
+
60
+ // terra T-MH-1: safeReadJson() collapses "file absent" and "file present but
61
+ // MALFORMED" both to null, so `safeReadJson(segFile) || newSeg()` would overwrite
62
+ // a corrupt segment with an empty one — discarding every indexed doc for that
63
+ // month. Distinguish the two: a MISSING segment is a fresh newSeg(); a present-
64
+ // but-unparseable segment THROWS, so the fail-open SessionEnd hook skips indexing
65
+ // this one session and PRESERVES the existing file rather than nuking it.
66
+ function readSegOrNew(segFile) {
67
+ let raw;
68
+ try { raw = fs.readFileSync(segFile, "utf8"); }
69
+ catch (e) { if (e.code === "ENOENT") return newSeg(); throw e; }
70
+ let parsed;
71
+ try { parsed = JSON.parse(raw); }
72
+ catch (_) { throw Object.assign(new Error("memory segment is malformed; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" }); }
73
+ // A parsed-but-wrong-shape file is also not safe to fold into — treat as corrupt.
74
+ if (!parsed || typeof parsed !== "object" || !parsed.docs || !parsed.postings) {
75
+ throw Object.assign(new Error("memory segment has an unexpected shape; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" });
76
+ }
77
+ return parsed;
78
+ }
79
+
80
+ function monthOf(ts, fallbackMs) {
81
+ let d = ts ? Date.parse(ts) : NaN;
82
+ if (!Number.isFinite(d)) d = fallbackMs || Date.now();
83
+ const dt = new Date(d);
84
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, "0")}`;
85
+ }
86
+
87
+ function newSeg() { return { docs: Object.create(null), postings: Object.create(null) }; }
88
+
89
+ // Add one doc {id,type,text,title,...} into a segment, updating global docFreq + length stats.
90
+ function addDoc(d, seg, df, lens) {
91
+ const toks = tokenize(d.text);
92
+ if (!toks.length) return;
93
+ const tf = termFreq(toks);
94
+ const len = toks.length;
95
+ lens.total += len; lens.count++;
96
+ const title = (d.title || "").slice(0, 160);
97
+ // Normalize ts to an ISO string — library/auto-learnings parsers pass mtime NUMBERS.
98
+ const ts = d.ts == null ? null : (typeof d.ts === "number" ? new Date(d.ts).toISOString() : String(d.ts));
99
+ // Strip a leading echo of the title from the snippet (session-log text starts with its title).
100
+ let body = d.snippet || d.text;
101
+ if (d.title && body.startsWith(d.title)) body = body.slice(d.title.length);
102
+ seg.docs[d.id] = {
103
+ id: d.id, type: d.type, title,
104
+ project: d.project || null, ts, len,
105
+ snippet: body.replace(/\s+/g, " ").trim().slice(0, 220),
106
+ meta: d.meta || {},
107
+ };
108
+ for (const term in tf) {
109
+ (seg.postings[term] || (seg.postings[term] = Object.create(null)))[d.id] = tf[term];
110
+ df[term] = (df[term] || 0) + 1;
111
+ }
112
+ }
113
+
114
+ // Remove ONE doc's contribution from a segment + the global manifest stats (postings, docFreq,
115
+ // totalDocs/Len, linkFreq). Used on re-index AND on the cross-month cleanup (H1). Defensive: clamps
116
+ // docFreq/linkFreq at 0 so a previously-inconsistent index can't drive a term's df negative (which
117
+ // would invert its IDF — M2). Caller persists the mutated segment.
118
+ function removeDocContribution(seg, id, man) {
119
+ const old = seg.docs[id];
120
+ if (!old) return;
121
+ for (const term in seg.postings) {
122
+ if (seg.postings[term][id] != null) {
123
+ delete seg.postings[term][id];
124
+ man.docFreq[term] = Math.max(0, (man.docFreq[term] || 0) - 1);
125
+ if (man.docFreq[term] <= 0) delete man.docFreq[term];
126
+ if (!Object.keys(seg.postings[term]).length) delete seg.postings[term];
127
+ }
128
+ }
129
+ man.totalDocs = Math.max(0, (man.totalDocs || 1) - 1);
130
+ man.totalLen = Math.max(0, (man.totalLen || old.len || 0) - (old.len || 0));
131
+ for (const note of new Set((old.meta && old.meta.candidateLinks) || [])) {
132
+ man.linkFreq[note] = Math.max(0, (man.linkFreq[note] || 0) - 1);
133
+ if (man.linkFreq[note] <= 0) delete man.linkFreq[note];
134
+ }
135
+ delete seg.docs[id];
136
+ }
137
+
138
+ // ── curated docs ──
139
+
140
+ function curatedDocs(vault) {
141
+ const docs = [];
142
+
143
+ // 1. Session-log arcs (highest signal — /log already distilled them).
144
+ try {
145
+ for (const a of parseSessionLogs(vault).docs) {
146
+ docs.push({ id: a.id, type: "sessionlog", title: a.title, project: a.project, ts: a.date, text: a.text,
147
+ meta: { date: a.date, time: a.time, commits: a.commits } });
148
+ }
149
+ } catch (e) { /* honest-null: missing dailies just means no arcs */ }
150
+
151
+ // 2. Library files (name + first paragraph + wikilinks).
152
+ try {
153
+ const { indexLibraryV2 } = require("./library-indexer-v2");
154
+ const lib = indexLibraryV2(vault);
155
+ const sections = (lib && lib.sections) || {};
156
+ for (const key of Object.keys(sections)) {
157
+ const sec = sections[key];
158
+ // Sections vary: most have items[]; inputs has byType{}; entities has people/orgs/places/decisions[].
159
+ let items = [];
160
+ if (Array.isArray(sec.items)) items = sec.items;
161
+ else if (sec.byType) items = [].concat(...Object.values(sec.byType));
162
+ else if (sec.people || sec.orgs || sec.places || sec.decisions)
163
+ items = [].concat(sec.people || [], sec.orgs || [], sec.places || [], sec.decisions || []);
164
+ for (const it of items) {
165
+ if (!it || !it.name) continue;
166
+ docs.push({ id: `lib:${it.path || it.name}`, type: "library", title: it.name, ts: it.mtime || null,
167
+ text: [it.name, it.firstParagraph || "", (it.wikilinks || []).join(" ")].join("\n"),
168
+ meta: { path: it.path, section: key } });
169
+ }
170
+ }
171
+ } catch (e) { /* library indexer optional */ }
172
+
173
+ // 3. Engine files (whole-file; ubiquitous so low boost, but searchable for "what's my X").
174
+ for (const name of ENGINE_FILES) {
175
+ const p = path.join(engineDir(vault), name);
176
+ const raw = safeRead(p);
177
+ if (!raw) continue;
178
+ docs.push({ id: `engine:${name}`, type: "engine", title: name.replace(/\.md$/, ""), ts: null,
179
+ text: raw.slice(0, 40000), meta: { path: p } });
180
+ }
181
+
182
+ // 4. Auto_Learnings entries (behavioral patterns w/ confidence).
183
+ try {
184
+ const { parseAutoLearnings } = require("./auto-learnings-parser");
185
+ const al = parseAutoLearnings(vault);
186
+ for (const e of (al.entries || [])) {
187
+ if (!e || !e.name) continue;
188
+ docs.push({ id: `al:${e.type || "x"}:${e.name}`, type: "autolearning", title: e.name, ts: e.lastUpdated || null,
189
+ text: [e.type, e.name, e.body || ""].join("\n"), meta: { confidence: e.confidence, status: e.status } });
190
+ }
191
+ } catch (e) { /* optional */ }
192
+
193
+ return docs;
194
+ }
195
+
196
+ // ── full backfill ──
197
+
198
+ async function buildIndex(vault, opts = {}) {
199
+ ensureDirs();
200
+ const log = opts.log || (() => {});
201
+ const t0 = Date.now();
202
+ const vocab = buildVocab(vault);
203
+ log(`vocab: ${vocab.entries.length} note names`);
204
+
205
+ const df = Object.create(null);
206
+ const lens = { total: 0, count: 0 };
207
+ const linkFreq = Object.create(null);
208
+ const indexedSessions = Object.create(null);
209
+ const segs = Object.create(null); // segName -> seg
210
+ const getSeg = (name) => segs[name] || (segs[name] = newSeg());
211
+
212
+ // curated segment
213
+ const cur = getSeg("curated");
214
+ const cdocs = curatedDocs(vault);
215
+ for (const d of cdocs) addDoc(d, cur, df, lens);
216
+ log(`curated: ${cdocs.length} docs`);
217
+
218
+ // raw session segments (streamed, one session at a time → bounded memory)
219
+ const sessions = enumerateSessions(opts.projectsDir);
220
+ log(`raw: ${sessions.length} unique sessions to index`);
221
+ let n = 0;
222
+ for (const s of sessions) {
223
+ const r = await parseTranscript(s.file, { vocab, sessionId: s.sessionId, project: s.project, agent: s.agent });
224
+ n++;
225
+ if (r.error) { log(` [${n}/${sessions.length}] SKIP (read error) ${s.sessionId}`); continue; }
226
+ const month = monthOf(r.firstTs, s.mtime);
227
+ const seg = getSeg("raw-" + month);
228
+ const title = `Session · ${s.project} · ${(r.firstTs || "").slice(0, 10) || month}`;
229
+ addDoc({
230
+ id: `session:${s.sessionId}`, type: "session", title, project: s.project, ts: r.firstTs,
231
+ text: r.sampleText,
232
+ snippet: r.snippet || r.decisions[0] || r.sampleText,
233
+ meta: {
234
+ sessionId: s.sessionId, file: s.file, month, agent: s.agent,
235
+ turns: r.turns, durationMin: r.durationMin, sizeMB: +(s.size / 1e6).toFixed(1),
236
+ commits: r.artifacts.commits, commands: r.artifacts.commands, errors: r.artifacts.errors,
237
+ topTools: Object.keys(r.artifacts.tools).slice(0, 6), candidateLinks: r.candidateLinks,
238
+ lastCommand: (r.meta && r.meta.lastCommand) || null, // C1: workflow active at the last error (GEPA attribution)
239
+ },
240
+ }, seg, df, lens);
241
+ indexedSessions[s.sessionId] = { size: s.size, mtime: s.mtime, month };
242
+ // linkFreq: how many sessions mention each note (drives link-IDF / the noise fix).
243
+ for (const note of new Set(r.candidateLinks)) linkFreq[note] = (linkFreq[note] || 0) + 1;
244
+ if (n % 25 === 0) log(` [${n}/${sessions.length}] indexed (${Math.round(process.memoryUsage().rss / 1e6)}MB rss)`);
245
+ }
246
+
247
+ // write segments
248
+ let bytes = 0;
249
+ for (const name of Object.keys(segs)) {
250
+ const file = path.join(SEG_DIR, name + ".json");
251
+ atomicWrite(file, segs[name]);
252
+ bytes += fs.statSync(file).size;
253
+ }
254
+ // Proactive-recall triggers: entities/projects worth surfacing when merely MENTIONED
255
+ // (signal only — drop ubiquitous notes and sub-4-char names). Plain newline text so the
256
+ // bash pre-filter can `grep -wFf` it in ~ms.
257
+ try {
258
+ const N = sessions.length || 1;
259
+ const trig = Object.keys(linkFreq)
260
+ .filter((n) => n.length >= 4 && linkFreq[n] / N <= 0.5)
261
+ .sort((a, b) => linkFreq[a] - linkFreq[b]);
262
+ fs.writeFileSync(path.join(MEM_DIR, "recall-triggers.txt"), trig.join("\n") + (trig.length ? "\n" : ""));
263
+ } catch {}
264
+ const manifest = {
265
+ version: VERSION, builtAt: new Date(t0).toISOString(), vault,
266
+ totalDocs: lens.count, totalLen: lens.total, avgDocLen: lens.count ? lens.total / lens.count : 0,
267
+ docFreq: df, linkFreq, indexedSessions,
268
+ segments: Object.keys(segs), trust: {},
269
+ };
270
+ atomicWrite(MANIFEST, manifest);
271
+
272
+ return {
273
+ ms: Date.now() - t0, totalDocs: lens.count, segments: Object.keys(segs).length,
274
+ indexBytes: bytes, curatedDocs: cdocs.length, sessions: sessions.length,
275
+ uniqueTerms: Object.keys(df).length, uniqueLinks: Object.keys(linkFreq).length,
276
+ };
277
+ }
278
+
279
+ // ── query ──
280
+
281
+ function loadManifest() { return safeReadJson(MANIFEST); }
282
+ function loadSeg(name) { return safeReadJson(path.join(SEG_DIR, name + ".json")); }
283
+
284
+ function idf(df, N, term) {
285
+ const n = df[term] || 0;
286
+ return Math.log(1 + (N - n + 0.5) / (n + 0.5));
287
+ }
288
+
289
+ // Scope guard (precision-first PUSH): a project-tagged doc is in-scope only for its own project,
290
+ // so another project's session never bleeds into this one. Vault-global curated docs
291
+ // (engine/library/autolearning, project=null) are always in scope. Lenient on sub-path drift so a
292
+ // session started in a project subdir still matches its root. Opt-in via opts.project — the deep
293
+ // pull skill passes none and searches everything.
294
+ // Normalize away tag punctuation ("[Mover OS Bundle", "Mover OS Bundle + Belgium") so a legit
295
+ // current-project arc isn't filtered over a stray bracket — while a real other project still won't match.
296
+ function normProject(p) { return String(p || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); }
297
+ function inScope(docProject, currentProject) {
298
+ if (!docProject) return true; // vault-global curated → always in scope
299
+ if (!currentProject) return true; // unknown current project → don't filter (fail-open)
300
+ const d = normProject(docProject), c = normProject(currentProject);
301
+ // One-directional, word-boundary match (M1): in scope iff the doc IS the current project, or a
302
+ // sub-scope of it ("Mover OS Bundle + Belgium" → starts with "mover os bundle "). NOT the reverse:
303
+ // a shorter prefix-named OTHER project ("Mover") must never bleed into "Mover OS Bundle", and the
304
+ // trailing space stops a same-prefix sibling ("Mover OS Bundle2") from matching either.
305
+ return d === c || d.startsWith(c + " ");
306
+ }
307
+
308
+ function query(qStr, opts = {}) {
309
+ const man = loadManifest();
310
+ if (!man) return { available: false, hits: [] };
311
+ const qTerms = [...new Set(tokenize(qStr))];
312
+ if (!qTerms.length) return { available: true, hits: [], terms: [] };
313
+
314
+ const N = man.totalDocs || 1;
315
+ const avgdl = man.avgDocLen || 1;
316
+ const maxSegments = opts.maxSegments || 12;
317
+ const trust = man.trust || {};
318
+
319
+ // Always load curated; then raw segments newest-first up to the budget.
320
+ const rawSegs = (man.segments || []).filter((s) => s.startsWith("raw-")).sort().reverse().slice(0, maxSegments);
321
+ const segNames = ["curated", ...rawSegs].filter((s) => (man.segments || []).includes(s));
322
+
323
+ const scores = Object.create(null);
324
+ const docRef = Object.create(null);
325
+ for (const name of segNames) {
326
+ const seg = loadSeg(name);
327
+ if (!seg) continue;
328
+ for (const term of qTerms) {
329
+ const post = seg.postings[term];
330
+ if (!post) continue;
331
+ const w = idf(man.docFreq, N, term);
332
+ for (const id in post) {
333
+ const d = seg.docs[id];
334
+ if (!d) continue;
335
+ const tf = post[id];
336
+ const denom = tf + K1 * (1 - B + B * (d.len / avgdl));
337
+ const s = w * (tf * (K1 + 1)) / denom;
338
+ scores[id] = (scores[id] || 0) + s;
339
+ docRef[id] = d;
340
+ }
341
+ }
342
+ }
343
+
344
+ const hits = Object.keys(scores).map((id) => {
345
+ const d = docRef[id];
346
+ const boost = TYPE_BOOST[d.type] || 1.0;
347
+ const tr = trust[id] != null ? trust[id] : 1.0;
348
+ const titleLc = (d.title || "").toLowerCase();
349
+ const tBoost = qTerms.some((t) => titleLc.includes(t)) ? TITLE_BOOST : 1.0;
350
+ return { id, type: d.type, title: d.title, project: d.project, ts: d.ts,
351
+ score: +(scores[id] * boost * tr * tBoost).toFixed(3), snippet: d.snippet, meta: d.meta };
352
+ });
353
+ hits.sort((a, b) => b.score - a.score);
354
+ // Cross-project scope guard — only when a current project is supplied (push path).
355
+ const scoped = opts.project ? hits.filter((h) => inScope(h.project, opts.project)) : hits;
356
+ return { available: true, terms: qTerms, total: scoped.length, hits: scoped.slice(0, opts.limit || 10) };
357
+ }
358
+
359
+ // ── link-IDF report (the "watch the noise disappear" proof) ──
360
+
361
+ function linkReport(sessionId, opts = {}) {
362
+ const man = loadManifest();
363
+ if (!man) return null;
364
+ const N = Object.keys(man.indexedSessions || {}).length || 1;
365
+ // find the session doc (search its month segment, else scan raw segs)
366
+ let doc = null;
367
+ for (const name of (man.segments || []).filter((s) => s.startsWith("raw-")).sort().reverse()) {
368
+ const seg = loadSeg(name);
369
+ if (seg && seg.docs["session:" + sessionId]) { doc = seg.docs["session:" + sessionId]; break; }
370
+ }
371
+ if (!doc) return null;
372
+ const links = (doc.meta.candidateLinks || []).map((note) => {
373
+ const freq = man.linkFreq[note] || 1;
374
+ const lidf = Math.log(N / freq);
375
+ return { note, sessions: freq, idf: +lidf.toFixed(2), score: +lidf.toFixed(2) };
376
+ });
377
+ const rawOrder = [...links].sort((a, b) => b.sessions - a.sessions); // "naive" = most-mentioned first
378
+ const idfOrder = [...links].sort((a, b) => b.idf - a.idf); // IDF-weighted = rarest first
379
+ return { sessionId, total: links.length, N, naive: rawOrder.slice(0, 12), idf: idfOrder.slice(0, 12) };
380
+ }
381
+
382
+ function stats() {
383
+ const man = loadManifest();
384
+ if (!man) return { available: false };
385
+ let bytes = 0;
386
+ for (const s of (man.segments || [])) {
387
+ try { bytes += fs.statSync(path.join(SEG_DIR, s + ".json")).size; } catch {}
388
+ }
389
+ return {
390
+ available: true, builtAt: man.builtAt, totalDocs: man.totalDocs,
391
+ avgDocLen: +man.avgDocLen.toFixed(1), uniqueTerms: Object.keys(man.docFreq || {}).length,
392
+ sessions: Object.keys(man.indexedSessions || {}).length, segments: man.segments,
393
+ uniqueLinks: Object.keys(man.linkFreq || {}).length, indexMB: +(bytes / 1e6).toFixed(1),
394
+ };
395
+ }
396
+
397
+ // ── incremental: index ONE session (the session-end path; no full rebuild) ──
398
+ // Idempotent: skips a session whose mtime is unchanged. Re-indexes (remove→add) if the
399
+ // file grew. Updates global docFreq/linkFreq/totals in the manifest directly so IDF stays
400
+ // correct without re-reading the whole corpus.
401
+ async function indexSession(file, opts = {}) {
402
+ const man = loadManifest();
403
+ if (!man) return { error: "no index — run --backfill first" };
404
+ man.docFreq = man.docFreq || {}; man.linkFreq = man.linkFreq || {}; man.indexedSessions = man.indexedSessions || {};
405
+ const vault = opts.vault || man.vault;
406
+ const pathSessionId = path.basename(file).replace(/\.jsonl$/, "");
407
+ const pathProject = deriveProject(path.basename(path.dirname(file))) || null;
408
+ let mtime = null, size = 0;
409
+ try { const st = fs.statSync(file); mtime = st.mtimeMs; size = st.size; } catch {}
410
+
411
+ // Parse before selecting identity. Claude stores sessions under an encoded project directory, but
412
+ // Codex stores rollouts under date folders and carries the real session id + cwd in session_meta.
413
+ const r = await parseTranscript(file, {
414
+ vocab: buildVocab(vault),
415
+ sessionId: opts.sessionId,
416
+ project: opts.project,
417
+ agent: opts.agent,
418
+ });
419
+ const sessionId = opts.sessionId || r.sessionId || pathSessionId;
420
+ const project = opts.project || r.project || pathProject;
421
+ if (r.error) return { error: "parse failed", sessionId };
422
+
423
+ let reindexed = false;
424
+ // Remove a pre-fix Codex entry keyed by the rollout filename. Without this migration the same
425
+ // transcript survives twice: once under rollout-* and once under its canonical session id.
426
+ if (pathSessionId !== sessionId) {
427
+ const legacy = man.indexedSessions[pathSessionId];
428
+ if (legacy && legacy.month) {
429
+ const legacyFile = path.join(SEG_DIR, "raw-" + legacy.month + ".json");
430
+ const legacySeg = safeReadJson(legacyFile);
431
+ if (legacySeg && legacySeg.docs["session:" + pathSessionId]) {
432
+ removeDocContribution(legacySeg, "session:" + pathSessionId, man);
433
+ atomicWrite(legacyFile, legacySeg);
434
+ }
435
+ delete man.indexedSessions[pathSessionId];
436
+ reindexed = true;
437
+ }
438
+ }
439
+
440
+ const prev = man.indexedSessions[sessionId];
441
+ if (prev && mtime && prev.mtime === mtime && !opts.force && !reindexed) return { skipped: true, sessionId };
442
+
443
+ const month = monthOf(r.firstTs, mtime);
444
+ const id = "session:" + sessionId;
445
+
446
+ // H1: a session's month can change between indexings (e.g. first indexed off mtime while its
447
+ // turns are still timestamp-less, then a real earlier/later timestamp appears). The old copy then
448
+ // lives in the PREVIOUSLY recorded month's segment — remove it from THERE too, else it orphans
449
+ // (ghost doc) and permanently inflates docFreq/totalDocs/linkFreq, degrading IDF until a full
450
+ // --backfill. Only fires on a genuine month change; the common same-month re-index skips it.
451
+ const prevMonth = prev && prev.month;
452
+ if (prevMonth && prevMonth !== month) {
453
+ const prevSegFile = path.join(SEG_DIR, "raw-" + prevMonth + ".json");
454
+ const prevSeg = safeReadJson(prevSegFile);
455
+ if (prevSeg && prevSeg.docs[id]) {
456
+ removeDocContribution(prevSeg, id, man);
457
+ atomicWrite(prevSegFile, prevSeg);
458
+ reindexed = true;
459
+ }
460
+ }
461
+
462
+ const segFile = path.join(SEG_DIR, "raw-" + month + ".json");
463
+ const seg = readSegOrNew(segFile); // terra T-MH-1: never clobber a malformed segment
464
+
465
+ // remove old contribution (same-month re-index case) — scan only this segment's postings.
466
+ if (seg.docs[id]) { removeDocContribution(seg, id, man); reindexed = true; }
467
+
468
+ // add the fresh doc + fold its terms into the global stats.
469
+ const tf = termFreq(tokenize(r.sampleText));
470
+ const len = Object.values(tf).reduce((a, b) => a + b, 0);
471
+ seg.docs[id] = {
472
+ id, type: "session", title: `Session · ${r.project || project || "?"} · ${(r.firstTs || "").slice(0, 10) || month}`,
473
+ project: r.project || project || null, ts: r.firstTs, len,
474
+ snippet: r.snippet || (r.decisions[0] || r.sampleText).slice(0, 220).replace(/\s+/g, " "),
475
+ meta: { sessionId, file, month, turns: r.turns, durationMin: r.durationMin,
476
+ commits: r.artifacts.commits, commands: r.artifacts.commands, errors: r.artifacts.errors,
477
+ topTools: Object.keys(r.artifacts.tools).slice(0, 6), candidateLinks: r.candidateLinks,
478
+ lastCommand: (r.meta && r.meta.lastCommand) || null }, // C1: workflow active at the last error (GEPA attribution)
479
+ };
480
+ for (const term in tf) {
481
+ (seg.postings[term] || (seg.postings[term] = Object.create(null)))[id] = tf[term];
482
+ man.docFreq[term] = (man.docFreq[term] || 0) + 1;
483
+ }
484
+ man.totalDocs = (man.totalDocs || 0) + 1;
485
+ man.totalLen = (man.totalLen || 0) + len;
486
+ man.avgDocLen = man.totalDocs ? man.totalLen / man.totalDocs : 0;
487
+ for (const note of new Set(r.candidateLinks)) man.linkFreq[note] = (man.linkFreq[note] || 0) + 1;
488
+ man.indexedSessions[sessionId] = { size, mtime, month };
489
+ if (!man.segments.includes("raw-" + month)) man.segments.push("raw-" + month);
490
+
491
+ ensureDirs();
492
+ atomicWrite(segFile, seg);
493
+ atomicWrite(MANIFEST, man);
494
+ return { ok: true, sessionId, month, len, totalDocs: man.totalDocs, reindexed };
495
+ }
496
+
497
+ module.exports = { buildIndex, indexSession, query, linkReport, stats, inScope, MEM_DIR };
498
+
499
+ // ── CLI ──
500
+ if (require.main === module) {
501
+ (async () => {
502
+ const cmd = process.argv[2];
503
+ const vault = require("./paths").resolveVault();
504
+ if (cmd === "--backfill") {
505
+ const r = await buildIndex(vault, { log: (m) => console.log(m) });
506
+ console.log("\nDONE:", JSON.stringify(r, null, 2));
507
+ } else if (cmd === "--query") {
508
+ console.log(JSON.stringify(query(process.argv.slice(3).join(" "), { limit: 8 }), null, 2));
509
+ } else if (cmd === "--links") {
510
+ console.log(JSON.stringify(linkReport(process.argv[3]), null, 2));
511
+ } else if (cmd === "--index") {
512
+ console.log(JSON.stringify(await indexSession(process.argv[3], { force: process.argv.includes("--force") }), null, 2));
513
+ } else if (cmd === "--stats") {
514
+ console.log(JSON.stringify(stats(), null, 2));
515
+ } else {
516
+ console.log('usage: node memory-index.js --backfill | --query "..." | --links <sessionId> | --stats');
517
+ }
518
+ })();
519
+ }
@@ -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 };