pi-mega-compact 0.11.6 → 0.11.7
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/src/recall.js +136 -1
- package/package.json +1 -1
- package/src/recall.ts +168 -4
package/dist/src/recall.js
CHANGED
|
@@ -20,6 +20,19 @@ import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
|
|
|
20
20
|
import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
|
|
21
21
|
import { maxCheckpointTimestamp } from "./store/sqlite.js";
|
|
22
22
|
import { normalizeSessionId } from "./store.js";
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// S57 RAG suite feature flags + module imports. Each flag defaults OFF — the
|
|
25
|
+
// flag-OFF path below is byte-identical to the pre-S57 recall path (recallQuery
|
|
26
|
+
// === opts.query, the single searchRecall call), so the helpers are only reached
|
|
27
|
+
// when their flag is ON. Wiring is additive; the proven v0.11.5 behavior is the
|
|
28
|
+
// default.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
import { RAG_QUERY_REFORMULATION, RAG_TIERED_ROUTER, RAG_RECALL_METRICS } from "./config.js";
|
|
31
|
+
import { reformulateQuery, isVagueQuery } from "./queryReformulation.js";
|
|
32
|
+
import { VAGUE_MIN_WORDS, VAGUE_VERY_SHORT_WORDS } from "./queryReformulation/cache.js";
|
|
33
|
+
import { getTieredRouter } from "./tieredRouter.js";
|
|
34
|
+
import { computeRecallMetrics } from "./recallMetrics.js";
|
|
35
|
+
import { Logger } from "./log.js";
|
|
23
36
|
/** Wrap a recall block so the model reads it as restored compacted context. */
|
|
24
37
|
export function formatRecallBlock(hits) {
|
|
25
38
|
if (hits.length === 0)
|
|
@@ -72,6 +85,107 @@ export function formatRaptorBlock(nodes) {
|
|
|
72
85
|
"session so far. Use it as a map of what has been covered:\n\n" +
|
|
73
86
|
parts.join("\n"));
|
|
74
87
|
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// S57 RAG suite helpers (B1/B2/B3). Each is only called when its feature flag
|
|
90
|
+
// is ON; flag-OFF never reaches them. All degrade non-fatally to the standard
|
|
91
|
+
// path on any error.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
/**
|
|
94
|
+
* B1: If the query is vague, reformulate it via embedding-neighbor TF-IDF
|
|
95
|
+
* expansion. Returns the original query when reformulation is not supported
|
|
96
|
+
* or the query is already specific. Non-fatal: any error → original query.
|
|
97
|
+
*/
|
|
98
|
+
function reformulateRecallQuery(query, store, sessionId) {
|
|
99
|
+
try {
|
|
100
|
+
if (!isVagueQuery(query, {
|
|
101
|
+
vagueMinWords: VAGUE_MIN_WORDS,
|
|
102
|
+
vagueVeryShortWords: VAGUE_VERY_SHORT_WORDS,
|
|
103
|
+
}))
|
|
104
|
+
return query;
|
|
105
|
+
// Pre-compute neighbor candidates once. reformulateQuery embeds the query
|
|
106
|
+
// and calls `scan` internally; we return these pre-computed neighbors (same
|
|
107
|
+
// query) so the store needs no by-embedding lookup.
|
|
108
|
+
const { hits } = searchRecall({ sessionId, query, limit: 10, skipInjected: false }, store);
|
|
109
|
+
if (hits.length < 2)
|
|
110
|
+
return query; // too few neighbors for TF-IDF
|
|
111
|
+
const neighbors = hits.map((h) => ({
|
|
112
|
+
id: h.checkpoint.checkpointId,
|
|
113
|
+
score: h.score,
|
|
114
|
+
}));
|
|
115
|
+
const textById = new Map();
|
|
116
|
+
for (const h of hits)
|
|
117
|
+
textById.set(h.checkpoint.checkpointId, h.checkpoint.summary);
|
|
118
|
+
const scan = () => neighbors;
|
|
119
|
+
const corpus = {
|
|
120
|
+
totalDocs: neighbors.length,
|
|
121
|
+
docFreq: (term) => {
|
|
122
|
+
let df = 0;
|
|
123
|
+
for (const text of textById.values()) {
|
|
124
|
+
if (text.toLowerCase().includes(term.toLowerCase()))
|
|
125
|
+
df++;
|
|
126
|
+
}
|
|
127
|
+
return Math.max(df, 1);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const neighborTexts = (ids) => ids.map((id) => ({ id, text: textById.get(id) ?? "" }));
|
|
131
|
+
const { result } = reformulateQuery(query, defaultEmbedder(), scan, corpus, neighborTexts);
|
|
132
|
+
return result.expanded.length > query.length ? result.expanded : query;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return query; // non-fatal: fall back to original query
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* B2: Synchronous tiered recall. The TieredRouter.route() is async, so the
|
|
140
|
+
* sync path can only peek the L0 in-memory cache; on miss it falls through to
|
|
141
|
+
* the standard sync vector scan. The router's L1/L2 value is realized on the
|
|
142
|
+
* async path (recallAndInlineAsync). Non-fatal.
|
|
143
|
+
*/
|
|
144
|
+
function runTieredRecall(query, sessionId, limit, skip, store) {
|
|
145
|
+
try {
|
|
146
|
+
const router = getTieredRouter();
|
|
147
|
+
if (!router) {
|
|
148
|
+
const result = searchRecall({ sessionId, query, limit, skipInjected: skip }, store);
|
|
149
|
+
return { newHits: result.newHits, tier: "off" };
|
|
150
|
+
}
|
|
151
|
+
// Peek the L0 in-memory cache synchronously; fall through on miss.
|
|
152
|
+
const cached = router.peekCache(sessionId, query, limit);
|
|
153
|
+
if (cached && cached.length > 0)
|
|
154
|
+
return { newHits: cached, tier: "L0-cache" };
|
|
155
|
+
const result = searchRecall({ sessionId, query, limit, skipInjected: skip }, store);
|
|
156
|
+
return { newHits: result.newHits, tier: "sync-fallback" };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
const result = searchRecall({ sessionId, query, limit, skipInjected: skip }, store);
|
|
160
|
+
return { newHits: result.newHits, tier: "fallback-error" };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* B3: Compute recall-quality metrics on the injected hits and log them.
|
|
165
|
+
* Only called when RAG_RECALL_METRICS() is ON. Non-fatal.
|
|
166
|
+
*/
|
|
167
|
+
function scoreAndLogRecallMetrics(query, toInject) {
|
|
168
|
+
try {
|
|
169
|
+
const logger = new Logger();
|
|
170
|
+
const metrics = computeRecallMetrics(query, toInject);
|
|
171
|
+
logger.info("recall_metrics", {
|
|
172
|
+
hitCount: toInject.length,
|
|
173
|
+
score: metrics.score,
|
|
174
|
+
pass: metrics.pass,
|
|
175
|
+
});
|
|
176
|
+
if (!metrics.pass && toInject.length > 0) {
|
|
177
|
+
logger.info("recall_metrics_low_quality", {
|
|
178
|
+
score: metrics.score,
|
|
179
|
+
relevance: metrics.breakdown.relevance,
|
|
180
|
+
coverage: metrics.breakdown.coverage,
|
|
181
|
+
diversity: metrics.breakdown.diversity,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* non-fatal: metrics never break recall */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
75
189
|
/**
|
|
76
190
|
* Run the unified recall+dudupe+prepare-inject pipeline. Does NOT touch pi;
|
|
77
191
|
* it records injections via `markInjected` so the next call dedupes. The
|
|
@@ -123,7 +237,22 @@ export function recallAndInline(opts, store) {
|
|
|
123
237
|
// F4: thread skipInjected through to searchRecall instead of hardcoding false
|
|
124
238
|
// and re-implementing the filter here. newHits is already deduped when skip
|
|
125
239
|
// is true (default); equals hits when skip is false (openclaw command path).
|
|
126
|
-
|
|
240
|
+
//
|
|
241
|
+
// S57 B1: optionally expand a vague query via TF-IDF neighbor terms.
|
|
242
|
+
// S57 B2: optionally route via the TieredRouter's sync L0 cache peek.
|
|
243
|
+
// Both flags default OFF — recallQuery === opts.query and the single
|
|
244
|
+
// searchRecall call reproduce the pre-S57 byte-identical path.
|
|
245
|
+
const recallQuery = RAG_QUERY_REFORMULATION()
|
|
246
|
+
? reformulateRecallQuery(opts.query, store, opts.sessionId)
|
|
247
|
+
: opts.query;
|
|
248
|
+
let newHits;
|
|
249
|
+
if (RAG_TIERED_ROUTER()) {
|
|
250
|
+
newHits = runTieredRecall(recallQuery, opts.sessionId, limit, skip, store).newHits;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const result = searchRecall({ sessionId: opts.sessionId, query: recallQuery, limit, skipInjected: skip }, store);
|
|
254
|
+
newHits = result.newHits;
|
|
255
|
+
}
|
|
127
256
|
// F1: hoist one embedder instance for inline dedupe (matches the async path).
|
|
128
257
|
// defaultEmbedder() is deterministic but creating it per hit wastes allocations.
|
|
129
258
|
const embedder = defaultEmbedder();
|
|
@@ -154,6 +283,12 @@ export function recallAndInline(opts, store) {
|
|
|
154
283
|
blockTokens += partTokens;
|
|
155
284
|
vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
|
|
156
285
|
}
|
|
286
|
+
// S57 B3: optionally score + log recall quality metrics (flag-OFF: skipped,
|
|
287
|
+
// byte-identical). Scores the injected hits against the ORIGINAL query so the
|
|
288
|
+
// metric measures whether the (possibly expanded) search results stay
|
|
289
|
+
// relevant to what the user actually asked.
|
|
290
|
+
if (RAG_RECALL_METRICS())
|
|
291
|
+
scoreAndLogRecallMetrics(opts.query, toInject);
|
|
157
292
|
// F3: format once — one preamble, correct [1..n] numbering.
|
|
158
293
|
const recallBlock = toInject.length > 0 ? formatRecallBlock(toInject) : "";
|
|
159
294
|
const report = toInject.map((h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.7",
|
|
4
4
|
"description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-3-Clause",
|
package/src/recall.ts
CHANGED
|
@@ -28,6 +28,21 @@ import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
|
|
|
28
28
|
import { maxCheckpointTimestamp } from "./store/sqlite.js";
|
|
29
29
|
import { normalizeSessionId } from "./store.js";
|
|
30
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// S57 RAG suite feature flags + module imports. Each flag defaults OFF — the
|
|
33
|
+
// flag-OFF path below is byte-identical to the pre-S57 recall path (recallQuery
|
|
34
|
+
// === opts.query, the single searchRecall call), so the helpers are only reached
|
|
35
|
+
// when their flag is ON. Wiring is additive; the proven v0.11.5 behavior is the
|
|
36
|
+
// default.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
import { RAG_QUERY_REFORMULATION, RAG_TIERED_ROUTER, RAG_RECALL_METRICS } from "./config.js";
|
|
39
|
+
import { reformulateQuery, isVagueQuery } from "./queryReformulation.js";
|
|
40
|
+
import type { CorpusStats, EmbedderLike, NeighborScanner } from "./queryReformulation.js";
|
|
41
|
+
import { VAGUE_MIN_WORDS, VAGUE_VERY_SHORT_WORDS } from "./queryReformulation/cache.js";
|
|
42
|
+
import { getTieredRouter } from "./tieredRouter.js";
|
|
43
|
+
import { computeRecallMetrics } from "./recallMetrics.js";
|
|
44
|
+
import { Logger } from "./log.js";
|
|
45
|
+
|
|
31
46
|
export type RecallSource = "resume" | "command" | "sentinel";
|
|
32
47
|
|
|
33
48
|
export interface RecallInjectOptions {
|
|
@@ -133,6 +148,135 @@ export function formatRaptorBlock(
|
|
|
133
148
|
);
|
|
134
149
|
}
|
|
135
150
|
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// S57 RAG suite helpers (B1/B2/B3). Each is only called when its feature flag
|
|
153
|
+
// is ON; flag-OFF never reaches them. All degrade non-fatally to the standard
|
|
154
|
+
// path on any error.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* B1: If the query is vague, reformulate it via embedding-neighbor TF-IDF
|
|
159
|
+
* expansion. Returns the original query when reformulation is not supported
|
|
160
|
+
* or the query is already specific. Non-fatal: any error → original query.
|
|
161
|
+
*/
|
|
162
|
+
function reformulateRecallQuery(
|
|
163
|
+
query: string,
|
|
164
|
+
store: VectorStore,
|
|
165
|
+
sessionId: string,
|
|
166
|
+
): string {
|
|
167
|
+
try {
|
|
168
|
+
if (
|
|
169
|
+
!isVagueQuery(query, {
|
|
170
|
+
vagueMinWords: VAGUE_MIN_WORDS,
|
|
171
|
+
vagueVeryShortWords: VAGUE_VERY_SHORT_WORDS,
|
|
172
|
+
})
|
|
173
|
+
)
|
|
174
|
+
return query;
|
|
175
|
+
// Pre-compute neighbor candidates once. reformulateQuery embeds the query
|
|
176
|
+
// and calls `scan` internally; we return these pre-computed neighbors (same
|
|
177
|
+
// query) so the store needs no by-embedding lookup.
|
|
178
|
+
const { hits } = searchRecall(
|
|
179
|
+
{ sessionId, query, limit: 10, skipInjected: false },
|
|
180
|
+
store,
|
|
181
|
+
);
|
|
182
|
+
if (hits.length < 2) return query; // too few neighbors for TF-IDF
|
|
183
|
+
const neighbors = hits.map((h) => ({
|
|
184
|
+
id: h.checkpoint.checkpointId,
|
|
185
|
+
score: h.score,
|
|
186
|
+
}));
|
|
187
|
+
const textById = new Map<string, string>();
|
|
188
|
+
for (const h of hits) textById.set(h.checkpoint.checkpointId, h.checkpoint.summary);
|
|
189
|
+
const scan: NeighborScanner = () => neighbors;
|
|
190
|
+
const corpus: CorpusStats = {
|
|
191
|
+
totalDocs: neighbors.length,
|
|
192
|
+
docFreq: (term: string) => {
|
|
193
|
+
let df = 0;
|
|
194
|
+
for (const text of textById.values()) {
|
|
195
|
+
if (text.toLowerCase().includes(term.toLowerCase())) df++;
|
|
196
|
+
}
|
|
197
|
+
return Math.max(df, 1);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
const neighborTexts = (ids: string[]) =>
|
|
201
|
+
ids.map((id) => ({ id, text: textById.get(id) ?? "" }));
|
|
202
|
+
const { result } = reformulateQuery(
|
|
203
|
+
query,
|
|
204
|
+
defaultEmbedder() as unknown as EmbedderLike,
|
|
205
|
+
scan,
|
|
206
|
+
corpus,
|
|
207
|
+
neighborTexts,
|
|
208
|
+
);
|
|
209
|
+
return result.expanded.length > query.length ? result.expanded : query;
|
|
210
|
+
} catch {
|
|
211
|
+
return query; // non-fatal: fall back to original query
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* B2: Synchronous tiered recall. The TieredRouter.route() is async, so the
|
|
217
|
+
* sync path can only peek the L0 in-memory cache; on miss it falls through to
|
|
218
|
+
* the standard sync vector scan. The router's L1/L2 value is realized on the
|
|
219
|
+
* async path (recallAndInlineAsync). Non-fatal.
|
|
220
|
+
*/
|
|
221
|
+
function runTieredRecall(
|
|
222
|
+
query: string,
|
|
223
|
+
sessionId: string,
|
|
224
|
+
limit: number,
|
|
225
|
+
skip: boolean,
|
|
226
|
+
store: VectorStore,
|
|
227
|
+
): { newHits: SearchHit[]; tier: string } {
|
|
228
|
+
try {
|
|
229
|
+
const router = getTieredRouter();
|
|
230
|
+
if (!router) {
|
|
231
|
+
const result = searchRecall(
|
|
232
|
+
{ sessionId, query, limit, skipInjected: skip },
|
|
233
|
+
store,
|
|
234
|
+
);
|
|
235
|
+
return { newHits: result.newHits, tier: "off" };
|
|
236
|
+
}
|
|
237
|
+
// Peek the L0 in-memory cache synchronously; fall through on miss.
|
|
238
|
+
const cached = router.peekCache(sessionId, query, limit);
|
|
239
|
+
if (cached && cached.length > 0) return { newHits: cached, tier: "L0-cache" };
|
|
240
|
+
const result = searchRecall(
|
|
241
|
+
{ sessionId, query, limit, skipInjected: skip },
|
|
242
|
+
store,
|
|
243
|
+
);
|
|
244
|
+
return { newHits: result.newHits, tier: "sync-fallback" };
|
|
245
|
+
} catch {
|
|
246
|
+
const result = searchRecall(
|
|
247
|
+
{ sessionId, query, limit, skipInjected: skip },
|
|
248
|
+
store,
|
|
249
|
+
);
|
|
250
|
+
return { newHits: result.newHits, tier: "fallback-error" };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* B3: Compute recall-quality metrics on the injected hits and log them.
|
|
256
|
+
* Only called when RAG_RECALL_METRICS() is ON. Non-fatal.
|
|
257
|
+
*/
|
|
258
|
+
function scoreAndLogRecallMetrics(query: string, toInject: SearchHit[]): void {
|
|
259
|
+
try {
|
|
260
|
+
const logger = new Logger();
|
|
261
|
+
const metrics = computeRecallMetrics(query, toInject);
|
|
262
|
+
logger.info("recall_metrics", {
|
|
263
|
+
hitCount: toInject.length,
|
|
264
|
+
score: metrics.score,
|
|
265
|
+
pass: metrics.pass,
|
|
266
|
+
});
|
|
267
|
+
if (!metrics.pass && toInject.length > 0) {
|
|
268
|
+
logger.info("recall_metrics_low_quality", {
|
|
269
|
+
score: metrics.score,
|
|
270
|
+
relevance: metrics.breakdown.relevance,
|
|
271
|
+
coverage: metrics.breakdown.coverage,
|
|
272
|
+
diversity: metrics.breakdown.diversity,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* non-fatal: metrics never break recall */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
136
280
|
/**
|
|
137
281
|
* Run the unified recall+dudupe+prepare-inject pipeline. Does NOT touch pi;
|
|
138
282
|
* it records injections via `markInjected` so the next call dedupes. The
|
|
@@ -189,10 +333,24 @@ export function recallAndInline(
|
|
|
189
333
|
// F4: thread skipInjected through to searchRecall instead of hardcoding false
|
|
190
334
|
// and re-implementing the filter here. newHits is already deduped when skip
|
|
191
335
|
// is true (default); equals hits when skip is false (openclaw command path).
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
336
|
+
//
|
|
337
|
+
// S57 B1: optionally expand a vague query via TF-IDF neighbor terms.
|
|
338
|
+
// S57 B2: optionally route via the TieredRouter's sync L0 cache peek.
|
|
339
|
+
// Both flags default OFF — recallQuery === opts.query and the single
|
|
340
|
+
// searchRecall call reproduce the pre-S57 byte-identical path.
|
|
341
|
+
const recallQuery = RAG_QUERY_REFORMULATION()
|
|
342
|
+
? reformulateRecallQuery(opts.query, store, opts.sessionId)
|
|
343
|
+
: opts.query;
|
|
344
|
+
let newHits: SearchHit[];
|
|
345
|
+
if (RAG_TIERED_ROUTER()) {
|
|
346
|
+
newHits = runTieredRecall(recallQuery, opts.sessionId, limit, skip, store).newHits;
|
|
347
|
+
} else {
|
|
348
|
+
const result = searchRecall(
|
|
349
|
+
{ sessionId: opts.sessionId, query: recallQuery, limit, skipInjected: skip },
|
|
350
|
+
store,
|
|
351
|
+
);
|
|
352
|
+
newHits = result.newHits;
|
|
353
|
+
}
|
|
196
354
|
|
|
197
355
|
// F1: hoist one embedder instance for inline dedupe (matches the async path).
|
|
198
356
|
// defaultEmbedder() is deterministic but creating it per hit wastes allocations.
|
|
@@ -228,6 +386,12 @@ export function recallAndInline(
|
|
|
228
386
|
vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
|
|
229
387
|
}
|
|
230
388
|
|
|
389
|
+
// S57 B3: optionally score + log recall quality metrics (flag-OFF: skipped,
|
|
390
|
+
// byte-identical). Scores the injected hits against the ORIGINAL query so the
|
|
391
|
+
// metric measures whether the (possibly expanded) search results stay
|
|
392
|
+
// relevant to what the user actually asked.
|
|
393
|
+
if (RAG_RECALL_METRICS()) scoreAndLogRecallMetrics(opts.query, toInject);
|
|
394
|
+
|
|
231
395
|
// F3: format once — one preamble, correct [1..n] numbering.
|
|
232
396
|
const recallBlock = toInject.length > 0 ? formatRecallBlock(toInject) : "";
|
|
233
397
|
const report = toInject.map(
|