recallnest 1.4.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 (261) hide show
  1. package/.claude-plugin/CLAUDE.md +29 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.env.example +3 -0
  4. package/.github/workflows/ci.yml +28 -0
  5. package/.mcp.json +9 -0
  6. package/CHANGELOG.md +51 -0
  7. package/CLAUDE.md +42 -0
  8. package/DEMO.md +94 -0
  9. package/EVAL.md +89 -0
  10. package/FAILURES.md +75 -0
  11. package/LICENSE +21 -0
  12. package/OPERATIONS.md +59 -0
  13. package/README.md +427 -0
  14. package/README_CN.md +425 -0
  15. package/ROADMAP.md +198 -0
  16. package/assets/recallnest-workbench.png +0 -0
  17. package/assets/ui/app.js +807 -0
  18. package/assets/ui/index.html +154 -0
  19. package/assets/ui/styles.css +605 -0
  20. package/assets/wechat-qr.jpg +0 -0
  21. package/config.json.example +38 -0
  22. package/daily-recall.sh +87 -0
  23. package/daily-reflections/2026-03-05.md +15 -0
  24. package/daily-reflections/2026-03-06.md +15 -0
  25. package/daily-reflections/2026-03-07.md +7 -0
  26. package/daily-reflections/2026-03-08.md +34 -0
  27. package/daily-reflections/2026-03-09.md +35 -0
  28. package/daily-reflections/2026-03-10.md +36 -0
  29. package/daily-reflections/2026-03-11.md +35 -0
  30. package/daily-reflections/2026-03-12.md +36 -0
  31. package/daily-reflections/2026-03-13.md +35 -0
  32. package/daily-reflections/2026-03-14.md +35 -0
  33. package/daily-reflections/2026-03-15.md +35 -0
  34. package/daily-reflections/2026-03-16.md +41 -0
  35. package/daily-reflections/2026-03-17.md +10 -0
  36. package/daily-reflections/2026-03-18.md +10 -0
  37. package/daily-reflections/2026-03-19.md +10 -0
  38. package/daily-reflections/2026-03-20.md +10 -0
  39. package/daily-reflections/2026-03-21.md +10 -0
  40. package/daily-reflections/2026-03-22.md +10 -0
  41. package/daily-reflections/2026-03-23.md +10 -0
  42. package/daily-reflections/2026-03-24.md +10 -0
  43. package/daily-reflections/2026-03-25.md +32 -0
  44. package/daily-reflections/2026-03-26.md +37 -0
  45. package/daily-reflections/2026-03-27.md +35 -0
  46. package/daily-reflections/2026-03-28.md +35 -0
  47. package/daily-reflections/2026-03-29.md +35 -0
  48. package/daily-reflections/2026-03-30.md +36 -0
  49. package/daily-reflections/2026-03-31.md +35 -0
  50. package/daily-reflections/2026-04-01.md +35 -0
  51. package/docs/api-reference.md +1291 -0
  52. package/docs/architecture.md +168 -0
  53. package/docs/memory-boundary-contract.md +226 -0
  54. package/docs/memory-categories.md +119 -0
  55. package/docs/pipeline-audit-ultramemory.md +177 -0
  56. package/docs/plans/2026-03-08-query-expansion.md +270 -0
  57. package/docs/plans/2026-03-16-continuity-layer.md +196 -0
  58. package/docs/plans/mcp-tiering-plan.md +126 -0
  59. package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
  60. package/docs/self-evolution.md +198 -0
  61. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
  62. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
  63. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
  64. package/eval/cases.json +85 -0
  65. package/eval/continuity/case-seeds.json +90 -0
  66. package/eval/continuity/cases.json +302 -0
  67. package/eval/continuity/memory-seeds.json +26 -0
  68. package/eval/continuity/pattern-seeds.json +100 -0
  69. package/eval/continuity/results-2026-03-16.md +117 -0
  70. package/eval/continuity/results-2026-03-19.md +453 -0
  71. package/eval/continuity/runbook.md +110 -0
  72. package/eval/continuity/seed-cases.md +54 -0
  73. package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
  74. package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
  75. package/eval/reports/2026-03-06-baseline.md +82 -0
  76. package/eval/reports/query-expansion.md +138 -0
  77. package/integrations/README.md +57 -0
  78. package/integrations/claude-code/README.md +133 -0
  79. package/integrations/claude-code/claude-md-snippet.md +31 -0
  80. package/integrations/claude-code/setup.sh +66 -0
  81. package/integrations/claude-code/smoke-continuity.sh +452 -0
  82. package/integrations/codex/README.md +49 -0
  83. package/integrations/codex/agents-md-snippet.md +31 -0
  84. package/integrations/codex/setup.sh +58 -0
  85. package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
  86. package/integrations/examples/langchain/memory-chain.py +111 -0
  87. package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
  88. package/integrations/gemini-cli/README.md +57 -0
  89. package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
  90. package/integrations/gemini-cli/setup.sh +64 -0
  91. package/integrations/shared/install-managed-block.sh +51 -0
  92. package/lm +19 -0
  93. package/package.json +50 -0
  94. package/plan-v1.2-distributable.md +105 -0
  95. package/scripts/archive-distilled-junk.ts +116 -0
  96. package/scripts/backfill-l0l1l2.ts +98 -0
  97. package/scripts/blind-scan.ts +588 -0
  98. package/scripts/cleanup-step1-exact-dups.ts +117 -0
  99. package/scripts/cleanup-step2-template-dups.ts +113 -0
  100. package/scripts/cleanup-step3-stale-content.ts +149 -0
  101. package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
  102. package/scripts/delete-distilled-junk.ts +88 -0
  103. package/scripts/distill-facts.ts +305 -0
  104. package/scripts/health-check.ts +343 -0
  105. package/scripts/incremental-ingest.sh +71 -0
  106. package/scripts/mlp-health-check.ts +382 -0
  107. package/scripts/pull-claude-desktop.ts +515 -0
  108. package/scripts/re-ingest-cc.sh +66 -0
  109. package/scripts/rebalance-memory-health.ts +181 -0
  110. package/scripts/run-desktop-ingest.sh +11 -0
  111. package/scripts/start-server.sh +12 -0
  112. package/scripts/viz-dashboard.html +423 -0
  113. package/scripts/viz-export.ts +703 -0
  114. package/src/__tests__/access-tracker-novelty.test.ts +228 -0
  115. package/src/__tests__/auto-consolidation.test.ts +168 -0
  116. package/src/__tests__/auto-recall.test.ts +131 -0
  117. package/src/__tests__/batch-dedup.test.ts +65 -0
  118. package/src/__tests__/capture-engine.test.ts +1142 -0
  119. package/src/__tests__/capture-heuristic.test.ts +171 -0
  120. package/src/__tests__/cascade-forget.test.ts +210 -0
  121. package/src/__tests__/category-min-scores.test.ts +33 -0
  122. package/src/__tests__/confidence-tracker.test.ts +196 -0
  123. package/src/__tests__/conflict-advisor.test.ts +206 -0
  124. package/src/__tests__/conflict-escalation.test.ts +149 -0
  125. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  126. package/src/__tests__/conflict-output.test.ts +85 -0
  127. package/src/__tests__/conflict-store.test.ts +374 -0
  128. package/src/__tests__/consolidation-engine.test.ts +181 -0
  129. package/src/__tests__/context-composer-pins.test.ts +64 -0
  130. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  131. package/src/__tests__/context-renderer.test.ts +91 -0
  132. package/src/__tests__/continuity-eval.test.ts +275 -0
  133. package/src/__tests__/continuity-schema.test.ts +97 -0
  134. package/src/__tests__/core-recall-eval.test.ts +235 -0
  135. package/src/__tests__/core-summary.test.ts +109 -0
  136. package/src/__tests__/dedup-actions.test.ts +58 -0
  137. package/src/__tests__/deterministic-id.test.ts +39 -0
  138. package/src/__tests__/doctor.test.ts +303 -0
  139. package/src/__tests__/drill-down.test.ts +108 -0
  140. package/src/__tests__/embedder.test.ts +122 -0
  141. package/src/__tests__/entity-resolver.test.ts +58 -0
  142. package/src/__tests__/event-segmenter.test.ts +153 -0
  143. package/src/__tests__/hotness-blend.test.ts +76 -0
  144. package/src/__tests__/ingest.test.ts +360 -0
  145. package/src/__tests__/kg-extractor.test.ts +184 -0
  146. package/src/__tests__/kg-store.test.ts +198 -0
  147. package/src/__tests__/llm-consolidation.test.ts +226 -0
  148. package/src/__tests__/memory-boundaries.test.ts +237 -0
  149. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  150. package/src/__tests__/memory-output.test.ts +215 -0
  151. package/src/__tests__/multi-vector.test.ts +242 -0
  152. package/src/__tests__/ppr-traversal.test.ts +256 -0
  153. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  154. package/src/__tests__/preference-matcher.test.ts +230 -0
  155. package/src/__tests__/preference-slots.test.ts +106 -0
  156. package/src/__tests__/prospective-memory.test.ts +283 -0
  157. package/src/__tests__/query-entity-detector.test.ts +171 -0
  158. package/src/__tests__/query-expander.test.ts +34 -0
  159. package/src/__tests__/recall-mode.test.ts +64 -0
  160. package/src/__tests__/result-synthesizer.test.ts +198 -0
  161. package/src/__tests__/resume-context.test.ts +4743 -0
  162. package/src/__tests__/retrieval-trace.test.ts +213 -0
  163. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  164. package/src/__tests__/retroactive-boost.test.ts +223 -0
  165. package/src/__tests__/scope-inventory.test.ts +200 -0
  166. package/src/__tests__/scope-policy.test.ts +69 -0
  167. package/src/__tests__/session-checkpoint.test.ts +259 -0
  168. package/src/__tests__/session-gc.test.ts +215 -0
  169. package/src/__tests__/store-scope-audit.test.ts +81 -0
  170. package/src/__tests__/temporal-parser.test.ts +241 -0
  171. package/src/__tests__/term-registry.test.ts +121 -0
  172. package/src/__tests__/tool-output-compressor.test.ts +133 -0
  173. package/src/__tests__/version-manager.test.ts +213 -0
  174. package/src/__tests__/workflow-observation.test.ts +400 -0
  175. package/src/access-tracker.ts +316 -0
  176. package/src/adaptive-retrieval.ts +64 -0
  177. package/src/api-server.ts +667 -0
  178. package/src/asset-sync.ts +79 -0
  179. package/src/auto-consolidation.ts +133 -0
  180. package/src/auto-gc.ts +124 -0
  181. package/src/auto-recall.ts +95 -0
  182. package/src/capture-engine.ts +931 -0
  183. package/src/capture-heuristic.ts +177 -0
  184. package/src/cascade-forget.ts +135 -0
  185. package/src/chunker.ts +253 -0
  186. package/src/cli.ts +1917 -0
  187. package/src/confidence-tracker.ts +166 -0
  188. package/src/conflict-advisor.ts +433 -0
  189. package/src/conflict-engine.ts +181 -0
  190. package/src/conflict-escalation.ts +138 -0
  191. package/src/conflict-lifecycle.ts +67 -0
  192. package/src/conflict-output.ts +218 -0
  193. package/src/conflict-schema.ts +130 -0
  194. package/src/conflict-store.ts +127 -0
  195. package/src/consolidation-engine.ts +256 -0
  196. package/src/context-composer-pins.ts +183 -0
  197. package/src/context-composer-queries.ts +121 -0
  198. package/src/context-composer-scope.ts +56 -0
  199. package/src/context-composer-stable-selection.ts +200 -0
  200. package/src/context-composer-stable.ts +217 -0
  201. package/src/context-composer-task-fallbacks.ts +36 -0
  202. package/src/context-composer-task-ranking.ts +197 -0
  203. package/src/context-composer-task-results.ts +137 -0
  204. package/src/context-composer-task-selection.ts +342 -0
  205. package/src/context-composer-text.ts +57 -0
  206. package/src/context-composer.ts +300 -0
  207. package/src/context-renderer.ts +179 -0
  208. package/src/decay-engine.ts +189 -0
  209. package/src/doctor.ts +543 -0
  210. package/src/embedder.ts +557 -0
  211. package/src/entity-resolver.ts +131 -0
  212. package/src/eval.ts +632 -0
  213. package/src/event-segmenter.ts +216 -0
  214. package/src/ingest.ts +1822 -0
  215. package/src/kg-extractor.ts +217 -0
  216. package/src/kg-store.ts +353 -0
  217. package/src/legacy-scope-review.ts +53 -0
  218. package/src/llm-client.ts +629 -0
  219. package/src/llm-consolidation.ts +168 -0
  220. package/src/mcp-server.ts +1337 -0
  221. package/src/memory-assets.ts +475 -0
  222. package/src/memory-boundaries.ts +354 -0
  223. package/src/memory-health-rebalance.ts +220 -0
  224. package/src/memory-output.ts +474 -0
  225. package/src/memory-schema.ts +184 -0
  226. package/src/multi-vector.ts +234 -0
  227. package/src/noise-filter.ts +155 -0
  228. package/src/ppr-traversal.ts +279 -0
  229. package/src/preference-matcher.ts +174 -0
  230. package/src/preference-slots.ts +259 -0
  231. package/src/prospective-memory.ts +198 -0
  232. package/src/query-entity-detector.ts +207 -0
  233. package/src/query-expander.ts +98 -0
  234. package/src/result-synthesizer.ts +114 -0
  235. package/src/retrieval-profiles.ts +101 -0
  236. package/src/retrieval-trace.ts +94 -0
  237. package/src/retriever.ts +1338 -0
  238. package/src/retroactive-boost.ts +154 -0
  239. package/src/rif.ts +73 -0
  240. package/src/runtime-config.ts +172 -0
  241. package/src/schema-utils.ts +56 -0
  242. package/src/scope-inventory.ts +310 -0
  243. package/src/scope-policy.ts +142 -0
  244. package/src/session-engine.ts +168 -0
  245. package/src/session-output.ts +96 -0
  246. package/src/session-schema.ts +60 -0
  247. package/src/session-store.ts +293 -0
  248. package/src/stderr-log.ts +16 -0
  249. package/src/store.ts +903 -0
  250. package/src/temporal-parser.ts +318 -0
  251. package/src/term-registry.ts +791 -0
  252. package/src/tool-output-compressor.ts +115 -0
  253. package/src/tracker.ts +57 -0
  254. package/src/ui-server.ts +430 -0
  255. package/src/version-manager.ts +172 -0
  256. package/src/workflow-observation-engine.ts +316 -0
  257. package/src/workflow-observation-managed.ts +83 -0
  258. package/src/workflow-observation-output.ts +86 -0
  259. package/src/workflow-observation-schema.ts +87 -0
  260. package/src/workflow-observation-scope-review.ts +409 -0
  261. package/src/workflow-observation-store.ts +69 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Preference Matcher — Tier 3.6
3
+ *
4
+ * When a new preference is about to be stored, checks for semantically similar
5
+ * existing preferences in the same scope. If found, uses LLM to decide:
6
+ * - MERGE: combine into existing entry (update text)
7
+ * - SKIP: incoming is a duplicate, drop it
8
+ * - CREATE: genuinely new preference, store normally
9
+ *
10
+ * This prevents duplicate preference accumulation without being too aggressive
11
+ * (unlike hard dedup which might miss nuanced differences).
12
+ */
13
+
14
+ import type { MemoryStore, MemoryEntry } from "./store.js";
15
+ import type { Embedder } from "./embedder.js";
16
+ import type { LLMClient } from "./llm-client.js";
17
+ import { logInfo } from "./stderr-log.js";
18
+
19
+ // ============================================================================
20
+ // Configuration
21
+ // ============================================================================
22
+
23
+ export interface PreferenceMatcherConfig {
24
+ /** Minimum vector similarity to consider a match (default: 0.78) */
25
+ similarityThreshold: number;
26
+ /** Maximum candidates to check (default: 3) */
27
+ maxCandidates: number;
28
+ }
29
+
30
+ export const DEFAULT_PREFERENCE_MATCHER_CONFIG: PreferenceMatcherConfig = {
31
+ similarityThreshold: 0.78,
32
+ maxCandidates: 3,
33
+ };
34
+
35
+ // ============================================================================
36
+ // Types
37
+ // ============================================================================
38
+
39
+ export interface PreferenceMatchResult {
40
+ /** What to do with the incoming preference */
41
+ action: "merge" | "skip" | "create";
42
+ /** ID of the existing entry to merge into (when action=merge) */
43
+ mergeTargetId?: string;
44
+ /** Merged text (when action=merge, LLM-generated) */
45
+ mergedText?: string;
46
+ /** Reason for the decision */
47
+ reason: string;
48
+ }
49
+
50
+ // ============================================================================
51
+ // Core
52
+ // ============================================================================
53
+
54
+ /**
55
+ * Check if an incoming preference matches existing ones and decide what to do.
56
+ *
57
+ * @param text The new preference text
58
+ * @param vector Pre-computed embedding vector for the text
59
+ * @param scope The scope to search within
60
+ * @param store Memory store
61
+ * @param llm LLM client (null = always create)
62
+ * @param config Optional config overrides
63
+ */
64
+ export async function matchPreference(
65
+ text: string,
66
+ vector: number[],
67
+ scope: string,
68
+ store: MemoryStore,
69
+ llm: LLMClient | null,
70
+ config: PreferenceMatcherConfig = DEFAULT_PREFERENCE_MATCHER_CONFIG,
71
+ ): Promise<PreferenceMatchResult> {
72
+ // Find similar preferences in the same scope
73
+ const candidates = await store.vectorSearch(
74
+ vector,
75
+ config.maxCandidates,
76
+ config.similarityThreshold,
77
+ [scope],
78
+ );
79
+
80
+ // Filter to only preferences category
81
+ const prefMatches = candidates.filter(c => c.entry.category === "preferences");
82
+
83
+ if (prefMatches.length === 0) {
84
+ return { action: "create", reason: "no-similar-preferences" };
85
+ }
86
+
87
+ // Without LLM, fall back to simple similarity-based decision
88
+ if (!llm) {
89
+ const topScore = prefMatches[0].score;
90
+ if (topScore >= 0.92) {
91
+ return { action: "skip", reason: "high-similarity-no-llm" };
92
+ }
93
+ return { action: "create", reason: "moderate-similarity-no-llm" };
94
+ }
95
+
96
+ // Ask LLM to decide
97
+ const topMatch = prefMatches[0];
98
+ try {
99
+ const decision = await llm.dedupDecision(text, topMatch.entry.text);
100
+
101
+ if (decision.action === "SKIP") {
102
+ return {
103
+ action: "skip",
104
+ reason: `llm-skip: ${decision.reason}`,
105
+ };
106
+ }
107
+
108
+ if (decision.action === "MERGE") {
109
+ // Generate merged text
110
+ const mergedText = await generateMergedPreference(llm, text, topMatch.entry.text);
111
+ return {
112
+ action: "merge",
113
+ mergeTargetId: topMatch.entry.id,
114
+ mergedText: mergedText ?? `${topMatch.entry.text}; ${text}`,
115
+ reason: `llm-merge: ${decision.reason}`,
116
+ };
117
+ }
118
+
119
+ return { action: "create", reason: `llm-create: ${decision.reason}` };
120
+ } catch {
121
+ // LLM failure → safe default: create
122
+ return { action: "create", reason: "llm-error" };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Apply the match result: either merge into existing or signal to skip/create.
128
+ *
129
+ * @returns true if handled (merged or skipped), false if caller should proceed to create
130
+ */
131
+ export async function applyPreferenceMatch(
132
+ result: PreferenceMatchResult,
133
+ store: MemoryStore,
134
+ scope: string,
135
+ ): Promise<{ handled: boolean; entry?: MemoryEntry }> {
136
+ if (result.action === "skip") {
137
+ logInfo(`[INFO] Preference skipped (duplicate): ${result.reason}`);
138
+ return { handled: true };
139
+ }
140
+
141
+ if (result.action === "merge" && result.mergeTargetId && result.mergedText) {
142
+ const updated = await store.update(
143
+ result.mergeTargetId,
144
+ { text: result.mergedText },
145
+ [scope],
146
+ );
147
+ if (updated) {
148
+ logInfo(`[INFO] Preference merged into ${result.mergeTargetId.slice(0, 8)}: ${result.reason}`);
149
+ return { handled: true, entry: updated };
150
+ }
151
+ }
152
+
153
+ return { handled: false };
154
+ }
155
+
156
+ // ============================================================================
157
+ // Helpers
158
+ // ============================================================================
159
+
160
+ async function generateMergedPreference(
161
+ llm: LLMClient,
162
+ newText: string,
163
+ existingText: string,
164
+ ): Promise<string | null> {
165
+ try {
166
+ return await llm.synthesizeFragments(
167
+ [existingText, newText],
168
+ "merge preferences",
169
+ 300,
170
+ );
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
@@ -0,0 +1,259 @@
1
+ const ROLE_PREFIX_RE = /^\[(用户|助手)\]\s*/gm;
2
+ const PREFERENCE_SPLIT_RE = /(?:、|,|,|\/|以及|及|与|和| and | & )/iu;
3
+ const PREFERENCE_CLAUSE_STOP_RE = /(?:因为|所以|但是|不过|if |when |because |but )/iu;
4
+ const LEADING_ENGLISH_ARTICLE_RE = /^(?:the|a|an)\s+/iu;
5
+ const REPLY_STYLE_CONTEXT_RE = /(?:\brepl(?:y|ies)\b|\bresponse(?:s)?\b|\brespond(?:ing)?\b|\bwriting\b|\btone\b|\bvoice\b|\bstyle\b|回复|回答|语气|写作|表达|风格|措辞|说话)/iu;
6
+ const REPLY_STYLE_PREFERENCE_CUE_RE = /(?:\bprefer(?:s|red)?\b|\bwants?\b|\bplease\b|\bavoid\b|\bkeep\b|\bneed(?:s|ed)?\b|偏好|喜欢|希望|请|要|不要|不接受|避免|尽量|最好)/iu;
7
+ const BRAND_ITEM_PREFERENCE_PATTERNS = [
8
+ /(?:^|[\s,,。;;!!??])(?:我|用户)?(?:很|更|还)?(?:喜欢|爱吃|偏爱|常吃|想吃)(?:吃|喝|用|买)?(?<brand>[\p{Script=Han}A-Za-z0-9&·'\-]{1,24})的(?<items>[\p{Script=Han}A-Za-z0-9&·'\-\s、,,和及与/]{1,80})/u,
9
+ /\b(?:i|user)?\s*(?:really\s+|still\s+|also\s+)?(?:like|love|prefer|enjoy)\s+(?<items>[a-z0-9'&\-\s]{1,80})\s+from\s+(?<brand>[a-z0-9'&\-\s]{1,40})/iu,
10
+ ] as const;
11
+ const TOOL_CHOICE_PREFERENCE_PATTERNS = [
12
+ /^(?:\s*)(?:i|user)?\s*(?:really\s+|still\s+|also\s+)?(?:use|uses|using|prefer|prefers|preferred|pick|picks|picked)\s+(?<preferred>[a-z0-9.+#/_\-\s]{1,24})\s+(?:over|instead of|rather than)\s+(?<avoided>[a-z0-9.+#/_\-\s]{1,24})(?:\s*[.!?。!?])?\s*$/iu,
13
+ /(?:更喜欢|喜欢|偏好|倾向|优先)(?:使用|用)?(?<preferred>[A-Za-z0-9.+#/_\-\s]{1,24})(?:\s*)(?:而不是|而非|不用|不选|代替|替代|优先于|胜过)(?:\s*)(?<avoided>[A-Za-z0-9.+#/_\-\s]{1,24})/u,
14
+ /(?:使用|用)(?<preferred>[A-Za-z0-9.+#/_\-\s]{1,24})(?:\s*)(?:不用|代替|替代)(?<avoided>[A-Za-z0-9.+#/_\-\s]{1,24})/u,
15
+ ] as const;
16
+ const REPLY_STYLE_TRAIT_PATTERNS = [
17
+ {
18
+ key: "concise",
19
+ pattern: /(?:\bconcise\b|\bbrief\b|\bshort\b|简洁|简短|短句|少废话|少空话|精炼)/iu,
20
+ },
21
+ {
22
+ key: "direct",
23
+ pattern: /(?:\bdirect\b|\bstraight(?:forward)?\b|to the point|直说|直接|不绕弯|别绕弯|开门见山)/iu,
24
+ },
25
+ {
26
+ key: "colloquial",
27
+ pattern: /(?:\bcolloquial\b|\bconversational\b|\bcasual\b|口语化|像聊天|像朋友聊天)/iu,
28
+ },
29
+ {
30
+ key: "grounded",
31
+ pattern: /(?:\bgrounded\b|non[-\s]?salesy|non[-\s]?hype|avoid sales language|avoid hype|不浮夸|不鸡血|不说教|不营销(?:腔)?|别太飘|不端着|别太端着)/iu,
32
+ },
33
+ {
34
+ key: "technical",
35
+ pattern: /(?:\btechnical\b|逻辑清晰|讲清楚|讲明白|技术向)/iu,
36
+ },
37
+ ] as const;
38
+ const TOOL_ALIAS_MAP: Record<string, string> = {
39
+ bun: "bun",
40
+ bunjs: "bun",
41
+ bunx: "bunx",
42
+ node: "node",
43
+ "node.js": "node",
44
+ nodejs: "node",
45
+ npm: "npm",
46
+ pnpm: "pnpm",
47
+ yarn: "yarn",
48
+ npx: "npx",
49
+ uv: "uv",
50
+ pip: "pip",
51
+ python: "python",
52
+ docker: "docker",
53
+ podman: "podman",
54
+ rg: "rg",
55
+ ripgrep: "rg",
56
+ grep: "grep",
57
+ git: "git",
58
+ claude: "claude-code",
59
+ claudecode: "claude-code",
60
+ "claude-code": "claude-code",
61
+ codex: "codex",
62
+ gemini: "gemini",
63
+ cursor: "cursor",
64
+ vscode: "vscode",
65
+ "vs-code": "vscode",
66
+ vscodeinsiders: "vscode",
67
+ vim: "vim",
68
+ neovim: "neovim",
69
+ nvim: "neovim",
70
+ } as const;
71
+
72
+ export interface ParsedBrandItemPreference {
73
+ brand: string;
74
+ items: string[];
75
+ aggregate: boolean;
76
+ }
77
+
78
+ export interface AtomicBrandItemPreferenceSlot {
79
+ type: "brand-item";
80
+ brand: string;
81
+ item: string;
82
+ }
83
+
84
+ export interface ReplyStylePreferenceSlot {
85
+ type: "reply-style";
86
+ traits: string[];
87
+ }
88
+
89
+ export interface ToolChoicePreferenceSlot {
90
+ type: "tool-choice";
91
+ preferredTool: string;
92
+ avoidedTool: string;
93
+ }
94
+
95
+ export type PreferenceSlot =
96
+ | AtomicBrandItemPreferenceSlot
97
+ | ReplyStylePreferenceSlot
98
+ | ToolChoicePreferenceSlot;
99
+
100
+ function normalizePreferenceText(value: string): string {
101
+ return value
102
+ .replace(ROLE_PREFIX_RE, "")
103
+ .replace(/\s+/g, " ")
104
+ .trim();
105
+ }
106
+
107
+ export function normalizePreferenceToken(value: string): string {
108
+ return normalizePreferenceTokenWithOptions(value);
109
+ }
110
+
111
+ function normalizePreferenceTokenWithOptions(
112
+ value: string,
113
+ options?: { stripLeadingEnglishArticle?: boolean },
114
+ ): string {
115
+ let normalized = normalizePreferenceText(value)
116
+ .replace(/^[“"'`‘’.]+|[”"'`‘’.。!!??,,;;::]+$/gu, "")
117
+ .trim();
118
+
119
+ if (options?.stripLeadingEnglishArticle) {
120
+ normalized = normalized.replace(LEADING_ENGLISH_ARTICLE_RE, "");
121
+ }
122
+
123
+ return normalized
124
+ .replace(/\s+/g, "")
125
+ .toLowerCase();
126
+ }
127
+
128
+ function splitPreferenceItems(
129
+ rawItems: string,
130
+ options?: { stripLeadingEnglishArticle?: boolean },
131
+ ): string[] {
132
+ const trimmed = rawItems.split(PREFERENCE_CLAUSE_STOP_RE)[0] || rawItems;
133
+ return trimmed
134
+ .split(PREFERENCE_SPLIT_RE)
135
+ .map((item) => normalizePreferenceTokenWithOptions(item, options))
136
+ .filter((item) => item.length > 0);
137
+ }
138
+
139
+ export function parseBrandItemPreference(text: string): ParsedBrandItemPreference | null {
140
+ const normalizedText = normalizePreferenceText(text);
141
+
142
+ for (const pattern of BRAND_ITEM_PREFERENCE_PATTERNS) {
143
+ const match = normalizedText.match(pattern);
144
+ if (!match?.groups) continue;
145
+
146
+ const brand = normalizePreferenceToken(match.groups.brand || "");
147
+ const items = splitPreferenceItems(match.groups.items || "", {
148
+ // Strip leading "the/a/an" from English item names so
149
+ // "the Big Mac" and "Big Mac" land on the same atomic slot.
150
+ stripLeadingEnglishArticle: pattern === BRAND_ITEM_PREFERENCE_PATTERNS[1],
151
+ });
152
+ if (!brand || items.length === 0) continue;
153
+
154
+ return {
155
+ brand,
156
+ items,
157
+ aggregate: items.length > 1,
158
+ };
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ export function inferAtomicBrandItemPreferenceSlot(text: string): AtomicBrandItemPreferenceSlot | null {
165
+ const parsed = parseBrandItemPreference(text);
166
+ if (!parsed || parsed.aggregate || parsed.items.length !== 1) {
167
+ return null;
168
+ }
169
+
170
+ return {
171
+ type: "brand-item",
172
+ brand: parsed.brand,
173
+ item: parsed.items[0],
174
+ };
175
+ }
176
+
177
+ export function inferReplyStylePreferenceSlot(text: string): ReplyStylePreferenceSlot | null {
178
+ const normalizedText = normalizePreferenceText(text);
179
+ const traits = REPLY_STYLE_TRAIT_PATTERNS
180
+ .filter(({ pattern }) => pattern.test(normalizedText))
181
+ .map(({ key }) => key);
182
+
183
+ if (traits.length === 0) {
184
+ return null;
185
+ }
186
+
187
+ const hasContext = REPLY_STYLE_CONTEXT_RE.test(normalizedText);
188
+ const hasPreferenceCue = REPLY_STYLE_PREFERENCE_CUE_RE.test(normalizedText);
189
+ if (!hasContext && (!hasPreferenceCue || traits.length < 2)) {
190
+ return null;
191
+ }
192
+
193
+ return {
194
+ type: "reply-style",
195
+ traits: Array.from(new Set(traits)).sort(),
196
+ };
197
+ }
198
+
199
+ function normalizeToolToken(value: string): string {
200
+ const normalized = normalizePreferenceToken(value);
201
+ return TOOL_ALIAS_MAP[normalized] || normalized;
202
+ }
203
+
204
+ function isKnownToolToken(value: string): boolean {
205
+ return Boolean(value && (TOOL_ALIAS_MAP[value] || Object.values(TOOL_ALIAS_MAP).includes(value as any)));
206
+ }
207
+
208
+ export function inferToolChoicePreferenceSlot(text: string): ToolChoicePreferenceSlot | null {
209
+ const normalizedText = normalizePreferenceText(text);
210
+
211
+ for (const pattern of TOOL_CHOICE_PREFERENCE_PATTERNS) {
212
+ const match = normalizedText.match(pattern);
213
+ if (!match?.groups) continue;
214
+
215
+ const preferredTool = normalizeToolToken(match.groups.preferred || "");
216
+ const avoidedTool = normalizeToolToken(match.groups.avoided || "");
217
+ if (!preferredTool || !avoidedTool || preferredTool === avoidedTool) continue;
218
+ if (!isKnownToolToken(preferredTool) || !isKnownToolToken(avoidedTool)) continue;
219
+
220
+ return {
221
+ type: "tool-choice",
222
+ preferredTool,
223
+ avoidedTool,
224
+ };
225
+ }
226
+
227
+ return null;
228
+ }
229
+
230
+ export function inferPreferenceSlot(text: string): PreferenceSlot | null {
231
+ return inferAtomicBrandItemPreferenceSlot(text)
232
+ || inferReplyStylePreferenceSlot(text)
233
+ || inferToolChoicePreferenceSlot(text);
234
+ }
235
+
236
+ export function samePreferenceSlot(
237
+ left: PreferenceSlot | null,
238
+ right: PreferenceSlot | null,
239
+ ): boolean {
240
+ if (!left || !right || left.type !== right.type) {
241
+ return false;
242
+ }
243
+
244
+ if (left.type === "brand-item" && right.type === "brand-item") {
245
+ return left.brand === right.brand && left.item === right.item;
246
+ }
247
+
248
+ if (left.type === "reply-style" && right.type === "reply-style") {
249
+ return left.traits.length === right.traits.length &&
250
+ left.traits.every((trait, index) => trait === right.traits[index]);
251
+ }
252
+
253
+ if (left.type === "tool-choice" && right.type === "tool-choice") {
254
+ return left.preferredTool === right.preferredTool &&
255
+ left.avoidedTool === right.avoidedTool;
256
+ }
257
+
258
+ return false;
259
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Prospective Memory — Tier 3.4
3
+ *
4
+ * "Remind me about Y next time X comes up"
5
+ *
6
+ * Stores reminders as pattern memories with special `prospective` metadata.
7
+ * During retrieval, pending triggers are checked against the query.
8
+ * When a trigger fires, the reminder is injected into context.
9
+ *
10
+ * Data model: stored as category="patterns" with metadata.prospective = {
11
+ * trigger, action, status, createdAt, firedAt?, expiresAt?
12
+ * }
13
+ */
14
+
15
+ import type { MemoryStore, MemoryEntry } from "./store.js";
16
+ import type { Embedder } from "./embedder.js";
17
+ import { logInfo } from "./stderr-log.js";
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export interface ProspectiveMetadata {
24
+ trigger: string;
25
+ action: string;
26
+ status: "pending" | "fired" | "expired";
27
+ createdAt: string;
28
+ firedAt?: string;
29
+ expiresAt?: string;
30
+ }
31
+
32
+ export interface Reminder {
33
+ entryId: string;
34
+ trigger: string;
35
+ action: string;
36
+ }
37
+
38
+ export interface SetReminderParams {
39
+ trigger: string;
40
+ action: string;
41
+ scope: string;
42
+ expiresInDays?: number;
43
+ }
44
+
45
+ // ============================================================================
46
+ // Core Functions
47
+ // ============================================================================
48
+
49
+ /**
50
+ * Create a new reminder (prospective memory).
51
+ * Stored as a pattern entry with prospective metadata.
52
+ */
53
+ export async function setReminder(
54
+ store: MemoryStore,
55
+ embedder: Embedder,
56
+ params: SetReminderParams,
57
+ ): Promise<MemoryEntry> {
58
+ const text = `[Reminder] When "${params.trigger}" comes up: ${params.action}`;
59
+ const vector = await embedder.embedPassage(text);
60
+
61
+ const prospective: ProspectiveMetadata = {
62
+ trigger: params.trigger,
63
+ action: params.action,
64
+ status: "pending",
65
+ createdAt: new Date().toISOString(),
66
+ ...(params.expiresInDays
67
+ ? { expiresAt: new Date(Date.now() + params.expiresInDays * 86_400_000).toISOString() }
68
+ : {}),
69
+ };
70
+
71
+ return store.store({
72
+ text,
73
+ vector,
74
+ category: "patterns",
75
+ scope: params.scope,
76
+ importance: 0.75,
77
+ metadata: JSON.stringify({
78
+ prospective,
79
+ tier: "working",
80
+ boundary: { layer: "durable", authority: "user" },
81
+ }),
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Check pending reminders against a query text.
87
+ * Returns reminders whose trigger matches the query (simple keyword check).
88
+ *
89
+ * @param store Memory store
90
+ * @param embedder Embedder for vector search
91
+ * @param query The user's query/message
92
+ * @param scopeFilter Scopes to check
93
+ * @returns Array of matching reminders (may be empty)
94
+ */
95
+ export async function checkTriggers(
96
+ store: MemoryStore,
97
+ embedder: Embedder,
98
+ query: string,
99
+ scopeFilter?: string[],
100
+ ): Promise<Reminder[]> {
101
+ // Vector search for similar prospective memories
102
+ const queryVector = await embedder.embedPassage(query);
103
+ const candidates = await store.vectorSearch(queryVector, 10, 0.3, scopeFilter);
104
+
105
+ const now = new Date().toISOString();
106
+ const reminders: Reminder[] = [];
107
+
108
+ for (const candidate of candidates) {
109
+ const meta = parseMetadata(candidate.entry.metadata);
110
+ const prospective = meta.prospective as ProspectiveMetadata | undefined;
111
+
112
+ if (!prospective || prospective.status !== "pending") continue;
113
+
114
+ // Check expiration
115
+ if (prospective.expiresAt && prospective.expiresAt < now) {
116
+ // Mark as expired
117
+ prospective.status = "expired";
118
+ meta.prospective = prospective;
119
+ await store.update(candidate.entry.id, { metadata: JSON.stringify(meta) }, scopeFilter);
120
+ continue;
121
+ }
122
+
123
+ // Check if trigger matches query (case-insensitive keyword match)
124
+ if (triggerMatches(prospective.trigger, query)) {
125
+ reminders.push({
126
+ entryId: candidate.entry.id,
127
+ trigger: prospective.trigger,
128
+ action: prospective.action,
129
+ });
130
+ }
131
+ }
132
+
133
+ return reminders;
134
+ }
135
+
136
+ /**
137
+ * Fire a reminder — mark it as fired and return the action text.
138
+ */
139
+ export async function fireReminder(
140
+ store: MemoryStore,
141
+ entryId: string,
142
+ scopeFilter?: string[],
143
+ ): Promise<string | null> {
144
+ const entry = await store.getById(entryId);
145
+ if (!entry) return null;
146
+
147
+ const meta = parseMetadata(entry.metadata);
148
+ const prospective = meta.prospective as ProspectiveMetadata | undefined;
149
+ if (!prospective || prospective.status !== "pending") return null;
150
+
151
+ prospective.status = "fired";
152
+ prospective.firedAt = new Date().toISOString();
153
+ meta.prospective = prospective;
154
+
155
+ await store.update(entryId, { metadata: JSON.stringify(meta) }, scopeFilter);
156
+
157
+ logInfo(`[INFO] Reminder fired: "${prospective.trigger}" → ${prospective.action}`);
158
+
159
+ return prospective.action;
160
+ }
161
+
162
+ /**
163
+ * Format fired reminders for injection into context.
164
+ */
165
+ export function formatReminders(reminders: Reminder[]): string[] {
166
+ return reminders.map(r =>
167
+ `[Reminder] Triggered by "${r.trigger}": ${r.action}`
168
+ );
169
+ }
170
+
171
+ // ============================================================================
172
+ // Helpers
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Check if a trigger string matches a query.
177
+ * Uses case-insensitive keyword matching with word boundary awareness.
178
+ */
179
+ function triggerMatches(trigger: string, query: string): boolean {
180
+ const triggerLower = trigger.toLowerCase();
181
+ const queryLower = query.toLowerCase();
182
+
183
+ // Split trigger into keywords
184
+ const keywords = triggerLower.split(/\s+/).filter(w => w.length >= 2);
185
+
186
+ if (keywords.length === 0) return false;
187
+
188
+ // All keywords must appear in query
189
+ return keywords.every(kw => queryLower.includes(kw));
190
+ }
191
+
192
+ function parseMetadata(raw?: string): Record<string, unknown> {
193
+ try {
194
+ return JSON.parse(raw || "{}") as Record<string, unknown>;
195
+ } catch {
196
+ return {};
197
+ }
198
+ }