pi-mega-compact 0.20.85 → 0.20.87
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/dist/config.js +9 -0
- package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +2 -0
- package/dist/extensions/mega-config.js +12 -0
- package/dist/extensions/mega-events/context-handler/gateCheck.js +27 -0
- package/dist/extensions/mega-events/context-handler/thrashGuard.js +186 -0
- package/dist/extensions/mega-events/context-handler.js +33 -1
- package/dist/extensions/mega-pipeline/compact/noop.js +104 -0
- package/dist/extensions/mega-pipeline/compact/run.js +268 -0
- package/dist/extensions/mega-pipeline/compact/vote.js +72 -0
- package/dist/extensions/mega-pipeline/compact.js +12 -343
- package/dist/extensions/mega-pipeline/recall/impl.js +258 -0
- package/dist/extensions/mega-pipeline/recall.js +6 -253
- package/dist/src/config.js +9 -0
- package/dist/src/failback/compact.js +109 -0
- package/dist/src/recall/readonly.js +39 -0
- package/dist/src/recall/recall3wf.fixture.js +67 -0
- package/dist/src/recall/validator.js +113 -0
- package/dist/src/recall/vote.js +217 -0
- package/dist/src/store/sqlite/fts5-search.js +26 -0
- package/dist/src/store/sqlite/meta.js +32 -0
- package/extensions/dashboard-server/routes-rag-settings-helpers.ts +9 -0
- package/extensions/mega-config-types.ts +13 -0
- package/extensions/mega-config.ts +12 -0
- package/extensions/mega-events/context-handler/gateCheck.ts +30 -0
- package/extensions/mega-events/context-handler/thrashGuard.ts +228 -0
- package/extensions/mega-events/context-handler.ts +36 -1
- package/extensions/mega-pipeline/compact/noop.ts +96 -0
- package/extensions/mega-pipeline/compact/run.ts +322 -0
- package/extensions/mega-pipeline/compact/vote.ts +85 -0
- package/extensions/mega-pipeline/compact.ts +12 -385
- package/extensions/mega-pipeline/recall/impl.ts +312 -0
- package/extensions/mega-pipeline/recall.ts +10 -306
- package/package.json +1 -1
- package/src/config.ts +12 -0
- package/src/failback/compact.ts +122 -0
- package/src/failback/types.ts +72 -0
- package/src/recall/readonly.ts +57 -0
- package/src/recall/recall3wf.fixture.ts +87 -0
- package/src/recall/validator.ts +150 -0
- package/src/recall/vote.ts +240 -0
- package/src/store/sqlite/fts5-search.ts +40 -0
- package/src/store/sqlite/meta.ts +36 -0
|
@@ -1,256 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* recall.ts — unified Layer-5 recall pipeline.
|
|
2
|
+
* recall.ts — shell re-export for the unified Layer-5 recall pipeline (3WF-3 split).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Delegate-shell pattern: the implementation lives in ./recall/impl.ts (kept
|
|
5
|
+
* under the 300-line soft cap). All public symbols are re-exported here so
|
|
6
|
+
* `export * from "./mega-pipeline/recall.js"` (mega-pipeline.ts) and any direct
|
|
7
|
+
* importers keep resolving with byte-identical names.
|
|
7
8
|
*/
|
|
8
|
-
|
|
9
|
-
import { recallAndInline, recallAndInlineAsync, formatRecallBlock, } from "../../src/recall.js";
|
|
10
|
-
import { normalizeSessionId } from "../../src/store.js";
|
|
11
|
-
import { incRecallInjected, incCacheHitTokens, getIndexDir, } from "../../src/store/sqlite.js";
|
|
12
|
-
import { ensureConversationIdFor, recordTurnWrite, recordRecallWrite, } from "../mega-turn-store.js";
|
|
13
|
-
import { C } from "../mega-runtime.js";
|
|
14
|
-
import { recordRecallLatency } from "../mega-runtime/vc-observer.js";
|
|
15
|
-
/**
|
|
16
|
-
* Unified recall (Layer 5). The ONE path that injects. Returns the recall
|
|
17
|
-
* result; callers decide whether to stage it for before_agent_start (resume)
|
|
18
|
-
* or report it (command).
|
|
19
|
-
*/
|
|
20
|
-
export function doRecall(runtime, config, ctx, query, source) {
|
|
21
|
-
runtime.bindRepo(ctx.cwd);
|
|
22
|
-
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
23
|
-
// Live window text for inline dedupe (Fix C): drop recalled checkpoints that
|
|
24
|
-
// are already resident in the session, so recall never re-injects context the
|
|
25
|
-
// model can already see. Best-effort — an empty window just skips dedupe.
|
|
26
|
-
const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
|
|
27
|
-
const recallStartMs = Date.now();
|
|
28
|
-
const result = recallAndInline({
|
|
29
|
-
sessionId: sid,
|
|
30
|
-
query,
|
|
31
|
-
limit: config.autoInlineK,
|
|
32
|
-
source,
|
|
33
|
-
skipInjected: true,
|
|
34
|
-
recallMaxTokens: config.recallMaxTokens,
|
|
35
|
-
windowDedupe: config.windowDedupe,
|
|
36
|
-
liveWindow,
|
|
37
|
-
dedupSim: config.dedupSim,
|
|
38
|
-
}, runtime.store);
|
|
39
|
-
runtime.dashboard.event("recall", {
|
|
40
|
-
source,
|
|
41
|
-
query: query.slice(0, 120),
|
|
42
|
-
injected: result.toInject.length,
|
|
43
|
-
empty: result.empty,
|
|
44
|
-
});
|
|
45
|
-
if (config.ragRecallMetrics && result.hydeInfo) {
|
|
46
|
-
runtime.dashboard.event("hyde_executed", {
|
|
47
|
-
sessionId: sid,
|
|
48
|
-
ran: result.hydeInfo.ran,
|
|
49
|
-
skipped: result.hydeInfo.skipped,
|
|
50
|
-
reason: result.hydeInfo.reason,
|
|
51
|
-
hypotheticalDoc: result.hydeInfo.hypotheticalDoc.slice(0, 400),
|
|
52
|
-
generationMs: result.hydeInfo.generationMs,
|
|
53
|
-
rawHitCount: result.hydeInfo.rawHitCount,
|
|
54
|
-
hydeHitCount: result.hydeInfo.hydeHitCount,
|
|
55
|
-
fusedHitCount: result.hydeInfo.fusedHitCount,
|
|
56
|
-
lift: result.hydeInfo.lift,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
if (config.ragRecallMetrics && result.recallMetrics) {
|
|
60
|
-
runtime.dashboard.event("recall_metrics", {
|
|
61
|
-
sessionId: sid,
|
|
62
|
-
hitCount: result.recallMetrics.hitCount,
|
|
63
|
-
score: result.recallMetrics.score,
|
|
64
|
-
pass: result.recallMetrics.pass,
|
|
65
|
-
relevance: result.recallMetrics.relevance,
|
|
66
|
-
coverage: result.recallMetrics.coverage,
|
|
67
|
-
diversity: result.recallMetrics.diversity,
|
|
68
|
-
specificity: result.recallMetrics.specificity,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
if (!result.empty && result.toInject.length > 0) {
|
|
72
|
-
const top = result.toInject[0];
|
|
73
|
-
const scorePct = Math.round((top.score ?? 0) * 100);
|
|
74
|
-
const files = top.checkpoint.filesModified ?? [];
|
|
75
|
-
const label = files.length
|
|
76
|
-
? files
|
|
77
|
-
.map((f) => f.split("/").pop() ?? f)
|
|
78
|
-
.slice(0, 2)
|
|
79
|
-
.join(", ")
|
|
80
|
-
: top.checkpoint.checkpointId;
|
|
81
|
-
runtime.pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
|
|
82
|
-
runtime.lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
|
|
83
|
-
}
|
|
84
|
-
let sumTokens = 0;
|
|
85
|
-
for (const h of result.toInject)
|
|
86
|
-
sumTokens += h.checkpoint.tokenEstimate;
|
|
87
|
-
if (result.toInject.length > 0) {
|
|
88
|
-
runtime.rt.recallInjections += result.toInject.length;
|
|
89
|
-
runtime.rt.cacheHitTokens += sumTokens;
|
|
90
|
-
incRecallInjected(result.toInject.length, runtime.currentStateDir);
|
|
91
|
-
incCacheHitTokens(sumTokens, runtime.currentStateDir);
|
|
92
|
-
}
|
|
93
|
-
// S43: record recall provenance — which checkpoints/summaries served this
|
|
94
|
-
// turn, their score + source path. Linked to the turn row written at
|
|
95
|
-
// turn_end via the conversation+turnIndex. Best-effort + non-fatal.
|
|
96
|
-
// Persists telemetry (HyDE + recall metrics) even when recall returned
|
|
97
|
-
// no hits, so empty-recall HyDE invocations are still visible in the
|
|
98
|
-
// dashboard Turns/Metrics tabs.
|
|
99
|
-
const hasTelemetry = result.hydeInfo != null || result.recallMetrics != null;
|
|
100
|
-
if (result.toInject.length > 0 || hasTelemetry) {
|
|
101
|
-
try {
|
|
102
|
-
const convId = ensureConversationIdFor(config, sid, runtime.currentStateDir);
|
|
103
|
-
const turnId = recordTurnWrite(config, {
|
|
104
|
-
conversationId: convId,
|
|
105
|
-
sessionId: sid,
|
|
106
|
-
turnIndex: runtime.currentTurn,
|
|
107
|
-
role: "assistant",
|
|
108
|
-
startedAt: Date.now(),
|
|
109
|
-
hyde: result.hydeInfo ?? undefined,
|
|
110
|
-
recallMetrics: result.recallMetrics ?? undefined,
|
|
111
|
-
}, runtime.currentStateDir);
|
|
112
|
-
if (result.toInject.length > 0) {
|
|
113
|
-
recordRecallWrite(config, turnId, result.toInject.map((h) => ({
|
|
114
|
-
checkpointId: h.checkpoint.checkpointId,
|
|
115
|
-
score: h.score,
|
|
116
|
-
source: h.raptorLevel !== undefined
|
|
117
|
-
? "raptor"
|
|
118
|
-
: h.repoId
|
|
119
|
-
? "cross-repo"
|
|
120
|
-
: "flat",
|
|
121
|
-
raptorLevel: h.raptorLevel,
|
|
122
|
-
})), runtime.currentStateDir);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
/* non-fatal: recall provenance never breaks the recall path */
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
// VC0A: record recall latency on the eval observer (mode A) so the dashboard
|
|
130
|
-
// histogram reflects real data. No-op when the observer is absent (flag off /
|
|
131
|
-
// construction failure).
|
|
132
|
-
try {
|
|
133
|
-
recordRecallLatency(runtime, Date.now() - recallStartMs, sid, 0);
|
|
134
|
-
}
|
|
135
|
-
catch {
|
|
136
|
-
/* non-fatal: latency recording never breaks recall */
|
|
137
|
-
}
|
|
138
|
-
return result;
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* S17: async recall with optional cross-repo augmentation. Used on resume
|
|
142
|
-
* (session_start) and /mega-recall --cross-repo — NEVER from the mid-turn
|
|
143
|
-
* context handler (that stays sync). Runs the sync same-repo scan first; if it
|
|
144
|
-
* returns < config.autoInlineK hits AND crossRepo is enabled, awaits the PGlite
|
|
145
|
-
* HNSW cross-repo path and merges (source-labeled, deduped by checkpointId). The
|
|
146
|
-
* recallMaxTokens cap + windowDedupe apply to the merged set so cross-repo can
|
|
147
|
-
* never net-inflate the window. Cross-repo uses a stricter cosine floor
|
|
148
|
-
* (config.crossRepoCosine) than same-repo. Non-fatal: any async failure returns
|
|
149
|
-
* the same-repo result unchanged.
|
|
150
|
-
*/
|
|
151
|
-
export async function doRecallAsync(runtime, config, ctx, query, source, opts = {}) {
|
|
152
|
-
runtime.bindRepo(ctx.cwd);
|
|
153
|
-
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
154
|
-
const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
|
|
155
|
-
// Sync same-repo first (fast, never blocks).
|
|
156
|
-
const sameRepo = recallAndInline({
|
|
157
|
-
sessionId: sid,
|
|
158
|
-
query,
|
|
159
|
-
limit: config.autoInlineK,
|
|
160
|
-
source,
|
|
161
|
-
skipInjected: true,
|
|
162
|
-
recallMaxTokens: config.recallMaxTokens,
|
|
163
|
-
windowDedupe: config.windowDedupe,
|
|
164
|
-
liveWindow,
|
|
165
|
-
dedupSim: config.dedupSim,
|
|
166
|
-
}, runtime.store);
|
|
167
|
-
if (!config.crossRepoEnabled || !opts.crossRepo)
|
|
168
|
-
return sameRepo;
|
|
169
|
-
if (sameRepo.toInject.length >= config.autoInlineK)
|
|
170
|
-
return sameRepo; // same-repo satisfied
|
|
171
|
-
// Augment: cross-repo HNSW (async) with the stricter floor. Non-fatal.
|
|
172
|
-
try {
|
|
173
|
-
const x = await recallAndInlineAsync({
|
|
174
|
-
sessionId: sid,
|
|
175
|
-
query,
|
|
176
|
-
limit: config.autoInlineK,
|
|
177
|
-
source,
|
|
178
|
-
skipInjected: true,
|
|
179
|
-
recallMaxTokens: config.recallMaxTokens,
|
|
180
|
-
windowDedupe: config.windowDedupe,
|
|
181
|
-
liveWindow,
|
|
182
|
-
dedupSim: config.crossRepoCosine,
|
|
183
|
-
crossRepo: true,
|
|
184
|
-
// F2: resolve the machine-wide index dir via the shared resolver so the
|
|
185
|
-
// cross-repo injected-set dedup works even when MEGACOMPACT_INDEX_DIR is
|
|
186
|
-
// unset. The env var still wins when set (getIndexDir checks it first);
|
|
187
|
-
// the default (~/.mega-compact-index) is the same DB mega-commands and the
|
|
188
|
-
// dashboard read, so injection counts stay consistent. Without this, a
|
|
189
|
-
// bare `process.env` read returns undefined → cross-repo hits re-inject in
|
|
190
|
-
// every new session (the global injected-set is never consulted).
|
|
191
|
-
globalIndexDir: getIndexDir(),
|
|
192
|
-
}, runtime.store);
|
|
193
|
-
runtime.dashboard.event("recall-crossrepo", {
|
|
194
|
-
source,
|
|
195
|
-
query: query.slice(0, 120),
|
|
196
|
-
injected: x.toInject.length,
|
|
197
|
-
sourceRepos: x.toInject.map((h) => h.repoId).filter(Boolean),
|
|
198
|
-
});
|
|
199
|
-
// Merge, dedup by checkpointId, respect the same token cap by reformatting.
|
|
200
|
-
const seen = new Set(sameRepo.toInject.map((h) => h.checkpoint.checkpointId));
|
|
201
|
-
const merged = [...sameRepo.toInject];
|
|
202
|
-
for (const h of x.toInject) {
|
|
203
|
-
if (!seen.has(h.checkpoint.checkpointId)) {
|
|
204
|
-
merged.push(h);
|
|
205
|
-
seen.add(h.checkpoint.checkpointId);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
const block = merged.length ? formatRecallBlock(merged) : "";
|
|
209
|
-
if (merged.length > 0) {
|
|
210
|
-
let sumTokens = 0;
|
|
211
|
-
for (const h of merged)
|
|
212
|
-
sumTokens += h.checkpoint.tokenEstimate;
|
|
213
|
-
runtime.rt.recallInjections += merged.length;
|
|
214
|
-
runtime.rt.cacheHitTokens += sumTokens;
|
|
215
|
-
incRecallInjected(merged.length, runtime.currentStateDir);
|
|
216
|
-
incCacheHitTokens(sumTokens, runtime.currentStateDir);
|
|
217
|
-
}
|
|
218
|
-
return {
|
|
219
|
-
toInject: merged,
|
|
220
|
-
report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
|
|
221
|
-
block,
|
|
222
|
-
empty: merged.length === 0,
|
|
223
|
-
// H1: merged cross-repo result reuses the same-repo pass's telemetry.
|
|
224
|
-
hydeInfo: sameRepo.hydeInfo,
|
|
225
|
-
recallMetrics: sameRepo.recallMetrics,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
return sameRepo; // cross-repo failure → same-repo only (non-fatal)
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Extract the live-window message texts from the session manager (Fix C),
|
|
234
|
-
* for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
|
|
235
|
-
* error so recall falls back to unbounded (still correct, just no dedupe).
|
|
236
|
-
* Mirrors recentUserQuery's use of sessionEntryToContextMessages.
|
|
237
|
-
*/
|
|
238
|
-
function extractLiveWindow(ctx) {
|
|
239
|
-
try {
|
|
240
|
-
const entries = ctx.sessionManager.getEntries();
|
|
241
|
-
const texts = [];
|
|
242
|
-
for (const e of entries) {
|
|
243
|
-
for (const m of sessionEntryToContextMessages(e)) {
|
|
244
|
-
const c = m.content;
|
|
245
|
-
if (typeof c === "string")
|
|
246
|
-
texts.push(c);
|
|
247
|
-
else if (Array.isArray(c))
|
|
248
|
-
texts.push(c.map((b) => b.text ?? "").join(" "));
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return texts;
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
return [];
|
|
255
|
-
}
|
|
256
|
-
}
|
|
9
|
+
export { doRecall, doRecallAsync, extractLiveWindow, } from "./recall/impl.js";
|
package/dist/src/config.js
CHANGED
|
@@ -110,6 +110,15 @@ export const RAG_HYDE_ENABLED = () => ragEnabled("MEGACOMPACT_HYDE");
|
|
|
110
110
|
/** Spec 1: vbrainstorm visual design migration for the dashboard. */
|
|
111
111
|
export const NEW_UI = () => ragEnabled("MEGACOMPACT_NEW_UI");
|
|
112
112
|
// ---------------------------------------------------------------------------
|
|
113
|
+
// 3WF-3 same-repo recall cosine floor. SEPARATE from the S17 cross-repo floor
|
|
114
|
+
// (config.crossRepoCosine, default 0.90 — stricter, cross-repo only). This is
|
|
115
|
+
// the same-repo floor the 3-source validator applies to the top winner. A low
|
|
116
|
+
// default (0.12) keeps recall permissive within a repo while still rejecting
|
|
117
|
+
// effectively-unrelated hits. Call-time read so tests can set the env per-test.
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
/** Same-repo recall cosine floor: top winner must be >= this to be injected. */
|
|
120
|
+
export const RECALL_MIN_COSINE = () => Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
113
122
|
// Vector-cortex flags + breaker constants (VC0A+). Positive sprint flags,
|
|
114
123
|
// default ON, `=0`/`_DISABLED` off. Re-exported from src/config/vector-cortex.ts
|
|
115
124
|
// so root consumers share one source of truth.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/failback/compact.ts — 3WF-2 candidate-veto + vote module (pure, advisory).
|
|
3
|
+
*
|
|
4
|
+
* The production bug this fixes: compaction "succeeded" (a checkpoint was
|
|
5
|
+
* persisted, `saved` grew) while the LIVE WINDOW (`currentTokens`) never
|
|
6
|
+
* shrank — because `saved` is a cumulative SQLite total, not the working-set
|
|
7
|
+
* delta. This module builds competing summary candidates (extractive vs
|
|
8
|
+
* cluster/raptor) and VOTES which one, if any, is worth replacing the
|
|
9
|
+
* supersede-only result. It is purely advisory/observational: it never mutates
|
|
10
|
+
* a checkpoint, never overwrites `result.summary`, and returning `null` means
|
|
11
|
+
* "keep the supersede-only result" — the caller must NOT substitute a summary.
|
|
12
|
+
*
|
|
13
|
+
* Pure: no store mutation, no I/O, no network, no console.*. pi-agnostic
|
|
14
|
+
* (imports only from src/). Designed for the 3WF umbrella flag gate at the
|
|
15
|
+
* extension layer (see extensions/mega-pipeline/compact/vote.ts).
|
|
16
|
+
*/
|
|
17
|
+
import { collectRecentUserRequests, summarizeMessages } from "../compact.js";
|
|
18
|
+
import { summarizeCluster } from "../dedup/raptor/summarizer.js";
|
|
19
|
+
import { estimateBlockTokens } from "../tokens.js";
|
|
20
|
+
/**
|
|
21
|
+
* Default floor (tokens of net reduction) below which a candidate vote is
|
|
22
|
+
* REJECTED, returning `null` (keep the supersede-only result).
|
|
23
|
+
*
|
|
24
|
+
* Rationale: a candidate that reduces the region by fewer than 1 token is not
|
|
25
|
+
* meaningfully smaller than the compacted region it would replace — swapping
|
|
26
|
+
* the supersede-only result for it buys nothing and only adds a (possibly
|
|
27
|
+
* less faithful) summary. The floor therefore requires the voted summary to be
|
|
28
|
+
* STRICTLY smaller than the compacted region. Set to 1 (minimally defensible:
|
|
29
|
+
* the summary must actually be smaller). Overridable via `opts.floor`.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_VOTE_FLOOR_TOKENS = 1;
|
|
32
|
+
/** Strip a trailing ellipsis/truncation marker from a needle before containment. */
|
|
33
|
+
function stripEllipsis(s) {
|
|
34
|
+
// collectRecentUserRequests truncates to 160 chars via compact.ts's truncate,
|
|
35
|
+
// which appends the U+2026 ellipsis when it cuts. Drop it for a fair test.
|
|
36
|
+
return s.replace(/…\s*$/u, "").trim();
|
|
37
|
+
}
|
|
38
|
+
/** Normalize for containment: collapse whitespace, lowercase. */
|
|
39
|
+
function normalize(s) {
|
|
40
|
+
return s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
/** True when `summary` contains the content of EVERY recent user request. */
|
|
43
|
+
export function signalPreserved(summary, messages) {
|
|
44
|
+
const requests = collectRecentUserRequests(messages, 3);
|
|
45
|
+
if (requests.length === 0)
|
|
46
|
+
return true; // nothing to preserve
|
|
47
|
+
const haystack = normalize(summary);
|
|
48
|
+
return requests.every((r) => haystack.includes(normalize(stripEllipsis(r))));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the two competing candidates (extractive + cluster/raptor) for a
|
|
52
|
+
* compacted message region. Degenerate (empty/whitespace-only) summaries are
|
|
53
|
+
* VETOED — never returned. Both candidates use estimateBlockTokens(summary) for
|
|
54
|
+
* a single consistent token basis so the vote compares like with like (the
|
|
55
|
+
* cluster path's own tokenEstimate is intentionally ignored for fairness).
|
|
56
|
+
*/
|
|
57
|
+
export function buildCandidates(messages) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const extractive = summarizeMessages(messages);
|
|
60
|
+
if (extractive.trim().length > 0) {
|
|
61
|
+
out.push({
|
|
62
|
+
source: "extractive",
|
|
63
|
+
summary: extractive,
|
|
64
|
+
tokenEstimate: estimateBlockTokens(extractive),
|
|
65
|
+
signalPreserved: signalPreserved(extractive, messages),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// summarizeCluster returns deterministic extractive when MEGACOMPACT_RAPTOR_MODEL
|
|
69
|
+
// is unset and the local-only Ollama variant when set — so using it makes the
|
|
70
|
+
// Ollama path an insertion that adds NO new LLM call site for the on-by-default
|
|
71
|
+
// extraction. No behavior change for the default config.
|
|
72
|
+
const cluster = summarizeCluster(messages).summary;
|
|
73
|
+
if (cluster.trim().length > 0) {
|
|
74
|
+
out.push({
|
|
75
|
+
source: "cluster",
|
|
76
|
+
summary: cluster,
|
|
77
|
+
tokenEstimate: estimateBlockTokens(cluster),
|
|
78
|
+
signalPreserved: signalPreserved(cluster, messages),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Vote the best candidate. `score = reduction * (signalPreserved ? 1 : 0.5)`
|
|
85
|
+
* where `reduction = tokensBefore - candidate.tokenEstimate`. Ties resolve to
|
|
86
|
+
* the EARLIER (extractive) candidate for determinism. Returns `null` when the
|
|
87
|
+
* winner's score is below `opts.floor` (default DEFAULT_VOTE_FLOOR_TOKENS) —
|
|
88
|
+
* caller MUST keep the supersede-only result and must NOT substitute a summary.
|
|
89
|
+
*/
|
|
90
|
+
export function voteCandidate(messages, tokensBefore, opts = {}) {
|
|
91
|
+
const floor = opts.floor ?? DEFAULT_VOTE_FLOOR_TOKENS;
|
|
92
|
+
const candidates = buildCandidates(messages);
|
|
93
|
+
let best = null;
|
|
94
|
+
let bestScore = -Infinity;
|
|
95
|
+
for (const c of candidates) {
|
|
96
|
+
const reduction = tokensBefore - c.tokenEstimate;
|
|
97
|
+
const score = reduction * (c.signalPreserved ? 1 : 0.5);
|
|
98
|
+
// Earlier candidate wins ties (strict > keeps insertion order = extractive first).
|
|
99
|
+
if (score > bestScore) {
|
|
100
|
+
bestScore = score;
|
|
101
|
+
best = c;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (best === null)
|
|
105
|
+
return null;
|
|
106
|
+
if (bestScore < floor)
|
|
107
|
+
return null;
|
|
108
|
+
return best;
|
|
109
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall/readonly.ts — read-only recall variant (3WF-3 Source A).
|
|
3
|
+
*
|
|
4
|
+
* A pure search+rank seam wrapping `engine.recall`'s RAW `hits` path. It is the
|
|
5
|
+
* canonical read-only entry point going forward (triggerGuard.ts still inlines
|
|
6
|
+
* `recall(...).hits` for its own need; this module is additive and does NOT
|
|
7
|
+
* refactor it).
|
|
8
|
+
*
|
|
9
|
+
* HARD contract (QA): this module MUST NOT call `vectorMarkInjected`, must NOT
|
|
10
|
+
* write any turn/recall rows, and must NOT emit S43 telemetry. It only searches
|
|
11
|
+
* and returns hits for the vote. RecallAndInline's inject loop is the ONLY place
|
|
12
|
+
* the injected-set is mutated; keying the vote on raw `hits` (skipInjected:false
|
|
13
|
+
* => hits === newHits) is deliberate — `newHits` is post-`skipInjected` filter,
|
|
14
|
+
* which would distort overlap appearance.
|
|
15
|
+
*
|
|
16
|
+
* Non-fatal: any failure returns [] so the caller degrades to other sources.
|
|
17
|
+
* Pi-agnostic: no pi runtime imports.
|
|
18
|
+
*/
|
|
19
|
+
import { recall } from "../engine.js";
|
|
20
|
+
/**
|
|
21
|
+
* Raw, read-only recall hits for the 3-source vote. Returns `engine.recall`'s
|
|
22
|
+
* RAW `.hits` (skipInjected:false => equals the unfiltered vector result). No
|
|
23
|
+
* injected-set mutation, no turn writes, no telemetry. Returns [] on failure.
|
|
24
|
+
*/
|
|
25
|
+
export function recallRawHits(opts, store) {
|
|
26
|
+
try {
|
|
27
|
+
const result = recall({
|
|
28
|
+
sessionId: opts.sessionId,
|
|
29
|
+
query: opts.query,
|
|
30
|
+
limit: opts.limit ?? 3,
|
|
31
|
+
skipInjected: false,
|
|
32
|
+
}, store);
|
|
33
|
+
return result.hits;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Non-fatal: never break the agent loop. Degrade to other sources.
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/recall/recall3wf.fixture.ts — shared fixtures for the 3WF-3 recall tests.
|
|
3
|
+
*
|
|
4
|
+
* Split out of recall3wf.test.ts (which crossed the src 300 soft cap) so each
|
|
5
|
+
* test file stays under the limit. These are REAL fixtures, not mocks/stubs:
|
|
6
|
+
* a REAL VectorStore over a temp stateDir, REAL checkpoints persisted via
|
|
7
|
+
* compactSession, and readers that go through the SAME working path the
|
|
8
|
+
* extension uses (recallRawHits -> vectorSearch -> listCheckpoints, and
|
|
9
|
+
* vectorWasInjected), mirroring the proven triggerGuard test pattern.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdtempSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { VectorStore } from "../vectorStore.js";
|
|
15
|
+
import { compactSession } from "../engine.js";
|
|
16
|
+
import { recallAndInline } from "../recall.js";
|
|
17
|
+
import { recallRawHits } from "./readonly.js";
|
|
18
|
+
import { openStore } from "../store/sqlite/utils.js";
|
|
19
|
+
import { initSchema } from "../store/sqlite/schema.js";
|
|
20
|
+
/** Real EngineMessage fixture. */
|
|
21
|
+
export function msg(role, text) {
|
|
22
|
+
return { role, text };
|
|
23
|
+
}
|
|
24
|
+
/** Fresh isolated state dir per VectorStore. */
|
|
25
|
+
export function freshStore() {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "mc-3wf-"));
|
|
27
|
+
return { store: new VectorStore({ dedupSim: 0.9, stateDir: dir }), dir };
|
|
28
|
+
}
|
|
29
|
+
/** Persist N distinct checkpoints with distinct content + ascending timestamps. */
|
|
30
|
+
export function seed(store, topics, sid = "sess_3wf") {
|
|
31
|
+
topics.forEach((t, i) => {
|
|
32
|
+
compactSession({
|
|
33
|
+
sessionId: sid,
|
|
34
|
+
messages: [msg("user", t), msg("assistant", "ok")],
|
|
35
|
+
keepFrom: 2,
|
|
36
|
+
timestamp: i + 1,
|
|
37
|
+
}, store);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Checkpoint ids via the real search path (vectorSearch -> listCheckpoints). */
|
|
41
|
+
export function checkpointIds(store, sid, query) {
|
|
42
|
+
return recallRawHits({ sessionId: sid, query, limit: 10 }, store).map((h) => h.checkpoint.checkpointId);
|
|
43
|
+
}
|
|
44
|
+
/** Run the real recallAndInline path with skipInjected:false so nothing is
|
|
45
|
+
* marked and the block reflects the search result exactly (deterministic). */
|
|
46
|
+
export function recallAndInlineCapture(sid, query, store) {
|
|
47
|
+
const r = recallAndInline({ sessionId: sid, query, limit: 3, source: "command", skipInjected: false, windowDedupe: false }, store);
|
|
48
|
+
return { block: r.block, empty: r.empty, toInject: r.toInject };
|
|
49
|
+
}
|
|
50
|
+
/** Count recall-provenance rows (turn_recall) for a session via raw SQL reader. */
|
|
51
|
+
export function countTurnRecallRows(store, sid) {
|
|
52
|
+
try {
|
|
53
|
+
const reader = openStore(store.stateDir);
|
|
54
|
+
// Ensure the turns/turn_recall tables exist so a 0-count is meaningful
|
|
55
|
+
// (a write on the new path would be visible, not masked by a missing table).
|
|
56
|
+
initSchema(reader);
|
|
57
|
+
const row = reader
|
|
58
|
+
.prepare(`SELECT COUNT(*) AS n FROM turn_recall tr
|
|
59
|
+
JOIN turns t ON t.id = tr.turn_id
|
|
60
|
+
WHERE t.session_id = ?`)
|
|
61
|
+
.get(sid);
|
|
62
|
+
return row?.n ?? 0;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall/validator.ts — independent candidate validator (3WF-3).
|
|
3
|
+
*
|
|
4
|
+
* Judges candidates handed to it; it MUST NOT call any search itself. Given the
|
|
5
|
+
* ranked vote winners + the live-window text (already extracted by the caller,
|
|
6
|
+
* since src/ cannot import pi types), it walks the winners in order and returns
|
|
7
|
+
* the first that passes BOTH gates:
|
|
8
|
+
*
|
|
9
|
+
* 1. Cosine floor: the winner's score >= the same-repo floor (default 0.12,
|
|
10
|
+
* env MEGACOMPACT_RECALL_MIN_COSINE). The cross-repo 0.90 floor
|
|
11
|
+
* (config.crossRepoCosine) is SEPARATE and intentionally untouched.
|
|
12
|
+
* 2. Not already resident in the live window: reuse recall/sync.ts's exact
|
|
13
|
+
* comparison — embed each live message, embed the checkpoint summary, and
|
|
14
|
+
* treat the checkpoint as resident when cosineSimilarity >= dedupSim. We
|
|
15
|
+
* reuse that metric rather than inventing a new one.
|
|
16
|
+
*
|
|
17
|
+
* On a failing candidate it advances to the next-ranked winner. If ALL fail it
|
|
18
|
+
* returns the provenance floor (FloorBlock built from the newest checkpoint —
|
|
19
|
+
* pure over checkpoints, same semantics as triggerGuard's buildFloorBlock).
|
|
20
|
+
*
|
|
21
|
+
* Non-fatal throughout: any error degrades to the next candidate / the floor.
|
|
22
|
+
* Pi-agnostic: no pi runtime imports.
|
|
23
|
+
*/
|
|
24
|
+
import { defaultEmbedder, cosineSimilarity } from "../embedder.js";
|
|
25
|
+
// SQLite store, NOT src/store.ts's legacy gzipped-JSON DR reader (that returns
|
|
26
|
+
// [] for live sessions). Mirrors vector-search.ts / tieredRouter.ts.
|
|
27
|
+
import { listCheckpoints } from "../store/sqlite.js";
|
|
28
|
+
import { RECALL_MIN_COSINE } from "../config.js";
|
|
29
|
+
/** Build the provenance floor block from the session's newest checkpoint. */
|
|
30
|
+
function buildFloorBlock(sessionId, store) {
|
|
31
|
+
try {
|
|
32
|
+
const cps = listCheckpoints(sessionId, store.stateDir).filter((c) => c.dedupStatus !== "removed");
|
|
33
|
+
let newest = cps[0];
|
|
34
|
+
for (const cp of cps) {
|
|
35
|
+
if (!newest || (cp.timestamp ?? 0) > (newest.timestamp ?? 0))
|
|
36
|
+
newest = cp;
|
|
37
|
+
}
|
|
38
|
+
const summary = newest?.summary?.trim();
|
|
39
|
+
if (summary) {
|
|
40
|
+
return {
|
|
41
|
+
text: "The following compacted context is the most recent checkpoint from " +
|
|
42
|
+
"this session (recall found no query-relevant match):\n\n" + summary,
|
|
43
|
+
basis: "lastCheckpoint",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
text: "This session has compacted context but recall could not surface a " +
|
|
48
|
+
"checkpoint relevant to the current request; the most recent checkpoint " +
|
|
49
|
+
"summary is unavailable.",
|
|
50
|
+
basis: "lastCheckpoint",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {
|
|
55
|
+
text: "This session has compacted context but recall could not surface a " +
|
|
56
|
+
"checkpoint relevant to the current request.",
|
|
57
|
+
basis: "none",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate the ranked vote winners, returning the first that passes both gates,
|
|
63
|
+
* or the provenance floor if none do. Does NOT mutate the injected set, does NOT
|
|
64
|
+
* write turns, does NOT emit telemetry. Non-fatal.
|
|
65
|
+
*/
|
|
66
|
+
export function validateRecall(winners, opts, store) {
|
|
67
|
+
const floor = RECALL_MIN_COSINE();
|
|
68
|
+
const dedupSim = opts.dedupSim ?? 0.9;
|
|
69
|
+
const embedder = defaultEmbedder();
|
|
70
|
+
const liveVecs = (opts.liveWindow ?? []).map((m) => embedder.embed(m));
|
|
71
|
+
// One checkpoint read for the whole pass (both gates share it).
|
|
72
|
+
const cps = listCheckpoints(opts.sessionId, store.stateDir);
|
|
73
|
+
const cpById = new Map(cps.map((c) => [c.checkpointId, c]));
|
|
74
|
+
const queryVec = opts.query ? embedder.embed(opts.query) : null;
|
|
75
|
+
for (const cand of winners) {
|
|
76
|
+
try {
|
|
77
|
+
const cp = cpById.get(cand.checkpointId);
|
|
78
|
+
// Gate 1: same-repo COSINE floor. `cand.score` is only a cosine for
|
|
79
|
+
// source "vector"; fts5 (BM25) and recency (freshness rank) live on
|
|
80
|
+
// other scales, so for those we re-derive the true cosine locally from
|
|
81
|
+
// the query + checkpoint embedding. No search call is made.
|
|
82
|
+
let cosine;
|
|
83
|
+
if (cand.source === "vector") {
|
|
84
|
+
cosine = cand.score;
|
|
85
|
+
}
|
|
86
|
+
else if (queryVec && cp) {
|
|
87
|
+
cosine = cosineSimilarity(queryVec, embedder.embed(cp.summary));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// No comparable cosine available => cannot clear a cosine gate.
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (cosine < floor)
|
|
94
|
+
continue;
|
|
95
|
+
// Gate 2: not already resident in the live window.
|
|
96
|
+
if (liveVecs.length > 0) {
|
|
97
|
+
if (!cp)
|
|
98
|
+
continue; // cannot verify => skip rather than risk re-inject
|
|
99
|
+
const hitVec = embedder.embed(cp.summary);
|
|
100
|
+
const resident = liveVecs.some((v) => cosineSimilarity(v, hitVec) >= dedupSim);
|
|
101
|
+
if (resident)
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
return { kind: "candidate", candidate: cand };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Non-fatal: skip this candidate, try the next.
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// All candidates rejected -> provenance floor.
|
|
112
|
+
return { kind: "floor", floor: buildFloorBlock(opts.sessionId, store) };
|
|
113
|
+
}
|