clawmem 0.7.2 → 0.8.1

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.
@@ -69,6 +69,19 @@ const INSTRUCTION_TOKEN_COST = estimateTokens(INSTRUCTION_XML);
69
69
  const RELATIONSHIPS_XML_OVERHEAD_TOKENS = estimateTokens("<relationships>\n\n</relationships>");
70
70
  const MAX_RELATION_SNIPPETS = 10;
71
71
 
72
+ // Ext 6b: Multi-turn prior-query lookback
73
+ // The retrieval query is built from the current prompt plus up to
74
+ // MULTI_TURN_LOOKBACK recent same-session prior prompts within
75
+ // MULTI_TURN_MAX_AGE_MINUTES. The combined query is clamped to
76
+ // MULTI_TURN_MAX_CHARS with newest content preserved first — so the
77
+ // current prompt is always the first N chars even when older priors
78
+ // would otherwise push it out. All other hook signals (scoring,
79
+ // composite recency intent, recall attribution, routing hints)
80
+ // continue to use the raw current prompt.
81
+ const MULTI_TURN_LOOKBACK = 2;
82
+ const MULTI_TURN_MAX_AGE_MINUTES = 10;
83
+ const MULTI_TURN_MAX_CHARS = 2000;
84
+
72
85
  // File path patterns to extract from prompts (E13 replacement: file-aware UserPromptSubmit)
73
86
  const FILE_PATH_RE = /(?:^|\s)((?:\/[\w.@-]+)+(?:\.\w+)?|[\w.@-]+\.(?:ts|js|py|md|sh|yaml|yml|json|toml|rs|go|tsx|jsx|css|html))\b/g;
74
87
 
@@ -133,12 +146,25 @@ export async function contextSurfacing(
133
146
  const isRecency = hasRecencyIntent(prompt);
134
147
  const minScore = isRecency ? MIN_COMPOSITE_SCORE_RECENCY : profile.minScore;
135
148
 
149
+ // Ext 6b: Build the retrieval query from the current prompt plus up to
150
+ // MULTI_TURN_LOOKBACK recent same-session prior prompts. Used only for
151
+ // the discovery path (vector, FTS, query expansion, reranking) so that
152
+ // a short "do that" / "same for X" turn can inherit the vocabulary of
153
+ // earlier turns. All other prompt-dependent signals (recency intent,
154
+ // composite scoring, recall attribution, snippet highlighting, routing
155
+ // hints, dedupe, heartbeat check) continue to use the raw current
156
+ // prompt. If the session has no priors in the window, the helper
157
+ // returns the current prompt unchanged.
158
+ const retrievalQuery = input.sessionId
159
+ ? buildMultiTurnSurfacingQuery(store, input.sessionId, prompt)
160
+ : prompt;
161
+
136
162
  // Search: try vector first (if profile allows), fall back to BM25
137
163
  // When vector succeeds, also supplement with FTS for keyword-exact recall
138
164
  let results: SearchResult[] = [];
139
165
  if (profile.useVector) {
140
166
  try {
141
- const vectorPromise = store.searchVec(prompt, DEFAULT_EMBED_MODEL, maxResults);
167
+ const vectorPromise = store.searchVec(retrievalQuery, DEFAULT_EMBED_MODEL, maxResults);
142
168
  const timeoutPromise = new Promise<SearchResult[]>((_, reject) =>
143
169
  setTimeout(() => reject(new Error("vector timeout")), profile.vectorTimeout)
144
170
  );
@@ -149,11 +175,11 @@ export async function contextSurfacing(
149
175
  }
150
176
 
151
177
  if (results.length === 0) {
152
- results = store.searchFTS(prompt, maxResults);
178
+ results = store.searchFTS(retrievalQuery, maxResults);
153
179
  } else {
154
180
  // Supplement vector results with FTS for keyword-exact matches (<10ms)
155
181
  const seen = new Set(results.map(r => r.filepath));
156
- const ftsSupplemental = store.searchFTS(prompt, 5);
182
+ const ftsSupplemental = store.searchFTS(retrievalQuery, 5);
157
183
  for (const r of ftsSupplemental) {
158
184
  if (!seen.has(r.filepath)) {
159
185
  seen.add(r.filepath);
@@ -166,7 +192,7 @@ export async function contextSurfacing(
166
192
  if (getVaultPath("skill")) {
167
193
  try {
168
194
  const skillStore = resolveStore("skill");
169
- const skillResults = skillStore.searchFTS(prompt, 5);
195
+ const skillResults = skillStore.searchFTS(retrievalQuery, 5);
170
196
  // Tag skill vault results for identification in output
171
197
  for (const r of skillResults) {
172
198
  (r as any)._fromVault = "skill";
@@ -178,7 +204,9 @@ export async function contextSurfacing(
178
204
  }
179
205
 
180
206
  // File-aware supplemental search (E13 replacement): extract file paths/names from prompt
181
- // and run targeted FTS queries to surface file-specific vault context
207
+ // and run targeted FTS queries to surface file-specific vault context.
208
+ // File-path extraction stays on the raw current prompt so priors cannot
209
+ // pollute the file-specific discovery channel with stale filenames.
182
210
  const fileMatches = [...prompt.matchAll(FILE_PATH_RE)].map(m => m[1]!.trim()).filter(Boolean);
183
211
  if (fileMatches.length > 0) {
184
212
  const seen = new Set(results.map(r => r.filepath));
@@ -195,17 +223,23 @@ export async function contextSurfacing(
195
223
  }
196
224
  }
197
225
 
198
- if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
226
+ if (results.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
199
227
 
200
228
  // Budget-aware deep escalation (deep profile only):
201
229
  // If the fast path finished quickly and found results, spend remaining time budget
202
230
  // on query expansion (discovers new candidates) and cross-encoder reranking (reorders).
231
+ // Ext 6b: expansion + FTS variants use the multi-turn retrieval query so
232
+ // short current prompts still inherit prior-turn vocabulary. Reranking
233
+ // continues to use the RAW current prompt so relevance scoring is not
234
+ // diluted by older turns — the cross-encoder is asked "how well does
235
+ // this doc match the user's current question", not "how well does it
236
+ // match the last 10 minutes of questions".
203
237
  if (profile.deepEscalation && results.length >= 2) {
204
238
  const elapsed = Date.now() - startTime;
205
239
  if (elapsed < profile.escalationBudgetMs) {
206
240
  try {
207
241
  // Phase 1: Query expansion — discover candidates BM25+vector missed
208
- const expanded = await store.expandQuery(prompt, DEFAULT_QUERY_MODEL);
242
+ const expanded = await store.expandQuery(retrievalQuery, DEFAULT_QUERY_MODEL);
209
243
  if (expanded.length > 0) {
210
244
  const seen = new Set(results.map(r => r.filepath));
211
245
  for (const eq of expanded.slice(0, 3)) {
@@ -253,7 +287,7 @@ export async function contextSurfacing(
253
287
  !FILTERED_PATHS.some(p => r.displayPath.includes(p))
254
288
  );
255
289
 
256
- if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
290
+ if (results.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
257
291
 
258
292
  // Filter out snoozed documents
259
293
  const now = new Date();
@@ -269,7 +303,7 @@ export async function contextSurfacing(
269
303
  return true;
270
304
  });
271
305
 
272
- if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
306
+ if (results.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
273
307
 
274
308
  // Deduplicate by filepath (keep best score per path)
275
309
  const deduped = new Map<string, SearchResult>();
@@ -311,7 +345,7 @@ export async function contextSurfacing(
311
345
  : 0;
312
346
 
313
347
  // Activation floor: if even the best result is too weak, bail entirely
314
- if (bestScore < profile.activationFloor) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
348
+ if (bestScore < profile.activationFloor) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
315
349
 
316
350
  const adaptiveMin = Math.max(bestScore * profile.minScoreRatio, profile.absoluteFloor);
317
351
  scored = allScored.filter(r => r.compositeScore >= adaptiveMin);
@@ -320,7 +354,7 @@ export async function contextSurfacing(
320
354
  scored = allScored.filter(r => r.compositeScore >= minScore);
321
355
  }
322
356
 
323
- if (scored.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
357
+ if (scored.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
324
358
 
325
359
  // Spreading activation (E11): boost results co-activated with top HOT results
326
360
  if (scored.length > 3) {
@@ -369,7 +403,7 @@ export async function contextSurfacing(
369
403
  const { context, paths, tokens } = buildContext(scored, prompt, factsBudget);
370
404
 
371
405
  if (!context) {
372
- logEmptyTurn(store, input);
406
+ logEmptyTurn(store, input, prompt);
373
407
  return makeEmptyOutput("context-surfacing");
374
408
  }
375
409
 
@@ -377,8 +411,10 @@ export async function contextSurfacing(
377
411
  if (input.sessionId) {
378
412
  const turnIndex = (input as any)._turnIndex ?? 0;
379
413
 
380
- // Log the injection — returns usage_id for recall event linkage
381
- const usageId = logInjection(store, input.sessionId, "context-surfacing", paths, tokens, turnIndex);
414
+ // Log the injection — returns usage_id for recall event linkage.
415
+ // Ext 6b: persist the raw prompt as query_text so future turns in
416
+ // the same session can reconstitute a multi-turn retrieval query.
417
+ const usageId = logInjection(store, input.sessionId, "context-surfacing", paths, tokens, turnIndex, prompt);
382
418
 
383
419
  // Record recall events ONLY for docs that made it into the injected context
384
420
  // (post-budget). Docs trimmed by token budget were never seen by the model.
@@ -469,12 +505,21 @@ export async function contextSurfacing(
469
505
  * Log an empty context_usage row for a skipped turn.
470
506
  * Keeps turn_index aligned with transcript turns so per-turn recall
471
507
  * attribution doesn't drift when some prompts are gated.
508
+ *
509
+ * Ext 6b: `queryText` is optional. Callers that gated BEFORE the
510
+ * retrieval stage (slash commands, heartbeat dedupe, too-short prompts,
511
+ * `shouldSkipRetrieval`) pass nothing — those turns are not meaningful
512
+ * user questions and their raw text is not worth persisting for future
513
+ * multi-turn lookback. Callers that gated AFTER retrieval (empty result
514
+ * set, threshold filter, budget) pass the prompt so a follow-up turn
515
+ * can still reuse the intent even though the current turn surfaced
516
+ * nothing.
472
517
  */
473
- function logEmptyTurn(store: Store, input: HookInput): void {
518
+ function logEmptyTurn(store: Store, input: HookInput, queryText?: string): void {
474
519
  if (!input.sessionId) return;
475
520
  try {
476
521
  const turnIndex = (input as any)._turnIndex ?? 0;
477
- logInjection(store, input.sessionId, "context-surfacing", [], 0, turnIndex);
522
+ logInjection(store, input.sessionId, "context-surfacing", [], 0, turnIndex, queryText);
478
523
  } catch { /* non-fatal */ }
479
524
  }
480
525
 
@@ -700,6 +745,105 @@ export function buildVaultContextInner(
700
745
  return lines.join("\n");
701
746
  }
702
747
 
748
+ // =============================================================================
749
+ // Ext 6b: Multi-turn prior-query lookback
750
+ // =============================================================================
751
+
752
+ /**
753
+ * Build the retrieval query from the current prompt plus up to `lookback`
754
+ * recent prior prompts from the same session within `maxAgeMinutes`.
755
+ *
756
+ * Returns the current prompt unchanged when:
757
+ * - no `sessionId` (nothing to scope by)
758
+ * - the `query_text` column is missing (pre-migration store)
759
+ * - no prior rows within the window / all NULL
760
+ * - any DB error (fail-open — never throws)
761
+ *
762
+ * The combined query format is
763
+ * `<current>\n\n<newest prior>\n\n<older prior>...`
764
+ * truncated to `MULTI_TURN_MAX_CHARS` with **current content preserved
765
+ * first** — so even when older priors would push the current prompt
766
+ * past the char limit, the truncation drops the tail (older priors),
767
+ * not the head. This guarantees the retrieval query always contains the
768
+ * user's current question verbatim.
769
+ *
770
+ * Exported for direct unit testing.
771
+ */
772
+ export function buildMultiTurnSurfacingQuery(
773
+ store: Store,
774
+ sessionId: string,
775
+ currentQuery: string,
776
+ lookback: number = MULTI_TURN_LOOKBACK,
777
+ maxAgeMinutes: number = MULTI_TURN_MAX_AGE_MINUTES,
778
+ maxChars: number = MULTI_TURN_MAX_CHARS,
779
+ ): string {
780
+ if (!sessionId || currentQuery.length === 0) return currentQuery;
781
+
782
+ let priors: string[] = [];
783
+ try {
784
+ // ISO 8601 cutoff computed in JS (same lesson as the v0.8.0
785
+ // countRecentContextUsages fix — datetime('now', ...) returns a
786
+ // space-separated string that sorts incorrectly against the
787
+ // T-separated ISO 8601 timestamps stored in context_usage).
788
+ const cutoff = new Date(Date.now() - maxAgeMinutes * 60 * 1000).toISOString();
789
+ // Self-match guard lives in SQL so a duplicate submit/retry cannot eat
790
+ // into the lookback budget. Turn 18 review found that filtering in
791
+ // application code with `LIMIT lookback + 1` under-fills when multiple
792
+ // prior rows carry the same text as the current prompt — the SELECT
793
+ // returned only `lookback + 1` rows and application-level skipping
794
+ // then dropped legitimate distinct priors along with the dupes.
795
+ // Pushing the inequality into WHERE means every returned row is a
796
+ // valid non-self prior and the LIMIT == lookback fits exactly.
797
+ const rows = store.db.prepare(
798
+ `SELECT query_text FROM context_usage
799
+ WHERE session_id = ?
800
+ AND hook_name = 'context-surfacing'
801
+ AND timestamp > ?
802
+ AND query_text IS NOT NULL
803
+ AND query_text != ''
804
+ AND query_text != ?
805
+ ORDER BY id DESC
806
+ LIMIT ?`,
807
+ ).all(sessionId, cutoff, currentQuery, lookback) as { query_text: string }[];
808
+
809
+ for (const row of rows) {
810
+ if (!row.query_text) continue;
811
+ priors.push(row.query_text);
812
+ }
813
+ } catch {
814
+ // query_text column may be missing on a pre-migration store, or
815
+ // the DB might be in a corrupted state — fall back to current-only.
816
+ return currentQuery;
817
+ }
818
+
819
+ if (priors.length === 0) return currentQuery;
820
+
821
+ // Assemble newest-first: current first, then newest prior, then older.
822
+ // The SQL already ordered rows DESC by id, so `priors[0]` is the newest.
823
+ const segments = [currentQuery, ...priors];
824
+ const combined = segments.join("\n\n");
825
+
826
+ if (combined.length <= maxChars) return combined;
827
+
828
+ // Over budget. Current query ALWAYS wins — include the full current
829
+ // prompt first, then add priors newest-first until the budget runs out.
830
+ // If the current prompt alone is already over budget, return it
831
+ // truncated (same as pre-v0.8.1 behavior — MAX_QUERY_LENGTH is
832
+ // enforced earlier in the handler so this branch is rare).
833
+ if (currentQuery.length >= maxChars) return currentQuery.slice(0, maxChars);
834
+
835
+ const parts: string[] = [currentQuery];
836
+ let used = currentQuery.length;
837
+ const separator = "\n\n";
838
+ for (const prior of priors) {
839
+ const cost = separator.length + prior.length;
840
+ if (used + cost > maxChars) break;
841
+ parts.push(prior);
842
+ used += cost;
843
+ }
844
+ return parts.join(separator);
845
+ }
846
+
703
847
  /**
704
848
  * Check if the agent should be nudged to use lifecycle tools.
705
849
  * Returns true if N+ context-surfacing invocations have occurred since the
package/src/hooks.ts CHANGED
@@ -379,6 +379,12 @@ export function smartTruncate(text: string, maxChars: number = 300): string {
379
379
 
380
380
  /**
381
381
  * Log a context injection to the usage tracking table.
382
+ *
383
+ * `queryText` (v0.8.1 Ext 6b) is the raw prompt for this turn. Persisted
384
+ * only when the caller passes it — logEmptyTurn-style skip paths omit it
385
+ * so gated turns (slash commands, heartbeats, noise) cannot leak raw
386
+ * prompt text into `context_usage.query_text`. Pre-migration stores
387
+ * transparently drop the column via `insertUsageFn`'s feature-detect.
382
388
  */
383
389
  export function logInjection(
384
390
  store: Store,
@@ -386,7 +392,8 @@ export function logInjection(
386
392
  hookName: string,
387
393
  injectedPaths: string[],
388
394
  estimatedTokens: number,
389
- turnIndex?: number
395
+ turnIndex?: number,
396
+ queryText?: string
390
397
  ): number {
391
398
  try {
392
399
  const usageId = store.insertUsage({
@@ -397,6 +404,7 @@ export function logInjection(
397
404
  estimatedTokens,
398
405
  wasReferenced: 0,
399
406
  turnIndex,
407
+ queryText,
400
408
  });
401
409
 
402
410
  // Record co-activation for all injected paths (E3)