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.
- package/README.md +34 -24
- package/install.js +2868 -251
- package/package.json +15 -3
- package/src/dashboard/build.js +1541 -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 +189 -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 +483 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/approval-registry.js +170 -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 +228 -0
- package/src/dashboard/lib/date-utils.js +43 -0
- package/src/dashboard/lib/distribution-parser.js +137 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +88 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +173 -0
- package/src/dashboard/lib/engine-writer.js +1831 -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 +427 -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 +226 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/library-search.js +290 -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 +519 -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/pid-markers.js +80 -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 +286 -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/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +248 -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 +371 -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 +2402 -0
- package/src/dashboard/shortcut.js +284 -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-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -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,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 };
|