recallnest 1.4.1 → 2.2.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.
Files changed (233) hide show
  1. package/.env +4 -0
  2. package/CLAUDE.md +7 -3
  3. package/README.md +202 -130
  4. package/README_CN.md +263 -190
  5. package/bin/recallnest.mjs +62 -0
  6. package/config.json +55 -0
  7. package/daily-recall.sh +23 -0
  8. package/integrations/setup-all.sh +31 -0
  9. package/logs/daily-recall.error.log +0 -0
  10. package/logs/daily-recall.log +30 -0
  11. package/logs/ingest-2026-04-11.log +110 -0
  12. package/logs/ingest-2026-04-12.log +90 -0
  13. package/logs/launchagent-stderr.log +0 -0
  14. package/logs/launchagent-stdout.log +0 -0
  15. package/package.json +10 -3
  16. package/src/access-tracker.ts +3 -2
  17. package/src/activity-counter.ts +109 -0
  18. package/src/adaptive-retrieval.ts +6 -5
  19. package/src/admission-control.ts +163 -0
  20. package/src/anchor-generator.ts +80 -0
  21. package/src/api-server.ts +10 -0
  22. package/src/audit-log.ts +122 -0
  23. package/src/auto-gc.ts +98 -29
  24. package/src/auto-recall.ts +33 -4
  25. package/src/capture-engine.ts +372 -23
  26. package/src/cascade-forget.ts +3 -2
  27. package/src/cli.ts +107 -5
  28. package/src/compat.ts +22 -0
  29. package/src/confidence-tracker.ts +210 -47
  30. package/src/conflict-store.ts +12 -3
  31. package/src/consolidation-engine.ts +296 -8
  32. package/src/context-collapse-renderer.ts +222 -0
  33. package/src/context-composer-stable-selection.ts +5 -1
  34. package/src/context-composer.ts +239 -4
  35. package/src/context-reconstructor.ts +404 -0
  36. package/src/conversation-importer.ts +441 -0
  37. package/src/data-checkup.ts +232 -0
  38. package/src/decay-engine.ts +84 -0
  39. package/src/distill-lock.ts +122 -0
  40. package/src/doctor.ts +8 -10
  41. package/src/dream-pipeline.ts +227 -0
  42. package/src/emotion-detector.ts +90 -0
  43. package/src/eval.ts +3 -2
  44. package/src/forget-engine.ts +269 -0
  45. package/src/frequency-tracker.ts +175 -0
  46. package/src/graph-export.ts +644 -0
  47. package/src/ingest.ts +36 -6
  48. package/src/interference-detector.ts +187 -0
  49. package/src/kg-extractor.ts +15 -24
  50. package/src/kg-store.ts +2 -1
  51. package/src/language-hook.ts +67 -0
  52. package/src/llm-client.ts +289 -6
  53. package/src/mcp-server.ts +707 -80
  54. package/src/memory-assets.ts +22 -5
  55. package/src/memory-evolution.ts +365 -0
  56. package/src/memory-lint.ts +429 -0
  57. package/src/memory-output.ts +69 -0
  58. package/src/memory-schema.ts +74 -2
  59. package/src/narrative-schema.ts +79 -0
  60. package/src/narrative-tagger.ts +164 -0
  61. package/src/pii-detector.ts +98 -0
  62. package/src/post-retrieval-filter.ts +76 -0
  63. package/src/prediction-engine.ts +253 -0
  64. package/src/preference-slots.ts +65 -2
  65. package/src/prospective-memory.ts +215 -0
  66. package/src/query-expander.ts +67 -0
  67. package/src/recall-governor.ts +183 -0
  68. package/src/retention-policy.ts +90 -0
  69. package/src/retriever.ts +828 -55
  70. package/src/rif.ts +34 -8
  71. package/src/runtime-config.ts +23 -8
  72. package/src/scope-inventory.ts +4 -3
  73. package/src/scope-policy.ts +4 -0
  74. package/src/session-distiller.ts +307 -0
  75. package/src/session-output.ts +40 -0
  76. package/src/session-schema.ts +37 -0
  77. package/src/session-store.ts +7 -2
  78. package/src/skill-engine.ts +208 -0
  79. package/src/skill-promotion.ts +293 -0
  80. package/src/skill-schema.ts +39 -0
  81. package/src/store.ts +96 -10
  82. package/src/topic-tag.ts +66 -0
  83. package/src/tracker.ts +3 -1
  84. package/src/ui-server.ts +80 -8
  85. package/src/workflow-observation-scope-review.ts +2 -1
  86. package/src/workflow-observation-store.ts +2 -1
  87. package/src/write-verifier.ts +126 -0
  88. package/.claude-plugin/CLAUDE.md +0 -29
  89. package/.claude-plugin/marketplace.json +0 -18
  90. package/.github/workflows/ci.yml +0 -28
  91. package/.mcp.json +0 -9
  92. package/CHANGELOG.md +0 -51
  93. package/DEMO.md +0 -94
  94. package/EVAL.md +0 -89
  95. package/FAILURES.md +0 -75
  96. package/OPERATIONS.md +0 -59
  97. package/ROADMAP.md +0 -198
  98. package/assets/recallnest-workbench.png +0 -0
  99. package/assets/ui/app.js +0 -807
  100. package/assets/ui/index.html +0 -154
  101. package/assets/ui/styles.css +0 -605
  102. package/assets/wechat-qr.jpg +0 -0
  103. package/daily-reflections/2026-03-05.md +0 -15
  104. package/daily-reflections/2026-03-06.md +0 -15
  105. package/daily-reflections/2026-03-07.md +0 -7
  106. package/daily-reflections/2026-03-08.md +0 -34
  107. package/daily-reflections/2026-03-09.md +0 -35
  108. package/daily-reflections/2026-03-10.md +0 -36
  109. package/daily-reflections/2026-03-11.md +0 -35
  110. package/daily-reflections/2026-03-12.md +0 -36
  111. package/daily-reflections/2026-03-13.md +0 -35
  112. package/daily-reflections/2026-03-14.md +0 -35
  113. package/daily-reflections/2026-03-15.md +0 -35
  114. package/daily-reflections/2026-03-16.md +0 -41
  115. package/daily-reflections/2026-03-17.md +0 -10
  116. package/daily-reflections/2026-03-18.md +0 -10
  117. package/daily-reflections/2026-03-19.md +0 -10
  118. package/daily-reflections/2026-03-20.md +0 -10
  119. package/daily-reflections/2026-03-21.md +0 -10
  120. package/daily-reflections/2026-03-22.md +0 -10
  121. package/daily-reflections/2026-03-23.md +0 -10
  122. package/daily-reflections/2026-03-24.md +0 -10
  123. package/daily-reflections/2026-03-25.md +0 -32
  124. package/daily-reflections/2026-03-26.md +0 -37
  125. package/daily-reflections/2026-03-27.md +0 -35
  126. package/daily-reflections/2026-03-28.md +0 -35
  127. package/daily-reflections/2026-03-29.md +0 -35
  128. package/daily-reflections/2026-03-30.md +0 -36
  129. package/daily-reflections/2026-03-31.md +0 -35
  130. package/daily-reflections/2026-04-01.md +0 -35
  131. package/docs/api-reference.md +0 -1291
  132. package/docs/architecture.md +0 -168
  133. package/docs/memory-boundary-contract.md +0 -226
  134. package/docs/memory-categories.md +0 -119
  135. package/docs/pipeline-audit-ultramemory.md +0 -177
  136. package/docs/plans/2026-03-08-query-expansion.md +0 -270
  137. package/docs/plans/2026-03-16-continuity-layer.md +0 -196
  138. package/docs/plans/mcp-tiering-plan.md +0 -126
  139. package/docs/research/recall-quality-audit-2026-03-22.md +0 -153
  140. package/docs/self-evolution.md +0 -198
  141. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +0 -782
  142. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +0 -77
  143. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +0 -95
  144. package/eval/continuity/results-2026-03-16.md +0 -117
  145. package/eval/continuity/results-2026-03-19.md +0 -453
  146. package/eval/continuity/runbook.md +0 -110
  147. package/eval/continuity/seed-cases.md +0 -54
  148. package/eval/reports/2026-03-06-abstract-query-baseline.md +0 -96
  149. package/eval/reports/2026-03-06-aws-ssh-baseline.md +0 -82
  150. package/eval/reports/2026-03-06-baseline.md +0 -82
  151. package/eval/reports/query-expansion.md +0 -138
  152. package/lm +0 -19
  153. package/plan-v1.2-distributable.md +0 -105
  154. package/scripts/archive-distilled-junk.ts +0 -116
  155. package/scripts/backfill-l0l1l2.ts +0 -98
  156. package/scripts/blind-scan.ts +0 -588
  157. package/scripts/cleanup-step1-exact-dups.ts +0 -117
  158. package/scripts/cleanup-step2-template-dups.ts +0 -113
  159. package/scripts/cleanup-step3-stale-content.ts +0 -149
  160. package/scripts/cleanup-step4-orphan-scopes.ts +0 -131
  161. package/scripts/delete-distilled-junk.ts +0 -88
  162. package/scripts/distill-facts.ts +0 -305
  163. package/scripts/health-check.ts +0 -343
  164. package/scripts/incremental-ingest.sh +0 -71
  165. package/scripts/mlp-health-check.ts +0 -382
  166. package/scripts/pull-claude-desktop.ts +0 -515
  167. package/scripts/re-ingest-cc.sh +0 -66
  168. package/scripts/rebalance-memory-health.ts +0 -181
  169. package/scripts/run-desktop-ingest.sh +0 -11
  170. package/scripts/start-server.sh +0 -12
  171. package/scripts/viz-dashboard.html +0 -423
  172. package/scripts/viz-export.ts +0 -703
  173. package/src/__tests__/access-tracker-novelty.test.ts +0 -228
  174. package/src/__tests__/auto-consolidation.test.ts +0 -168
  175. package/src/__tests__/auto-recall.test.ts +0 -131
  176. package/src/__tests__/batch-dedup.test.ts +0 -65
  177. package/src/__tests__/capture-engine.test.ts +0 -1142
  178. package/src/__tests__/capture-heuristic.test.ts +0 -171
  179. package/src/__tests__/cascade-forget.test.ts +0 -210
  180. package/src/__tests__/category-min-scores.test.ts +0 -33
  181. package/src/__tests__/confidence-tracker.test.ts +0 -196
  182. package/src/__tests__/conflict-advisor.test.ts +0 -206
  183. package/src/__tests__/conflict-escalation.test.ts +0 -149
  184. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  185. package/src/__tests__/conflict-output.test.ts +0 -85
  186. package/src/__tests__/conflict-store.test.ts +0 -374
  187. package/src/__tests__/consolidation-engine.test.ts +0 -181
  188. package/src/__tests__/context-composer-pins.test.ts +0 -64
  189. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  190. package/src/__tests__/context-renderer.test.ts +0 -91
  191. package/src/__tests__/continuity-eval.test.ts +0 -275
  192. package/src/__tests__/continuity-schema.test.ts +0 -97
  193. package/src/__tests__/core-recall-eval.test.ts +0 -235
  194. package/src/__tests__/core-summary.test.ts +0 -109
  195. package/src/__tests__/dedup-actions.test.ts +0 -58
  196. package/src/__tests__/deterministic-id.test.ts +0 -39
  197. package/src/__tests__/doctor.test.ts +0 -303
  198. package/src/__tests__/drill-down.test.ts +0 -108
  199. package/src/__tests__/embedder.test.ts +0 -122
  200. package/src/__tests__/entity-resolver.test.ts +0 -58
  201. package/src/__tests__/event-segmenter.test.ts +0 -153
  202. package/src/__tests__/hotness-blend.test.ts +0 -76
  203. package/src/__tests__/ingest.test.ts +0 -360
  204. package/src/__tests__/kg-extractor.test.ts +0 -184
  205. package/src/__tests__/kg-store.test.ts +0 -198
  206. package/src/__tests__/llm-consolidation.test.ts +0 -226
  207. package/src/__tests__/memory-boundaries.test.ts +0 -237
  208. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  209. package/src/__tests__/memory-output.test.ts +0 -215
  210. package/src/__tests__/multi-vector.test.ts +0 -242
  211. package/src/__tests__/ppr-traversal.test.ts +0 -256
  212. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  213. package/src/__tests__/preference-matcher.test.ts +0 -230
  214. package/src/__tests__/preference-slots.test.ts +0 -106
  215. package/src/__tests__/prospective-memory.test.ts +0 -283
  216. package/src/__tests__/query-entity-detector.test.ts +0 -171
  217. package/src/__tests__/query-expander.test.ts +0 -34
  218. package/src/__tests__/recall-mode.test.ts +0 -64
  219. package/src/__tests__/result-synthesizer.test.ts +0 -198
  220. package/src/__tests__/resume-context.test.ts +0 -4743
  221. package/src/__tests__/retrieval-trace.test.ts +0 -213
  222. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  223. package/src/__tests__/retroactive-boost.test.ts +0 -223
  224. package/src/__tests__/scope-inventory.test.ts +0 -200
  225. package/src/__tests__/scope-policy.test.ts +0 -69
  226. package/src/__tests__/session-checkpoint.test.ts +0 -259
  227. package/src/__tests__/session-gc.test.ts +0 -215
  228. package/src/__tests__/store-scope-audit.test.ts +0 -81
  229. package/src/__tests__/temporal-parser.test.ts +0 -241
  230. package/src/__tests__/term-registry.test.ts +0 -121
  231. package/src/__tests__/tool-output-compressor.test.ts +0 -133
  232. package/src/__tests__/version-manager.test.ts +0 -213
  233. package/src/__tests__/workflow-observation.test.ts +0 -400
@@ -92,10 +92,17 @@ export interface ToolChoicePreferenceSlot {
92
92
  avoidedTool: string;
93
93
  }
94
94
 
95
+ export interface ImplicitUsagePreferenceSlot {
96
+ type: "implicit-usage";
97
+ /** Normalized subject: the tool, product, or activity mentioned */
98
+ subject: string;
99
+ }
100
+
95
101
  export type PreferenceSlot =
96
102
  | AtomicBrandItemPreferenceSlot
97
103
  | ReplyStylePreferenceSlot
98
- | ToolChoicePreferenceSlot;
104
+ | ToolChoicePreferenceSlot
105
+ | ImplicitUsagePreferenceSlot;
99
106
 
100
107
  function normalizePreferenceText(value: string): string {
101
108
  return value
@@ -227,10 +234,62 @@ export function inferToolChoicePreferenceSlot(text: string): ToolChoicePreferenc
227
234
  return null;
228
235
  }
229
236
 
237
+ // ---------------------------------------------------------------------------
238
+ // Implicit usage preference detection
239
+ // Captures "I use X", "I have a X", "I enjoy X", "I'm learning X" etc.
240
+ // These are indirect signals — user never says "I prefer X" explicitly.
241
+ // ---------------------------------------------------------------------------
242
+
243
+ const IMPLICIT_USAGE_EN_PATTERNS: RegExp[] = [
244
+ // "I/User use(s)/using X for..." or at end of sentence
245
+ /\b(?:i|user)\s+(?:use[sd]?|using|am\s+using|have\s+been\s+using|work[s]?\s+with)\s+(?<subject>[A-Za-z][A-Za-z0-9\s.+#\-']{1,40}?)\s*(?=\s+(?:for|to|in|at|on|as|which|because|since)\b|[,.:;!?]|$)/i,
246
+ // "I/User have/own a X (camera|phone|...)" or at end
247
+ /\b(?:i|user)\s+(?:have|has|own[s]?|bought|got)\s+(?:a |an |the )?(?<subject>[A-Za-z][A-Za-z0-9\s.+#\-']{1,40}?)\s*(?=\s+(?:camera|phone|laptop|computer|tablet|watch|car|bike|guitar|piano|keyboard|headphones|monitor|setup|kit|device)\b|[,.:;!?]|$)/i,
248
+ // "I/User enjoy(s)/love(s) (to use) X" (no "from" — avoids brand-item overlap)
249
+ /\b(?:i|user)\s+(?:enjoy[s]?|love[s]?|really\s+like[s]?)\s+(?:to\s+(?:use\s+)?)?(?<subject>[A-Za-z][A-Za-z0-9\s.+#\-']{1,40}?)\s*(?=[,.:;!?]|$)/i,
250
+ // "I'm learning/studying X"
251
+ /\b(?:i'm|i\s+am|user\s+is)\s+(?:learning|studying|interested\s+in|passionate\s+about)\s+(?<subject>[A-Za-z][A-Za-z0-9\s.+#\-']{1,40}?)\s*(?=[,.:;!?]|$)/i,
252
+ ];
253
+
254
+ const IMPLICIT_USAGE_CN_PATTERNS: RegExp[] = [
255
+ // "我用/在用 X" (lookahead stops at verb complements or punctuation; no \b for CJK)
256
+ /(?:我|用户)(?:在)?(?:用|使用)\s*(?<subject>[\p{Script=Han}A-Za-z0-9][\p{Script=Han}A-Za-z0-9\s.+#\-]{0,30}?)(?=\s*(?:来|做|进行|处理|开发|编辑)|[,。!?,.:;!?]|$)/u,
257
+ // "我有/买了 X"
258
+ /(?:我|用户)(?:有|买了|入了)\s*(?:一[个台部套])?(?<subject>[\p{Script=Han}A-Za-z0-9][\p{Script=Han}A-Za-z0-9\s.+#\-]{0,30}?)(?=[,。!?,.:;!?]|$)/u,
259
+ // "我喜欢做 X" / "我爱 X" (activity, not brand-item — no "的" connector)
260
+ /(?:我|用户)(?:很)?(?:喜欢|爱|享受)\s*(?:做|玩|学|用)\s*(?<subject>[\p{Script=Han}A-Za-z0-9][\p{Script=Han}A-Za-z0-9\s.+#\-]{0,30}?)(?=[,。!?,.:;!?]|$)/u,
261
+ // "我在学/研究 X"
262
+ /(?:我|用户)(?:在学|正在学|在研究)\s*(?<subject>[\p{Script=Han}A-Za-z0-9][\p{Script=Han}A-Za-z0-9\s.+#\-]{0,30}?)(?=[,。!?,.:;!?]|$)/u,
263
+ ];
264
+
265
+ /** Minimum normalized subject length to avoid false positives like "I use it" */
266
+ const IMPLICIT_USAGE_MIN_SUBJECT_LEN = 2;
267
+
268
+ export function inferImplicitUsageSlot(text: string): ImplicitUsagePreferenceSlot | null {
269
+ const normalizedText = normalizePreferenceText(text);
270
+
271
+ for (const pattern of [...IMPLICIT_USAGE_EN_PATTERNS, ...IMPLICIT_USAGE_CN_PATTERNS]) {
272
+ const match = normalizedText.match(pattern);
273
+ if (!match?.groups?.subject) continue;
274
+
275
+ const subject = normalizePreferenceToken(match.groups.subject);
276
+ if (subject.length < IMPLICIT_USAGE_MIN_SUBJECT_LEN) continue;
277
+
278
+ return {
279
+ type: "implicit-usage",
280
+ subject,
281
+ };
282
+ }
283
+
284
+ return null;
285
+ }
286
+
230
287
  export function inferPreferenceSlot(text: string): PreferenceSlot | null {
288
+ // Explicit patterns take priority over implicit detection
231
289
  return inferAtomicBrandItemPreferenceSlot(text)
232
290
  || inferReplyStylePreferenceSlot(text)
233
- || inferToolChoicePreferenceSlot(text);
291
+ || inferToolChoicePreferenceSlot(text)
292
+ || inferImplicitUsageSlot(text);
234
293
  }
235
294
 
236
295
  export function samePreferenceSlot(
@@ -255,5 +314,9 @@ export function samePreferenceSlot(
255
314
  left.avoidedTool === right.avoidedTool;
256
315
  }
257
316
 
317
+ if (left.type === "implicit-usage" && right.type === "implicit-usage") {
318
+ return left.subject === right.subject;
319
+ }
320
+
258
321
  return false;
259
322
  }
@@ -15,6 +15,9 @@
15
15
  import type { MemoryStore, MemoryEntry } from "./store.js";
16
16
  import type { Embedder } from "./embedder.js";
17
17
  import { logInfo } from "./stderr-log.js";
18
+ import type { ScoredPrediction, PredictionContext } from "./prediction-engine.js";
19
+ import { collectSignals, scorePredictions } from "./prediction-engine.js";
20
+ import { isPredictiveMemoryEnabled } from "./memory-schema.js";
18
21
 
19
22
  // ============================================================================
20
23
  // Types
@@ -27,6 +30,16 @@ export interface ProspectiveMetadata {
27
30
  createdAt: string;
28
31
  firedAt?: string;
29
32
  expiresAt?: string;
33
+ /** HP-predictive: "explicit" = user-created, "predicted" = heuristic-generated */
34
+ source?: "explicit" | "predicted";
35
+ /** HP-predictive: confidence score 0-1 for predicted reminders */
36
+ confidence?: number;
37
+ /** HP-predictive: evidence strings explaining why this was predicted */
38
+ evidence?: string[];
39
+ /** HP-predictive: last time this prediction was surfaced to the user */
40
+ lastSuggestedAt?: string;
41
+ /** HP-predictive: when the user accepted/acted on this predicted reminder */
42
+ acceptedAt?: string;
30
43
  }
31
44
 
32
45
  export interface Reminder {
@@ -42,6 +55,20 @@ export interface SetReminderParams {
42
55
  expiresInDays?: number;
43
56
  }
44
57
 
58
+ export interface SuggestedReminder {
59
+ entryId: string;
60
+ trigger: string;
61
+ action: string;
62
+ confidence: number;
63
+ evidence: string[];
64
+ }
65
+
66
+ /** Default expiry for predicted reminders: 7 days */
67
+ const PREDICTED_REMINDER_EXPIRY_DAYS = 7;
68
+
69
+ /** Confidence penalty per ignored suggestion cycle */
70
+ const IGNORE_CONFIDENCE_PENALTY = 0.15;
71
+
45
72
  // ============================================================================
46
73
  // Core Functions
47
74
  // ============================================================================
@@ -62,6 +89,7 @@ export async function setReminder(
62
89
  trigger: params.trigger,
63
90
  action: params.action,
64
91
  status: "pending",
92
+ source: "explicit",
65
93
  createdAt: new Date().toISOString(),
66
94
  ...(params.expiresInDays
67
95
  ? { expiresAt: new Date(Date.now() + params.expiresInDays * 86_400_000).toISOString() }
@@ -168,6 +196,193 @@ export function formatReminders(reminders: Reminder[]): string[] {
168
196
  );
169
197
  }
170
198
 
199
+ // ============================================================================
200
+ // HP-predictive: Predicted Reminders
201
+ // ============================================================================
202
+
203
+ /**
204
+ * Generate predicted reminders from behavioral signals.
205
+ * Deduplicates against existing pending reminders via vector similarity.
206
+ * Stores new predictions as ephemeral entries with 7-day expiry.
207
+ *
208
+ * @returns Array of suggested reminders to surface to the user
209
+ */
210
+ export async function suggestPredictedReminders(
211
+ store: MemoryStore,
212
+ embedder: Embedder,
213
+ predictionContext: PredictionContext,
214
+ scope: string,
215
+ ): Promise<SuggestedReminder[]> {
216
+ if (!isPredictiveMemoryEnabled()) return [];
217
+
218
+ // 1. Collect and score signals
219
+ const signals = collectSignals(predictionContext);
220
+ const predictions = scorePredictions(signals);
221
+
222
+ if (predictions.length === 0) return [];
223
+
224
+ const suggested: SuggestedReminder[] = [];
225
+ const now = predictionContext.now ?? new Date();
226
+
227
+ for (const prediction of predictions) {
228
+ // 2. Deduplicate: vector search for similar existing reminders
229
+ const isDuplicate = await isDuplicateReminder(store, embedder, prediction, [scope]);
230
+ if (isDuplicate) continue;
231
+
232
+ // 3. Store as predicted + ephemeral reminder with 7-day expiry
233
+ const text = `[Predicted Reminder] ${prediction.action}`;
234
+ const vector = await embedder.embedPassage(text);
235
+
236
+ const prospective: ProspectiveMetadata = {
237
+ trigger: prediction.trigger,
238
+ action: prediction.action,
239
+ status: "pending",
240
+ source: "predicted",
241
+ confidence: prediction.confidence,
242
+ evidence: prediction.evidence,
243
+ lastSuggestedAt: now.toISOString(),
244
+ createdAt: now.toISOString(),
245
+ expiresAt: new Date(now.getTime() + PREDICTED_REMINDER_EXPIRY_DAYS * 86_400_000).toISOString(),
246
+ };
247
+
248
+ const entry = await store.store({
249
+ text,
250
+ vector,
251
+ category: "patterns",
252
+ scope,
253
+ importance: 0.5, // Lower than explicit reminders (0.75)
254
+ metadata: JSON.stringify({
255
+ prospective,
256
+ tier: "working",
257
+ privacyTier: "ephemeral",
258
+ boundary: { layer: "ephemeral", authority: "system" },
259
+ }),
260
+ });
261
+
262
+ suggested.push({
263
+ entryId: entry.id,
264
+ trigger: prediction.trigger,
265
+ action: prediction.action,
266
+ confidence: prediction.confidence,
267
+ evidence: prediction.evidence,
268
+ });
269
+
270
+ logInfo(`[INFO] Predicted reminder stored: "${prediction.trigger}" (confidence: ${prediction.confidence})`);
271
+ }
272
+
273
+ return suggested;
274
+ }
275
+
276
+ /**
277
+ * Accept a predicted reminder — upgrade it to explicit, persist as durable.
278
+ */
279
+ export async function acceptPredictedReminder(
280
+ store: MemoryStore,
281
+ entryId: string,
282
+ scopeFilter?: string[],
283
+ ): Promise<boolean> {
284
+ const entry = await store.getById(entryId);
285
+ if (!entry) return false;
286
+
287
+ const meta = parseMetadata(entry.metadata);
288
+ const prospective = meta.prospective as ProspectiveMetadata | undefined;
289
+ if (!prospective || prospective.source !== "predicted" || prospective.status !== "pending") return false;
290
+
291
+ // Upgrade: predicted → explicit, ephemeral → durable
292
+ prospective.source = "explicit";
293
+ prospective.acceptedAt = new Date().toISOString();
294
+ meta.prospective = prospective;
295
+ meta.privacyTier = "durable";
296
+ if (meta.boundary && typeof meta.boundary === "object") {
297
+ (meta.boundary as Record<string, string>).layer = "durable";
298
+ (meta.boundary as Record<string, string>).authority = "user";
299
+ }
300
+
301
+ await store.update(entryId, {
302
+ metadata: JSON.stringify(meta),
303
+ importance: 0.75, // Upgrade to explicit-level importance
304
+ }, scopeFilter);
305
+
306
+ logInfo(`[INFO] Predicted reminder accepted and promoted: ${entryId}`);
307
+ return true;
308
+ }
309
+
310
+ /**
311
+ * Demote a predicted reminder — reduce confidence.
312
+ * If confidence drops below threshold after penalty, mark as expired.
313
+ */
314
+ export async function demotePredictedReminder(
315
+ store: MemoryStore,
316
+ entryId: string,
317
+ scopeFilter?: string[],
318
+ ): Promise<boolean> {
319
+ const entry = await store.getById(entryId);
320
+ if (!entry) return false;
321
+
322
+ const meta = parseMetadata(entry.metadata);
323
+ const prospective = meta.prospective as ProspectiveMetadata | undefined;
324
+ if (!prospective || prospective.source !== "predicted" || prospective.status !== "pending") return false;
325
+
326
+ const newConfidence = Math.max(0, (prospective.confidence ?? 0) - IGNORE_CONFIDENCE_PENALTY);
327
+
328
+ if (newConfidence < 0.6) {
329
+ // Below threshold — expire it
330
+ prospective.status = "expired";
331
+ prospective.confidence = newConfidence;
332
+ meta.prospective = prospective;
333
+ await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter);
334
+ logInfo(`[INFO] Predicted reminder demoted below threshold and expired: ${entryId}`);
335
+ } else {
336
+ // Still above threshold — just lower confidence
337
+ prospective.confidence = newConfidence;
338
+ prospective.lastSuggestedAt = new Date().toISOString();
339
+ meta.prospective = prospective;
340
+ await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter);
341
+ logInfo(`[INFO] Predicted reminder demoted: ${entryId} (new confidence: ${newConfidence})`);
342
+ }
343
+
344
+ return true;
345
+ }
346
+
347
+ /**
348
+ * Format suggested reminders for output in search_memory results.
349
+ */
350
+ export function formatSuggestedReminders(suggestions: SuggestedReminder[]): string {
351
+ if (suggestions.length === 0) return "";
352
+
353
+ const lines = suggestions.map(s => {
354
+ const confidenceLabel = s.confidence >= 0.8 ? "high" : s.confidence >= 0.7 ? "medium" : "low";
355
+ return `- [${confidenceLabel}] ${s.action}\n Evidence: ${s.evidence.join("; ")}`;
356
+ });
357
+
358
+ return "\n\n--- Suggested Reminders ---\n" + lines.join("\n");
359
+ }
360
+
361
+ /**
362
+ * Check if a prediction duplicates an existing pending reminder.
363
+ * Uses vector similarity — threshold 0.75 = likely duplicate.
364
+ */
365
+ async function isDuplicateReminder(
366
+ store: MemoryStore,
367
+ embedder: Embedder,
368
+ prediction: ScoredPrediction,
369
+ scopeFilter: string[],
370
+ ): Promise<boolean> {
371
+ const searchText = `${prediction.trigger} ${prediction.action}`;
372
+ const vector = await embedder.embedPassage(searchText);
373
+ const candidates = await store.vectorSearch(vector, 5, 0.75, scopeFilter);
374
+
375
+ for (const candidate of candidates) {
376
+ const meta = parseMetadata(candidate.entry.metadata);
377
+ const prospective = meta.prospective as ProspectiveMetadata | undefined;
378
+ if (prospective && prospective.status === "pending") {
379
+ return true; // Found an existing pending reminder that's similar enough
380
+ }
381
+ }
382
+
383
+ return false;
384
+ }
385
+
171
386
  // ============================================================================
172
387
  // Helpers
173
388
  // ============================================================================
@@ -7,8 +7,11 @@
7
7
  * MAX_EXPANSION_TERMS cap to prevent query bloat hurting BM25 precision.
8
8
  * Structured synonym entries separating CN (substring) from EN (word-boundary).
9
9
  * v1.2: Entity resolution pre-pass normalizes aliases before synonym expansion.
10
+ * v1.3: P0.3 — User-level alias-map (data/alias-map.json) for short query expansion.
10
11
  */
11
12
 
13
+ import { existsSync, readFileSync } from "fs";
14
+ import { join } from "path";
12
15
  import { resolveQueryEntities } from "./entity-resolver.js";
13
16
 
14
17
  interface SynonymEntry {
@@ -59,6 +62,56 @@ const SYNONYM_MAP: SynonymEntry[] = [
59
62
  /** Maximum expansion terms to prevent query bloat hurting BM25 precision */
60
63
  const MAX_EXPANSION_TERMS = 5;
61
64
 
65
+ // ============================================================================
66
+ // P0.3: User-level alias-map for short query expansion
67
+ // ============================================================================
68
+
69
+ /**
70
+ * Alias map entry: a short trigger → expansion terms.
71
+ * Loaded from data/alias-map.json on first use (lazy, cached).
72
+ */
73
+ export interface AliasEntry {
74
+ trigger: string;
75
+ expansions: string[];
76
+ }
77
+
78
+ let aliasMapCache: AliasEntry[] | null = null;
79
+ let aliasMapPath: string | null = null;
80
+
81
+ /** Override the alias-map file path (for testing). */
82
+ export function setAliasMapPath(path: string): void {
83
+ aliasMapPath = path;
84
+ aliasMapCache = null; // force reload
85
+ }
86
+
87
+ /** Reset alias-map cache (for testing). */
88
+ export function resetAliasMapCache(): void {
89
+ aliasMapCache = null;
90
+ }
91
+
92
+ function loadAliasMap(): AliasEntry[] {
93
+ if (aliasMapCache !== null) return aliasMapCache;
94
+ const filePath = aliasMapPath ?? join(process.cwd(), "data", "alias-map.json");
95
+ try {
96
+ if (existsSync(filePath)) {
97
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
98
+ if (Array.isArray(raw)) {
99
+ aliasMapCache = raw.filter(
100
+ (e: unknown): e is AliasEntry =>
101
+ typeof e === "object" && e !== null &&
102
+ typeof (e as AliasEntry).trigger === "string" &&
103
+ Array.isArray((e as AliasEntry).expansions),
104
+ );
105
+ return aliasMapCache;
106
+ }
107
+ }
108
+ } catch {
109
+ // Silent — alias map is optional
110
+ }
111
+ aliasMapCache = [];
112
+ return aliasMapCache;
113
+ }
114
+
62
115
  /**
63
116
  * Expand a query by appending synonym terms from the dictionary.
64
117
  * Returns the original query with additional terms appended.
@@ -72,6 +125,7 @@ export function expandQuery(query: string): string {
72
125
  const lower = resolved.toLowerCase();
73
126
  const additions = new Set<string>();
74
127
 
128
+ // Static synonym map (built-in)
75
129
  for (const entry of SYNONYM_MAP) {
76
130
  // Chinese triggers: substring match (CJK has no word boundaries)
77
131
  const cnMatch = entry.cn.some(t => lower.includes(t.toLowerCase()));
@@ -90,6 +144,19 @@ export function expandQuery(query: string): string {
90
144
  }
91
145
  }
92
146
 
147
+ // P0.3: User alias-map (data/alias-map.json) — short triggers → expansions
148
+ const aliasMap = loadAliasMap();
149
+ for (const alias of aliasMap) {
150
+ const triggerLower = alias.trigger.toLowerCase();
151
+ if (lower.includes(triggerLower)) {
152
+ for (const exp of alias.expansions) {
153
+ if (!lower.includes(exp.toLowerCase())) {
154
+ additions.add(exp);
155
+ }
156
+ }
157
+ }
158
+ }
159
+
93
160
  if (additions.size === 0) return resolved;
94
161
 
95
162
  // Cap expansion terms to prevent query bloat
@@ -0,0 +1,183 @@
1
+ /**
2
+ * LME-7: Recall Governor — auto-recall 治理层
3
+ *
4
+ * 在 HybridRetriever 返回结果后、auto-recall 返回给调用方之前,
5
+ * 对检索结果施加 4 层治理:
6
+ * 1. Query 截断 — 超长 query 截到 maxQueryChars(省 embedding 成本)
7
+ * 2. 状态过滤 — 排除 archived/superseded 记忆(evolution 兜底)
8
+ * 3. 预算控制 — 总字符上限 + 条目数上限
9
+ * 4. 会话去重 — 同一 session 内不重复注入同一条记忆
10
+ */
11
+
12
+ import { isActiveMemory } from "./memory-evolution.js";
13
+ import type { RetrievalResult } from "./retriever.js";
14
+ import { logInfo } from "./stderr-log.js";
15
+
16
+ // ============================================================================
17
+ // Configuration
18
+ // ============================================================================
19
+
20
+ export interface GovernorConfig {
21
+ /** Max query length before truncation (default: 1000) */
22
+ maxQueryChars: number;
23
+ /** Max total characters across all injected memories (default: 8000) */
24
+ charBudget: number;
25
+ /** Max number of memories to inject (default: 10) */
26
+ maxItems: number;
27
+ }
28
+
29
+ const DEFAULT_CONFIG: GovernorConfig = {
30
+ maxQueryChars: 1000,
31
+ charBudget: 8000,
32
+ maxItems: 10,
33
+ };
34
+
35
+ export function resolveGovernorConfig(
36
+ overrides?: Partial<GovernorConfig>,
37
+ ): GovernorConfig {
38
+ return { ...DEFAULT_CONFIG, ...overrides };
39
+ }
40
+
41
+ // ============================================================================
42
+ // Governor Session — tracks per-session dedup state
43
+ // ============================================================================
44
+
45
+ export class GovernorSession {
46
+ private readonly injectedIds = new Set<string>();
47
+
48
+ /** Mark a memory ID as already injected in this session. */
49
+ markInjected(id: string): void {
50
+ this.injectedIds.add(id);
51
+ }
52
+
53
+ /** Check whether a memory ID was already injected. */
54
+ wasInjected(id: string): boolean {
55
+ return this.injectedIds.has(id);
56
+ }
57
+
58
+ /** Bulk-mark results after governing. */
59
+ markAll(results: RetrievalResult[]): void {
60
+ for (const r of results) {
61
+ this.injectedIds.add(r.entry.id);
62
+ }
63
+ }
64
+
65
+ /** Number of unique IDs tracked so far. */
66
+ get size(): number {
67
+ return this.injectedIds.size;
68
+ }
69
+ }
70
+
71
+ // ============================================================================
72
+ // Layer 1: Query Truncation
73
+ // ============================================================================
74
+
75
+ export function truncateQuery(
76
+ query: string,
77
+ maxChars: number,
78
+ ): string {
79
+ if (query.length <= maxChars) return query;
80
+ logInfo(`[recall-governor] query truncated: ${query.length} → ${maxChars} chars`);
81
+ return query.slice(0, maxChars);
82
+ }
83
+
84
+ // ============================================================================
85
+ // Layer 2: Evolution State Filter
86
+ // ============================================================================
87
+
88
+ function filterByEvolutionState(results: RetrievalResult[]): RetrievalResult[] {
89
+ const before = results.length;
90
+ const filtered = results.filter((r) => isActiveMemory(r.entry.metadata));
91
+ const dropped = before - filtered.length;
92
+ if (dropped > 0) {
93
+ logInfo(`[recall-governor] evolution filter dropped ${dropped}/${before} inactive memories`);
94
+ }
95
+ return filtered;
96
+ }
97
+
98
+ // ============================================================================
99
+ // Layer 3: Budget Control
100
+ // ============================================================================
101
+
102
+ function applyBudget(
103
+ results: RetrievalResult[],
104
+ config: GovernorConfig,
105
+ ): RetrievalResult[] {
106
+ const kept: RetrievalResult[] = [];
107
+ let totalChars = 0;
108
+
109
+ for (const r of results) {
110
+ if (kept.length >= config.maxItems) break;
111
+ const textLen = r.entry.text.length;
112
+ if (totalChars + textLen > config.charBudget && kept.length > 0) break;
113
+ // Always allow at least one result even if it exceeds budget
114
+ kept.push(r);
115
+ totalChars += textLen;
116
+ }
117
+
118
+ if (kept.length < results.length) {
119
+ logInfo(
120
+ `[recall-governor] budget control: ${kept.length}/${results.length} kept ` +
121
+ `(${totalChars} chars, limit ${config.charBudget})`,
122
+ );
123
+ }
124
+ return kept;
125
+ }
126
+
127
+ // ============================================================================
128
+ // Layer 4: Session Dedup
129
+ // ============================================================================
130
+
131
+ function deduplicateBySession(
132
+ results: RetrievalResult[],
133
+ session: GovernorSession | undefined,
134
+ ): RetrievalResult[] {
135
+ if (!session) return results;
136
+
137
+ const before = results.length;
138
+ const deduped = results.filter((r) => !session.wasInjected(r.entry.id));
139
+ const dropped = before - deduped.length;
140
+ if (dropped > 0) {
141
+ logInfo(`[recall-governor] session dedup dropped ${dropped}/${before} already-injected memories`);
142
+ }
143
+ return deduped;
144
+ }
145
+
146
+ // ============================================================================
147
+ // Public API — compose all layers
148
+ // ============================================================================
149
+
150
+ /**
151
+ * Run the full governance pipeline on retrieval results.
152
+ * Call this after tier-aware filtering in auto-recall, before returning.
153
+ *
154
+ * @param results - Pre-sorted retrieval results (score descending)
155
+ * @param session - Optional GovernorSession for cross-call dedup
156
+ * @param config - Optional config overrides
157
+ * @returns Governed results, ready for injection
158
+ */
159
+ export function governResults(
160
+ results: RetrievalResult[],
161
+ session?: GovernorSession,
162
+ config?: Partial<GovernorConfig>,
163
+ ): RetrievalResult[] {
164
+ if (results.length === 0) return results;
165
+
166
+ const cfg = resolveGovernorConfig(config);
167
+
168
+ // Layer 2: evolution state filter
169
+ let governed = filterByEvolutionState(results);
170
+
171
+ // Layer 3: session dedup (before budget so deduped items don't waste budget slots)
172
+ governed = deduplicateBySession(governed, session);
173
+
174
+ // Layer 4: budget control
175
+ governed = applyBudget(governed, cfg);
176
+
177
+ // Mark newly injected IDs for future dedup
178
+ if (session) {
179
+ session.markAll(governed);
180
+ }
181
+
182
+ return governed;
183
+ }