recallnest 2.3.0 → 2.6.0

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 (258) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +49 -38
  3. package/README_CN.md +46 -31
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +25 -4
  15. package/src/__tests__/access-tracker-novelty.test.ts +304 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/api-server-security.test.ts +102 -0
  21. package/src/__tests__/auto-consolidation.test.ts +168 -0
  22. package/src/__tests__/auto-recall.test.ts +131 -0
  23. package/src/__tests__/babel-integration.test.ts +144 -0
  24. package/src/__tests__/batch-dedup.test.ts +65 -0
  25. package/src/__tests__/belief-history.test.ts +220 -0
  26. package/src/__tests__/canary-eval.test.ts +114 -0
  27. package/src/__tests__/capture-engine.test.ts +1328 -0
  28. package/src/__tests__/capture-heuristic.test.ts +171 -0
  29. package/src/__tests__/cascade-forget.test.ts +210 -0
  30. package/src/__tests__/category-min-scores.test.ts +116 -0
  31. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  32. package/src/__tests__/circuit-breaker.test.ts +99 -0
  33. package/src/__tests__/cluster-consolidation.test.ts +945 -0
  34. package/src/__tests__/confidence-metadata.test.ts +125 -0
  35. package/src/__tests__/confidence-tracker.test.ts +261 -0
  36. package/src/__tests__/conflict-advisor.test.ts +206 -0
  37. package/src/__tests__/conflict-escalation.test.ts +149 -0
  38. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  39. package/src/__tests__/conflict-output.test.ts +85 -0
  40. package/src/__tests__/conflict-store.test.ts +418 -0
  41. package/src/__tests__/connector-v1.test.ts +181 -0
  42. package/src/__tests__/consolidation-engine.test.ts +430 -0
  43. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  44. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  45. package/src/__tests__/context-composer-pins.test.ts +64 -0
  46. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  47. package/src/__tests__/context-reconstructor.test.ts +65 -0
  48. package/src/__tests__/context-renderer.test.ts +91 -0
  49. package/src/__tests__/continuity-eval.test.ts +275 -0
  50. package/src/__tests__/continuity-schema.test.ts +97 -0
  51. package/src/__tests__/contracts.test.ts +262 -0
  52. package/src/__tests__/conversation-importer.test.ts +692 -0
  53. package/src/__tests__/core-recall-eval.test.ts +235 -0
  54. package/src/__tests__/core-summary.test.ts +109 -0
  55. package/src/__tests__/data-checkup.test.ts +276 -0
  56. package/src/__tests__/decay-exemption.test.ts +129 -0
  57. package/src/__tests__/dedup-actions.test.ts +58 -0
  58. package/src/__tests__/dedup-guard.test.ts +39 -0
  59. package/src/__tests__/deterministic-id.test.ts +39 -0
  60. package/src/__tests__/distill-lock.test.ts +305 -0
  61. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  62. package/src/__tests__/doctor.test.ts +319 -0
  63. package/src/__tests__/dream-pipeline.test.ts +780 -0
  64. package/src/__tests__/drill-down.test.ts +108 -0
  65. package/src/__tests__/embedder.test.ts +122 -0
  66. package/src/__tests__/emotion-decay.test.ts +46 -0
  67. package/src/__tests__/emotion-detector.test.ts +56 -0
  68. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  69. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  70. package/src/__tests__/entity-resolver.test.ts +58 -0
  71. package/src/__tests__/env-config.test.ts +151 -0
  72. package/src/__tests__/error-signature-boost.test.ts +87 -0
  73. package/src/__tests__/error-signature.test.ts +95 -0
  74. package/src/__tests__/error-taxonomy.test.ts +129 -0
  75. package/src/__tests__/ethics-layer.test.ts +476 -0
  76. package/src/__tests__/event-segmenter.test.ts +153 -0
  77. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  78. package/src/__tests__/frequency-tracker.test.ts +113 -0
  79. package/src/__tests__/freshness.test.ts +186 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +283 -0
  82. package/src/__tests__/ingest-chunking.test.ts +44 -0
  83. package/src/__tests__/ingest.test.ts +360 -0
  84. package/src/__tests__/interference-detector.test.ts +203 -0
  85. package/src/__tests__/kg-extractor.test.ts +192 -0
  86. package/src/__tests__/kg-store.test.ts +300 -0
  87. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  88. package/src/__tests__/llm-consolidation.test.ts +226 -0
  89. package/src/__tests__/maintenance-locks.test.ts +189 -0
  90. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  91. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  92. package/src/__tests__/memory-boundaries.test.ts +282 -0
  93. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  94. package/src/__tests__/memory-evolution.test.ts +271 -0
  95. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  96. package/src/__tests__/memory-lint.test.ts +421 -0
  97. package/src/__tests__/memory-output.test.ts +215 -0
  98. package/src/__tests__/memory-promotion.test.ts +315 -0
  99. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  100. package/src/__tests__/multi-vector.test.ts +242 -0
  101. package/src/__tests__/narrative-architecture.test.ts +456 -0
  102. package/src/__tests__/noise-filter.test.ts +205 -0
  103. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  104. package/src/__tests__/obsidian-connector.test.ts +363 -0
  105. package/src/__tests__/omitted-reporting.test.ts +98 -0
  106. package/src/__tests__/pending-review.test.ts +77 -0
  107. package/src/__tests__/pii-redact.test.ts +333 -0
  108. package/src/__tests__/pivot-apply.test.ts +373 -0
  109. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  110. package/src/__tests__/pivot-distill.test.ts +1178 -0
  111. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  112. package/src/__tests__/ppr-traversal.test.ts +256 -0
  113. package/src/__tests__/prediction-engine.test.ts +349 -0
  114. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  115. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  116. package/src/__tests__/preference-matcher.test.ts +282 -0
  117. package/src/__tests__/preference-slots.test.ts +205 -0
  118. package/src/__tests__/prospective-memory.test.ts +283 -0
  119. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  120. package/src/__tests__/query-entity-detector.test.ts +171 -0
  121. package/src/__tests__/query-expander-alias.test.ts +146 -0
  122. package/src/__tests__/query-expander.test.ts +34 -0
  123. package/src/__tests__/recall-gate.test.ts +266 -0
  124. package/src/__tests__/recall-governor.test.ts +227 -0
  125. package/src/__tests__/recall-mode.test.ts +64 -0
  126. package/src/__tests__/resolve-db-path.test.ts +130 -0
  127. package/src/__tests__/resolve-tier.test.ts +64 -0
  128. package/src/__tests__/result-synthesizer.test.ts +198 -0
  129. package/src/__tests__/resume-context.test.ts +4898 -0
  130. package/src/__tests__/retrieval-eval.test.ts +163 -0
  131. package/src/__tests__/retrieval-trace.test.ts +213 -0
  132. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  133. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  134. package/src/__tests__/retroactive-boost.test.ts +223 -0
  135. package/src/__tests__/salience-scoring.test.ts +106 -0
  136. package/src/__tests__/scope-inventory.test.ts +200 -0
  137. package/src/__tests__/scope-policy.test.ts +69 -0
  138. package/src/__tests__/scope-suggester.test.ts +80 -0
  139. package/src/__tests__/session-checkpoint.test.ts +291 -0
  140. package/src/__tests__/session-distiller.test.ts +640 -0
  141. package/src/__tests__/session-gc.test.ts +215 -0
  142. package/src/__tests__/skill-engine.test.ts +552 -0
  143. package/src/__tests__/skill-promotion.test.ts +654 -0
  144. package/src/__tests__/skill-verifier.test.ts +122 -0
  145. package/src/__tests__/source-heartbeat.test.ts +250 -0
  146. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  147. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  148. package/src/__tests__/store-read-consistency.test.ts +122 -0
  149. package/src/__tests__/store-schema-migration.test.ts +87 -0
  150. package/src/__tests__/store-scope-audit.test.ts +81 -0
  151. package/src/__tests__/store-upsert.test.ts +201 -0
  152. package/src/__tests__/supersede-chain.test.ts +154 -0
  153. package/src/__tests__/temporal-parser.test.ts +241 -0
  154. package/src/__tests__/temporal-validity.test.ts +160 -0
  155. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  156. package/src/__tests__/term-registry.test.ts +121 -0
  157. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  158. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  159. package/src/__tests__/topic-tag.test.ts +113 -0
  160. package/src/__tests__/trusted-memory.test.ts +839 -0
  161. package/src/__tests__/ui-server-security.test.ts +93 -0
  162. package/src/__tests__/usage-tracker.test.ts +287 -0
  163. package/src/__tests__/version-manager.test.ts +213 -0
  164. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  165. package/src/__tests__/workflow-observation.test.ts +433 -0
  166. package/src/__tests__/write-verifier.test.ts +122 -0
  167. package/src/access-tracker.ts +90 -34
  168. package/src/activity-counter.ts +46 -18
  169. package/src/adaptive-retrieval.ts +11 -0
  170. package/src/aliases.ts +133 -0
  171. package/src/api-server.ts +36 -9
  172. package/src/audit-log.ts +4 -2
  173. package/src/auto-consolidation.ts +8 -3
  174. package/src/auto-gc.ts +85 -21
  175. package/src/auto-recall.ts +61 -1
  176. package/src/belief-history.ts +164 -0
  177. package/src/capture-engine.ts +142 -32
  178. package/src/cli.ts +268 -41
  179. package/src/confidence-tracker.ts +36 -18
  180. package/src/conflict-engine.ts +24 -3
  181. package/src/consolidation-engine.ts +255 -23
  182. package/src/context-composer-stable-selection.ts +7 -1
  183. package/src/context-composer.ts +8 -1
  184. package/src/data-checkup.ts +96 -7
  185. package/src/decay-engine.ts +59 -7
  186. package/src/distill-lock.ts +194 -15
  187. package/src/doctor.ts +13 -4
  188. package/src/dream-pipeline.ts +473 -41
  189. package/src/env-config.ts +118 -0
  190. package/src/error-signature.ts +153 -0
  191. package/src/error-taxonomy.ts +115 -0
  192. package/src/eval.ts +402 -14
  193. package/src/freshness.ts +192 -0
  194. package/src/http-request-policy.ts +148 -0
  195. package/src/idempotency.ts +8 -0
  196. package/src/ingest.ts +308 -12
  197. package/src/kg-extractor.ts +13 -3
  198. package/src/kg-store.ts +236 -44
  199. package/src/llm-client.ts +106 -11
  200. package/src/llm-consolidation.ts +2 -1
  201. package/src/mcp-server.ts +87 -1800
  202. package/src/mcp-tool-deps.ts +48 -0
  203. package/src/mcp-tools-advanced.ts +954 -0
  204. package/src/mcp-tools-core.ts +637 -0
  205. package/src/mcp-tools-governance.ts +475 -0
  206. package/src/memory-boundaries.ts +26 -3
  207. package/src/memory-evolution.ts +73 -1
  208. package/src/memory-health-rebalance.ts +4 -0
  209. package/src/memory-lint.ts +183 -10
  210. package/src/memory-output.ts +129 -4
  211. package/src/memory-promotion.ts +246 -0
  212. package/src/memory-schema.ts +25 -2
  213. package/src/memory-store-port.ts +46 -0
  214. package/src/multi-vector.ts +2 -1
  215. package/src/narrative-schema.ts +2 -1
  216. package/src/noise-filter.ts +57 -6
  217. package/src/noise-prototype-bank.ts +408 -0
  218. package/src/pii-detector.ts +207 -5
  219. package/src/pivot-apply.ts +399 -0
  220. package/src/preference-matcher.ts +37 -3
  221. package/src/query-expander.ts +90 -9
  222. package/src/recall-gate.ts +305 -0
  223. package/src/result-synthesizer.ts +2 -1
  224. package/src/retention-policy.ts +2 -1
  225. package/src/retrieval-profiles.ts +46 -4
  226. package/src/retriever.ts +354 -157
  227. package/src/retroactive-boost.ts +6 -2
  228. package/src/runtime-config.ts +78 -6
  229. package/src/scope-suggester.ts +113 -0
  230. package/src/session-distiller.ts +23 -2
  231. package/src/session-engine.ts +2 -1
  232. package/src/session-schema.ts +1 -0
  233. package/src/session-store.ts +5 -0
  234. package/src/skill-engine.ts +78 -0
  235. package/src/skill-promotion.ts +304 -48
  236. package/src/skill-schema.ts +16 -3
  237. package/src/skill-verifier.ts +152 -0
  238. package/src/store.ts +321 -57
  239. package/src/term-registry.ts +35 -0
  240. package/src/tool-output-compressor.ts +3 -1
  241. package/src/ui-server.ts +9 -1
  242. package/src/usage-tracker.ts +242 -0
  243. package/src/version-manager.ts +3 -2
  244. package/src/version.ts +19 -0
  245. package/src/workflow-observation-engine.ts +2 -1
  246. package/src/workflow-observation-schema.ts +2 -1
  247. package/src/workflow-observation-store.ts +6 -2
  248. package/.env +0 -4
  249. package/CLAUDE.md +0 -46
  250. package/config.json +0 -55
  251. package/daily-recall.sh +0 -110
  252. package/logs/daily-recall.error.log +0 -0
  253. package/logs/daily-recall.log +0 -60
  254. package/logs/ingest-2026-04-11.log +0 -110
  255. package/logs/ingest-2026-04-12.log +0 -90
  256. package/logs/ingest-2026-04-13.log +0 -87
  257. package/logs/launchagent-stderr.log +0 -0
  258. package/logs/launchagent-stdout.log +0 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Pivot Apply — dedicated structured write channel for reviewed pivot candidates.
3
+ *
4
+ * Purpose: apply LLM-distilled pivot candidates (reportId-keyed, reviewed via
5
+ * allowlist) into scope `memory:pivot` with deterministic dispositions.
6
+ *
7
+ * Why not the normal store_memory path (mutual-review P0, 2026-08-04):
8
+ * - persistMemory routes preferences through matchPreference (LLM semantic
9
+ * merge) → polymorphic dispositions the apply ledger cannot predict;
10
+ * - A-2 supersede detection calls the LLM per write (re-judging candidates is
11
+ * out of contract for apply);
12
+ * - default source=manual mislabels distilled content as 0.9/direct confidence;
13
+ * - kind/anchor/evidence/reportId/candidateHash/batchId are dropped;
14
+ * - the implicit preference dual-write (LME-1) is fire-and-forget, so its id
15
+ * can never reach the ledger.
16
+ *
17
+ * This channel therefore:
18
+ * - keeps: PII scan+redact (trust boundary), rule-based admission (minus rate
19
+ * limiting — bulk writes always trip it and it is not a quality signal),
20
+ * canonicalKey supersede semantics via writeDurableEntry, audit logging,
21
+ * synchronous post-write verification;
22
+ * - drops: every LLM call, F2 interference marking (must not silently mutate
23
+ * existing memories), async KG extraction, and the implicit preference
24
+ * dual-write (apply's contract is to write exactly the allowlisted entries
25
+ * and nothing else — a derived preference is not on the allowlist).
26
+ */
27
+
28
+ import { z } from "zod";
29
+
30
+ import { checkAdmission } from "./admission-control.js";
31
+ import {
32
+ buildStructuredMetadata,
33
+ writeDurableEntry,
34
+ type PersistMemoryDeps,
35
+ } from "./capture-engine.js";
36
+ import { assignDefaultConfidence } from "./confidence-tracker.js";
37
+ import { detectLang, tokenizeFts } from "./language-hook.js";
38
+ import { scanForPII, redactSecrets } from "./pii-detector.js";
39
+
40
+ export const PIVOT_APPLY_SOURCE = "session_distill";
41
+ export const PIVOT_APPLY_CAPTURE = "pivot_apply_v1";
42
+ export const PIVOT_SCOPE = "memory:pivot";
43
+
44
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
45
+
46
+ export const PivotKindSchema = z.enum([
47
+ "judgment_shift",
48
+ "decision",
49
+ "preference_rule",
50
+ "case",
51
+ ]);
52
+ export type PivotKind = z.infer<typeof PivotKindSchema>;
53
+
54
+ /** The only kind→category pairs that exist in the corpus (verified over all 8,971 candidates). */
55
+ const KIND_CATEGORY: Record<PivotKind, "cases" | "events" | "preferences"> = {
56
+ case: "cases",
57
+ decision: "events",
58
+ judgment_shift: "events",
59
+ preference_rule: "preferences",
60
+ };
61
+
62
+ export const PivotCandidateInputSchema = z
63
+ .object({
64
+ kind: PivotKindSchema,
65
+ text: z.string().min(10).max(4000),
66
+ anchor: z.string().min(1).max(2000),
67
+ canonicalKey: z.string().min(1).max(120),
68
+ evidence: z.array(z.string().min(1).max(4000)).max(16),
69
+ proposedScope: z.literal(PIVOT_SCOPE),
70
+ proposedCategory: z.enum(["cases", "events", "preferences"]),
71
+ // candidate tags max out at 4 in the corpus; the channel appends 2 more and
72
+ // buildStructuredMetadata stores tags verbatim, so cap input at 6 to stay
73
+ // within the schema-wide 8-tag budget.
74
+ tags: z.array(z.string().min(1).max(40)).min(1).max(6),
75
+ sessionId: z.string().min(8).max(200),
76
+ reportId: z.string().regex(SHA256_HEX, "reportId must be 64-char sha256 hex"),
77
+ candidateHash: z.string().regex(SHA256_HEX, "candidateHash must be 64-char sha256 hex"),
78
+ batchId: z.string().min(1).max(64),
79
+ importance: z.number().min(0).max(1),
80
+ })
81
+ .refine((c) => KIND_CATEGORY[c.kind] === c.proposedCategory, {
82
+ message: "proposedCategory does not match kind (corpus-verified mapping violated)",
83
+ });
84
+ export type PivotCandidateInput = z.infer<typeof PivotCandidateInputSchema>;
85
+
86
+ export type PivotApplyDisposition =
87
+ | "stored"
88
+ | "updated"
89
+ | "deduped"
90
+ | "promoted"
91
+ | "conflict"
92
+ | "rejected";
93
+
94
+ /**
95
+ * The stock embedder only retries connection-class errors; HTTP 429/5xx throw
96
+ * straight through (embedder.ts isTransientEmbeddingError). A bulk apply makes
97
+ * thousands of calls, so rate-limit blips must not void a whole batch — retry
98
+ * with backoff here, and only here (single-write callers keep stock behavior).
99
+ */
100
+ async function embedWithRetry(
101
+ embedder: PersistMemoryDeps["embedder"],
102
+ text: string,
103
+ delaysMs: readonly number[] = [1000, 5000, 15000],
104
+ ): Promise<number[]> {
105
+ let lastErr: unknown;
106
+ for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
107
+ try {
108
+ return await embedder.embedPassage(text);
109
+ } catch (err) {
110
+ lastErr = err;
111
+ const msg = String((err as Error)?.message ?? err);
112
+ const retriable = /\b(429|500|502|503|504)\b|rate.?limit|overloaded/i.test(msg);
113
+ if (!retriable || attempt === delaysMs.length) throw err;
114
+ await new Promise((r) => setTimeout(r, delaysMs[attempt]));
115
+ }
116
+ }
117
+ throw lastErr;
118
+ }
119
+
120
+ export interface PivotApplyOutcome {
121
+ candidateHash: string;
122
+ batchId: string;
123
+ /** null when the write never reached the store (admission rejection). */
124
+ memoryId: string | null;
125
+ disposition: PivotApplyDisposition;
126
+ conflictId?: string;
127
+ rejectionReason?: string;
128
+ supersededOldId?: string;
129
+ piiRedactedCount: number;
130
+ canonicalKey: string;
131
+ category: "cases" | "events" | "preferences";
132
+ verified: boolean;
133
+ verifyIssues: string[];
134
+ }
135
+
136
+ export interface PivotBatchReport {
137
+ batchId: string;
138
+ total: number;
139
+ written: number;
140
+ halted: boolean;
141
+ haltReason?: string;
142
+ outcomes: PivotApplyOutcome[];
143
+ }
144
+
145
+ /**
146
+ * Persist one reviewed pivot candidate. Deterministic: no LLM calls, no writes
147
+ * outside the candidate itself (plus the audit log line).
148
+ */
149
+ export async function persistPivotCandidate(
150
+ deps: PersistMemoryDeps,
151
+ rawInput: unknown,
152
+ ): Promise<PivotApplyOutcome> {
153
+ const input = PivotCandidateInputSchema.parse(rawInput);
154
+
155
+ // F-3b ordering (same as persistMemory): scan first so piiWarning reflects
156
+ // the raw text, then redact before the text reaches embedding/storage.
157
+ const piiScan = scanForPII(input.text);
158
+ const redaction = redactSecrets(input.text);
159
+ const text = redaction.redacted > 0 ? redaction.text : input.text;
160
+
161
+ // Rule-based admission only. No rateLimiter on purpose: a bulk apply always
162
+ // exceeds per-scope write rates, which says nothing about entry quality.
163
+ const admission = checkAdmission(text, input.importance, PIVOT_SCOPE, undefined, deps.admissionConfig);
164
+ if (admission.verdict === "rejected") {
165
+ try {
166
+ deps.auditLogger?.log({
167
+ operation: "reject",
168
+ scope: PIVOT_SCOPE,
169
+ actor: PIVOT_APPLY_SOURCE,
170
+ details: `pivot-apply ${input.kind} ${input.candidateHash.slice(0, 12)}: ${admission.reason ?? "unknown"}`,
171
+ });
172
+ } catch {
173
+ // Audit must never block the outcome report.
174
+ }
175
+ return {
176
+ candidateHash: input.candidateHash,
177
+ batchId: input.batchId,
178
+ memoryId: null,
179
+ disposition: "rejected",
180
+ rejectionReason: admission.reason,
181
+ piiRedactedCount: redaction.redacted,
182
+ canonicalKey: input.canonicalKey,
183
+ category: input.proposedCategory,
184
+ verified: false,
185
+ verifyIssues: [],
186
+ };
187
+ }
188
+
189
+ const vector = await embedWithRetry(deps.embedder, text);
190
+ // The store layer never validates vector shape (only importEntry does), so an
191
+ // empty/short vector would land silently and poison retrieval — assert here.
192
+ if (!Array.isArray(vector) || vector.length === 0) {
193
+ throw new Error(`embedding returned an empty vector for candidate ${input.candidateHash.slice(0, 12)}`);
194
+ }
195
+ const language = detectLang(text);
196
+ const fts_text = tokenizeFts(text, language);
197
+
198
+ let metadata = buildStructuredMetadata({
199
+ source: PIVOT_APPLY_SOURCE,
200
+ tags: [...input.tags, "pivot-apply", `batch:${input.batchId}`],
201
+ capture: PIVOT_APPLY_CAPTURE,
202
+ category: input.proposedCategory,
203
+ canonicalKey: input.canonicalKey,
204
+ extra: {
205
+ anchor: input.anchor,
206
+ pivotApply: {
207
+ kind: input.kind,
208
+ evidence: input.evidence,
209
+ sessionId: input.sessionId,
210
+ reportId: input.reportId,
211
+ candidateHash: input.candidateHash,
212
+ batchId: input.batchId,
213
+ },
214
+ },
215
+ });
216
+ {
217
+ // Same post-build patch pattern persistMemory uses for confidence/PII.
218
+ const parsed = JSON.parse(metadata) as Record<string, unknown>;
219
+ parsed.confidence = assignDefaultConfidence(PIVOT_APPLY_SOURCE);
220
+ if (piiScan.hasPII) {
221
+ parsed.piiWarning = {
222
+ summary: piiScan.summary,
223
+ detections: piiScan.detections.length,
224
+ severity: piiScan.detections.some((d) => d.severity === "high") ? "high" : "medium",
225
+ ...(redaction.redacted > 0 ? { redacted: redaction.redacted } : {}),
226
+ };
227
+ }
228
+ metadata = JSON.stringify(parsed);
229
+ }
230
+
231
+ const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
232
+ text,
233
+ vector,
234
+ category: input.proposedCategory,
235
+ scope: PIVOT_SCOPE,
236
+ importance: input.importance,
237
+ metadata,
238
+ canonicalKey: input.canonicalKey,
239
+ source: PIVOT_APPLY_SOURCE,
240
+ language,
241
+ fts_text,
242
+ });
243
+
244
+ try {
245
+ deps.auditLogger?.log({
246
+ operation: "store",
247
+ scope: PIVOT_SCOPE,
248
+ memoryId: entry.id,
249
+ actor: PIVOT_APPLY_SOURCE,
250
+ details: `pivot-apply ${input.kind} ${input.candidateHash.slice(0, 12)} → ${disposition}`,
251
+ });
252
+ } catch {
253
+ // Audit must never block the outcome report.
254
+ }
255
+
256
+ // Post-write verification, synchronous on purpose: a bulk apply needs the
257
+ // verdict inside the ledger row, not on a console later. Deliberately NOT
258
+ // verifyWrite(): its checkEntry expects scope/importance inside metadata,
259
+ // which buildStructuredMetadata-shaped rows never carry (a pre-existing
260
+ // mismatch its async caller silently swallows). This check asserts the claim
261
+ // that matters instead: the row at entry.id holds THIS candidate's content.
262
+ let verified = true;
263
+ let verifyIssues: string[] = [];
264
+ if ((disposition === "stored" || disposition === "updated") && deps.store.get) {
265
+ verifyIssues = await verifyPivotRow(deps.store.get.bind(deps.store), entry.id, {
266
+ candidateHash: input.candidateHash,
267
+ text,
268
+ });
269
+ verified = verifyIssues.length === 0;
270
+ }
271
+
272
+ return {
273
+ candidateHash: input.candidateHash,
274
+ batchId: input.batchId,
275
+ memoryId: entry.id,
276
+ disposition,
277
+ ...(conflictId ? { conflictId } : {}),
278
+ piiRedactedCount: redaction.redacted,
279
+ canonicalKey: input.canonicalKey,
280
+ category: input.proposedCategory,
281
+ verified,
282
+ verifyIssues,
283
+ };
284
+ }
285
+
286
+ function normalizeForCompare(text: string): string {
287
+ return text.replace(/\s+/g, " ").trim();
288
+ }
289
+
290
+ /** Read the row back and assert it is this candidate's write, not merely a well-formed row. */
291
+ async function verifyPivotRow(
292
+ get: (id: string) => Promise<{ text: string; scope: string; metadata: string } | null>,
293
+ id: string,
294
+ expected: { candidateHash: string; text: string },
295
+ ): Promise<string[]> {
296
+ const issues: string[] = [];
297
+ let row: { text: string; scope: string; metadata: string } | null = null;
298
+ try {
299
+ row = await get(id);
300
+ } catch (err) {
301
+ return [`readback threw: ${(err as Error).message}`];
302
+ }
303
+ if (!row) return ["row missing on readback"];
304
+ if (row.scope !== PIVOT_SCOPE) issues.push(`scope is "${row.scope}", expected "${PIVOT_SCOPE}"`);
305
+ if (normalizeForCompare(row.text) !== normalizeForCompare(expected.text)) {
306
+ issues.push("text on readback differs from written text");
307
+ }
308
+ try {
309
+ const meta = JSON.parse(row.metadata || "{}") as Record<string, unknown>;
310
+ const pivotApply = meta.pivotApply as Record<string, unknown> | undefined;
311
+ if (pivotApply?.candidateHash !== expected.candidateHash) {
312
+ issues.push("pivotApply.candidateHash on readback does not match this candidate");
313
+ }
314
+ } catch {
315
+ issues.push("metadata unparseable on readback");
316
+ }
317
+ return issues;
318
+ }
319
+
320
+ export interface PivotBatchOptions {
321
+ /** Dispositions that do NOT halt the batch. Default: only "stored". */
322
+ expectedDispositions?: readonly PivotApplyDisposition[];
323
+ }
324
+
325
+ /**
326
+ * Persist a physical batch sequentially, fail-closed.
327
+ *
328
+ * Pre-screening guarantees the incoming batch has no key collisions with the
329
+ * live pool (R5) and no identical-text duplicates (R3 folding), so every
330
+ * disposition other than "stored" is unexpected by default and halts the batch
331
+ * immediately. Rows already written before the halt stay in the store — the
332
+ * caller reconciles via the returned outcomes (ledger) and the table snapshot
333
+ * taken before the batch.
334
+ */
335
+ export async function persistPivotBatch(
336
+ deps: PersistMemoryDeps,
337
+ batchId: string,
338
+ rawInputs: readonly unknown[],
339
+ options?: PivotBatchOptions,
340
+ ): Promise<PivotBatchReport> {
341
+ const expected = new Set<PivotApplyDisposition>(options?.expectedDispositions ?? ["stored"]);
342
+ const outcomes: PivotApplyOutcome[] = [];
343
+ let written = 0;
344
+
345
+ for (const raw of rawInputs) {
346
+ // Halt before the write when the row belongs to a different batch: the
347
+ // ledger keys every side effect by batchId, so a mixed batch would corrupt
348
+ // snapshot/rollback bookkeeping.
349
+ const claimed =
350
+ typeof raw === "object" && raw !== null && "batchId" in raw
351
+ ? String((raw as Record<string, unknown>).batchId)
352
+ : "(missing)";
353
+ if (claimed !== batchId) {
354
+ return {
355
+ batchId,
356
+ total: rawInputs.length,
357
+ written,
358
+ halted: true,
359
+ haltReason: `candidate claims batchId "${claimed}" but batch is "${batchId}"`,
360
+ outcomes,
361
+ };
362
+ }
363
+
364
+ const outcome = await persistPivotCandidate(deps, raw);
365
+ outcomes.push(outcome);
366
+ if (outcome.memoryId !== null && (outcome.disposition === "stored" || outcome.disposition === "updated")) {
367
+ written += 1;
368
+ }
369
+
370
+ if (!expected.has(outcome.disposition)) {
371
+ return {
372
+ batchId,
373
+ total: rawInputs.length,
374
+ written,
375
+ halted: true,
376
+ haltReason: `unexpected disposition "${outcome.disposition}" for candidate ${outcome.candidateHash.slice(0, 12)}`,
377
+ outcomes,
378
+ };
379
+ }
380
+ if (!outcome.verified && (outcome.disposition === "stored" || outcome.disposition === "updated")) {
381
+ return {
382
+ batchId,
383
+ total: rawInputs.length,
384
+ written,
385
+ halted: true,
386
+ haltReason: `post-write verification failed for ${outcome.candidateHash.slice(0, 12)}: ${outcome.verifyIssues.join("; ")}`,
387
+ outcomes,
388
+ };
389
+ }
390
+ }
391
+
392
+ return {
393
+ batchId,
394
+ total: rawInputs.length,
395
+ written,
396
+ halted: false,
397
+ outcomes,
398
+ };
399
+ }
@@ -15,6 +15,9 @@ import type { MemoryStore, MemoryEntry } from "./store.js";
15
15
  import type { Embedder } from "./embedder.js";
16
16
  import type { LLMClient } from "./llm-client.js";
17
17
  import { logInfo } from "./stderr-log.js";
18
+ import { isActiveMemory, parseEvolution } from "./memory-evolution.js";
19
+ import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
20
+ import { detectLang, tokenizeFts } from "./language-hook.js";
18
21
 
19
22
  // ============================================================================
20
23
  // Configuration
@@ -77,8 +80,13 @@ export async function matchPreference(
77
80
  [scope],
78
81
  );
79
82
 
80
- // Filter to only preferences category
81
- const prefMatches = candidates.filter(c => c.entry.category === "preferences");
83
+ // Filter to only preferences category. store.vectorSearch() filters by scope only, so
84
+ // superseded belief-history rows come back here too — matching against one would either
85
+ // silently drop the incoming preference as a duplicate of a belief no longer held, or
86
+ // merge new text into an archived version.
87
+ const prefMatches = candidates.filter(
88
+ c => c.entry.category === "preferences" && isActiveMemory(c.entry.metadata),
89
+ );
82
90
 
83
91
  if (prefMatches.length === 0) {
84
92
  return { action: "create", reason: "no-similar-preferences" };
@@ -132,6 +140,7 @@ export async function applyPreferenceMatch(
132
140
  result: PreferenceMatchResult,
133
141
  store: MemoryStore,
134
142
  scope: string,
143
+ deps: { embedder: Pick<Embedder, "embedPassage"> },
135
144
  ): Promise<{ handled: boolean; entry?: MemoryEntry }> {
136
145
  if (result.action === "skip") {
137
146
  logInfo(`[INFO] Preference skipped (duplicate): ${result.reason}`);
@@ -139,9 +148,34 @@ export async function applyPreferenceMatch(
139
148
  }
140
149
 
141
150
  if (result.action === "merge" && result.mergeTargetId && result.mergedText) {
151
+ const existing = await store.getById(result.mergeTargetId);
152
+ if (!existing) return { handled: false };
153
+
154
+ // Merging rewrites the target's text, which is a belief change like any other —
155
+ // archive the version being replaced before overwriting it.
156
+ const now = Date.now();
157
+ const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
158
+ const { historyId } = await archiveBeliefVersion({ store, embedder: deps.embedder }, existing, { now });
159
+
160
+ // The old code updated `text` alone, leaving vector and fts_text describing the
161
+ // pre-merge wording — so a merged preference stayed unfindable by its own new text.
162
+ const language = detectLang(result.mergedText);
142
163
  const updated = await store.update(
143
164
  result.mergeTargetId,
144
- { text: result.mergedText },
165
+ {
166
+ text: result.mergedText,
167
+ vector: await deps.embedder.embedPassage(result.mergedText),
168
+ metadata: buildSupersedingBeliefMetadata(
169
+ existing.metadata,
170
+ previousEvo,
171
+ historyId,
172
+ now,
173
+ `preference-merge:${result.reason}`,
174
+ ),
175
+ timestamp: now,
176
+ language,
177
+ fts_text: tokenizeFts(result.mergedText, language),
178
+ },
145
179
  [scope],
146
180
  );
147
181
  if (updated) {
@@ -10,8 +10,8 @@
10
10
  * v1.3: P0.3 — User-level alias-map (data/alias-map.json) for short query expansion.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync } from "fs";
14
- import { join } from "path";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
14
+ import { dirname, join } from "path";
15
15
  import { resolveQueryEntities } from "./entity-resolver.js";
16
16
 
17
17
  interface SynonymEntry {
@@ -89,9 +89,14 @@ export function resetAliasMapCache(): void {
89
89
  aliasMapCache = null;
90
90
  }
91
91
 
92
+ /** Resolve the alias-map file path currently in effect. */
93
+ export function aliasMapFilePath(): string {
94
+ return aliasMapPath ?? join(process.cwd(), "data", "alias-map.json");
95
+ }
96
+
92
97
  function loadAliasMap(): AliasEntry[] {
93
98
  if (aliasMapCache !== null) return aliasMapCache;
94
- const filePath = aliasMapPath ?? join(process.cwd(), "data", "alias-map.json");
99
+ const filePath = aliasMapFilePath();
95
100
  try {
96
101
  if (existsSync(filePath)) {
97
102
  const raw = JSON.parse(readFileSync(filePath, "utf-8"));
@@ -112,6 +117,75 @@ function loadAliasMap(): AliasEntry[] {
112
117
  return aliasMapCache;
113
118
  }
114
119
 
120
+ // ---------------------------------------------------------------------------
121
+ // User alias management (P1-B manage_alias tool 后端)
122
+ //
123
+ // 这些别名只作用于 BM25 通道(expandQuery 在 hybridRetrieval 内部被调用),
124
+ // 刻意不进 vector embedding——随手加的用户别名若改变语义向量,排查
125
+ // retrieval drift 会非常困难。全通道的 builtin 规则在 src/aliases.ts。
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** 写入校验:trigger 最短 2 字符(防高频泛词),expansions 最多 8 个。 */
129
+ export const USER_ALIAS_TRIGGER_MIN = 2;
130
+ export const USER_ALIAS_EXPANSIONS_MAX = 8;
131
+
132
+ export type AliasUpsertResult =
133
+ | { ok: true; action: "added" | "updated"; entry: AliasEntry }
134
+ | { ok: false; reason: string };
135
+
136
+ /** 新增/更新用户别名(写 alias-map.json;校验后缓存即时失效)。 */
137
+ export function upsertUserAlias(trigger: string, expansions: string[]): AliasUpsertResult {
138
+ const t = trigger.trim();
139
+ const exp = [...new Set(expansions.map(e => e.trim()).filter(e => e.length > 0))];
140
+
141
+ if (t.length < USER_ALIAS_TRIGGER_MIN) {
142
+ return { ok: false, reason: `trigger 太短(<${USER_ALIAS_TRIGGER_MIN} 字符)——高频泛词会污染所有查询` };
143
+ }
144
+ if (exp.length === 0) {
145
+ return { ok: false, reason: "expansions 为空" };
146
+ }
147
+ if (exp.length > USER_ALIAS_EXPANSIONS_MAX) {
148
+ return { ok: false, reason: `expansions 超过 ${USER_ALIAS_EXPANSIONS_MAX} 个——过长扩展会冲淡 BM25 原查询词权重` };
149
+ }
150
+ if (exp.every(e => e === t)) {
151
+ return { ok: false, reason: "expansions 与 trigger 相同,无扩展意义" };
152
+ }
153
+
154
+ const rules = loadAliasMap();
155
+ const idx = rules.findIndex(r => r.trigger === t);
156
+ const entry: AliasEntry = { trigger: t, expansions: exp };
157
+ const next = idx >= 0 ? rules.map((r, i) => (i === idx ? entry : r)) : [...rules, entry];
158
+
159
+ const path = aliasMapFilePath();
160
+ mkdirSync(dirname(path), { recursive: true });
161
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8");
162
+ aliasMapCache = null;
163
+ return { ok: true, action: idx >= 0 ? "updated" : "added", entry };
164
+ }
165
+
166
+ /** 删除用户别名;返回是否真的删了。 */
167
+ export function removeUserAlias(trigger: string): boolean {
168
+ const rules = loadAliasMap();
169
+ const next = rules.filter(r => r.trigger !== trigger.trim());
170
+ if (next.length === rules.length) return false;
171
+ const path = aliasMapFilePath();
172
+ mkdirSync(dirname(path), { recursive: true });
173
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8");
174
+ aliasMapCache = null;
175
+ return true;
176
+ }
177
+
178
+ /** 列出用户别名规则。 */
179
+ export function listUserAliases(): AliasEntry[] {
180
+ return [...loadAliasMap()];
181
+ }
182
+
183
+ /** 检视:query 命中了哪些用户别名(BM25-only 通道)。 */
184
+ export function explainUserAliases(query: string): AliasEntry[] {
185
+ const lower = query.toLowerCase();
186
+ return loadAliasMap().filter(e => lower.includes(e.trigger.toLowerCase()));
187
+ }
188
+
115
189
  /**
116
190
  * Expand a query by appending synonym terms from the dictionary.
117
191
  * Returns the original query with additional terms appended.
@@ -123,7 +197,8 @@ export function expandQuery(query: string): string {
123
197
  // Entity resolution pre-pass: normalize aliases before synonym expansion
124
198
  const resolved = resolveQueryEntities(query);
125
199
  const lower = resolved.toLowerCase();
126
- const additions = new Set<string>();
200
+ const synonymAdditions = new Set<string>();
201
+ const aliasAdditions = new Set<string>();
127
202
 
128
203
  // Static synonym map (built-in)
129
204
  for (const entry of SYNONYM_MAP) {
@@ -138,7 +213,7 @@ export function expandQuery(query: string): string {
138
213
  if (cnMatch || enMatch) {
139
214
  for (const exp of entry.expansions) {
140
215
  if (!lower.includes(exp.toLowerCase())) {
141
- additions.add(exp);
216
+ synonymAdditions.add(exp);
142
217
  }
143
218
  }
144
219
  }
@@ -151,15 +226,21 @@ export function expandQuery(query: string): string {
151
226
  if (lower.includes(triggerLower)) {
152
227
  for (const exp of alias.expansions) {
153
228
  if (!lower.includes(exp.toLowerCase())) {
154
- additions.add(exp);
229
+ aliasAdditions.add(exp);
155
230
  }
156
231
  }
157
232
  }
158
233
  }
159
234
 
160
- if (additions.size === 0) return resolved;
235
+ if (aliasAdditions.size === 0 && synonymAdditions.size === 0) return resolved;
161
236
 
162
- // Cap expansion terms to prevent query bloat
163
- const limited = [...additions].slice(0, MAX_EXPANSION_TERMS);
237
+ // Cap expansion terms to prevent query bloat. User aliases rank first inside
238
+ // the cap — 用户为这个 trigger 显式配置的 canonical 名,不应被泛化同义词
239
+ // 挤出 MAX_EXPANSION_TERMS(P1-B:同义词"挂"曾把 alias 的实体名挤掉)。
240
+ const merged = [
241
+ ...aliasAdditions,
242
+ ...[...synonymAdditions].filter(t => !aliasAdditions.has(t)),
243
+ ];
244
+ const limited = merged.slice(0, MAX_EXPANSION_TERMS);
164
245
  return `${resolved} ${limited.join(" ")}`;
165
246
  }