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.
@@ -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
- const { newHits } = searchRecall({ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: skip }, store);
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.6",
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
- const { newHits } = searchRecall(
193
- { sessionId: opts.sessionId, query: opts.query, limit, skipInjected: skip },
194
- store,
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(