recallnest 2.3.0 → 2.5.4

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 (221) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +17 -31
  3. package/README_CN.md +12 -22
  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 +22 -1
  15. package/src/__tests__/access-tracker-novelty.test.ts +238 -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__/auto-consolidation.test.ts +168 -0
  21. package/src/__tests__/auto-recall.test.ts +131 -0
  22. package/src/__tests__/babel-integration.test.ts +144 -0
  23. package/src/__tests__/batch-dedup.test.ts +65 -0
  24. package/src/__tests__/canary-eval.test.ts +114 -0
  25. package/src/__tests__/capture-engine.test.ts +1238 -0
  26. package/src/__tests__/capture-heuristic.test.ts +171 -0
  27. package/src/__tests__/cascade-forget.test.ts +210 -0
  28. package/src/__tests__/category-min-scores.test.ts +33 -0
  29. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  30. package/src/__tests__/circuit-breaker.test.ts +99 -0
  31. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  32. package/src/__tests__/confidence-metadata.test.ts +125 -0
  33. package/src/__tests__/confidence-tracker.test.ts +261 -0
  34. package/src/__tests__/conflict-advisor.test.ts +206 -0
  35. package/src/__tests__/conflict-escalation.test.ts +149 -0
  36. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  37. package/src/__tests__/conflict-output.test.ts +85 -0
  38. package/src/__tests__/conflict-store.test.ts +374 -0
  39. package/src/__tests__/connector-v1.test.ts +181 -0
  40. package/src/__tests__/consolidation-engine.test.ts +333 -0
  41. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  42. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  43. package/src/__tests__/context-composer-pins.test.ts +64 -0
  44. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  45. package/src/__tests__/context-reconstructor.test.ts +65 -0
  46. package/src/__tests__/context-renderer.test.ts +91 -0
  47. package/src/__tests__/continuity-eval.test.ts +275 -0
  48. package/src/__tests__/continuity-schema.test.ts +97 -0
  49. package/src/__tests__/contracts.test.ts +216 -0
  50. package/src/__tests__/conversation-importer.test.ts +692 -0
  51. package/src/__tests__/core-recall-eval.test.ts +235 -0
  52. package/src/__tests__/core-summary.test.ts +109 -0
  53. package/src/__tests__/data-checkup.test.ts +276 -0
  54. package/src/__tests__/decay-exemption.test.ts +84 -0
  55. package/src/__tests__/dedup-actions.test.ts +58 -0
  56. package/src/__tests__/dedup-guard.test.ts +39 -0
  57. package/src/__tests__/deterministic-id.test.ts +39 -0
  58. package/src/__tests__/distill-lock.test.ts +305 -0
  59. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  60. package/src/__tests__/doctor.test.ts +319 -0
  61. package/src/__tests__/dream-pipeline.test.ts +321 -0
  62. package/src/__tests__/drill-down.test.ts +108 -0
  63. package/src/__tests__/embedder.test.ts +122 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/entity-resolver.test.ts +58 -0
  69. package/src/__tests__/env-config.test.ts +151 -0
  70. package/src/__tests__/error-signature-boost.test.ts +87 -0
  71. package/src/__tests__/error-signature.test.ts +95 -0
  72. package/src/__tests__/error-taxonomy.test.ts +129 -0
  73. package/src/__tests__/ethics-layer.test.ts +476 -0
  74. package/src/__tests__/event-segmenter.test.ts +153 -0
  75. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  76. package/src/__tests__/frequency-tracker.test.ts +113 -0
  77. package/src/__tests__/graph-export.test.ts +489 -0
  78. package/src/__tests__/hotness-blend.test.ts +76 -0
  79. package/src/__tests__/ingest-chunking.test.ts +44 -0
  80. package/src/__tests__/ingest.test.ts +360 -0
  81. package/src/__tests__/interference-detector.test.ts +203 -0
  82. package/src/__tests__/kg-extractor.test.ts +192 -0
  83. package/src/__tests__/kg-store.test.ts +300 -0
  84. package/src/__tests__/llm-consolidation.test.ts +226 -0
  85. package/src/__tests__/maintenance-locks.test.ts +174 -0
  86. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  87. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  88. package/src/__tests__/memory-boundaries.test.ts +250 -0
  89. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  90. package/src/__tests__/memory-evolution.test.ts +271 -0
  91. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  92. package/src/__tests__/memory-lint.test.ts +421 -0
  93. package/src/__tests__/memory-output.test.ts +215 -0
  94. package/src/__tests__/memory-promotion.test.ts +315 -0
  95. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  96. package/src/__tests__/multi-vector.test.ts +242 -0
  97. package/src/__tests__/narrative-architecture.test.ts +456 -0
  98. package/src/__tests__/noise-filter.test.ts +205 -0
  99. package/src/__tests__/obsidian-connector.test.ts +363 -0
  100. package/src/__tests__/omitted-reporting.test.ts +98 -0
  101. package/src/__tests__/pending-review.test.ts +77 -0
  102. package/src/__tests__/pii-redact.test.ts +314 -0
  103. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  104. package/src/__tests__/ppr-traversal.test.ts +256 -0
  105. package/src/__tests__/prediction-engine.test.ts +349 -0
  106. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  107. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  108. package/src/__tests__/preference-matcher.test.ts +230 -0
  109. package/src/__tests__/preference-slots.test.ts +205 -0
  110. package/src/__tests__/prospective-memory.test.ts +283 -0
  111. package/src/__tests__/query-entity-detector.test.ts +171 -0
  112. package/src/__tests__/query-expander-alias.test.ts +146 -0
  113. package/src/__tests__/query-expander.test.ts +34 -0
  114. package/src/__tests__/recall-governor.test.ts +227 -0
  115. package/src/__tests__/recall-mode.test.ts +64 -0
  116. package/src/__tests__/result-synthesizer.test.ts +198 -0
  117. package/src/__tests__/resume-context.test.ts +4898 -0
  118. package/src/__tests__/retrieval-trace.test.ts +213 -0
  119. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  120. package/src/__tests__/retroactive-boost.test.ts +223 -0
  121. package/src/__tests__/salience-scoring.test.ts +106 -0
  122. package/src/__tests__/scope-inventory.test.ts +200 -0
  123. package/src/__tests__/scope-policy.test.ts +69 -0
  124. package/src/__tests__/scope-suggester.test.ts +80 -0
  125. package/src/__tests__/session-checkpoint.test.ts +291 -0
  126. package/src/__tests__/session-distiller.test.ts +640 -0
  127. package/src/__tests__/session-gc.test.ts +215 -0
  128. package/src/__tests__/skill-engine.test.ts +552 -0
  129. package/src/__tests__/skill-promotion.test.ts +601 -0
  130. package/src/__tests__/skill-verifier.test.ts +122 -0
  131. package/src/__tests__/source-heartbeat.test.ts +250 -0
  132. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  133. package/src/__tests__/store-scope-audit.test.ts +81 -0
  134. package/src/__tests__/store-upsert.test.ts +184 -0
  135. package/src/__tests__/supersede-chain.test.ts +154 -0
  136. package/src/__tests__/temporal-parser.test.ts +241 -0
  137. package/src/__tests__/temporal-validity.test.ts +160 -0
  138. package/src/__tests__/term-registry.test.ts +121 -0
  139. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  140. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  141. package/src/__tests__/topic-tag.test.ts +113 -0
  142. package/src/__tests__/trusted-memory.test.ts +839 -0
  143. package/src/__tests__/usage-tracker.test.ts +287 -0
  144. package/src/__tests__/version-manager.test.ts +213 -0
  145. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  146. package/src/__tests__/workflow-observation.test.ts +433 -0
  147. package/src/__tests__/write-verifier.test.ts +122 -0
  148. package/src/access-tracker.ts +38 -32
  149. package/src/activity-counter.ts +46 -18
  150. package/src/aliases.ts +133 -0
  151. package/src/api-server.ts +27 -7
  152. package/src/audit-log.ts +4 -2
  153. package/src/auto-consolidation.ts +8 -3
  154. package/src/auto-gc.ts +85 -21
  155. package/src/capture-engine.ts +54 -21
  156. package/src/cli.ts +169 -37
  157. package/src/confidence-tracker.ts +36 -18
  158. package/src/consolidation-engine.ts +109 -11
  159. package/src/context-composer.ts +8 -1
  160. package/src/data-checkup.ts +95 -6
  161. package/src/distill-lock.ts +194 -15
  162. package/src/doctor.ts +9 -2
  163. package/src/dream-pipeline.ts +118 -34
  164. package/src/env-config.ts +77 -0
  165. package/src/error-signature.ts +153 -0
  166. package/src/error-taxonomy.ts +115 -0
  167. package/src/eval.ts +251 -6
  168. package/src/idempotency.ts +8 -0
  169. package/src/ingest.ts +31 -10
  170. package/src/kg-extractor.ts +6 -2
  171. package/src/kg-store.ts +236 -44
  172. package/src/llm-consolidation.ts +2 -1
  173. package/src/mcp-server.ts +85 -1799
  174. package/src/mcp-tool-deps.ts +48 -0
  175. package/src/mcp-tools-advanced.ts +941 -0
  176. package/src/mcp-tools-core.ts +633 -0
  177. package/src/mcp-tools-governance.ts +475 -0
  178. package/src/memory-boundaries.ts +3 -1
  179. package/src/memory-evolution.ts +73 -1
  180. package/src/memory-lint.ts +183 -10
  181. package/src/memory-output.ts +114 -3
  182. package/src/memory-promotion.ts +246 -0
  183. package/src/memory-schema.ts +25 -2
  184. package/src/memory-store-port.ts +43 -0
  185. package/src/multi-vector.ts +2 -1
  186. package/src/narrative-schema.ts +2 -1
  187. package/src/noise-filter.ts +57 -6
  188. package/src/pii-detector.ts +157 -5
  189. package/src/query-expander.ts +90 -9
  190. package/src/result-synthesizer.ts +2 -1
  191. package/src/retention-policy.ts +2 -1
  192. package/src/retriever.ts +259 -139
  193. package/src/runtime-config.ts +8 -1
  194. package/src/scope-suggester.ts +113 -0
  195. package/src/session-distiller.ts +23 -2
  196. package/src/session-engine.ts +2 -1
  197. package/src/session-schema.ts +1 -0
  198. package/src/session-store.ts +5 -0
  199. package/src/skill-engine.ts +78 -0
  200. package/src/skill-promotion.ts +247 -47
  201. package/src/skill-schema.ts +16 -3
  202. package/src/skill-verifier.ts +152 -0
  203. package/src/store.ts +267 -38
  204. package/src/tool-output-compressor.ts +3 -1
  205. package/src/ui-server.ts +2 -1
  206. package/src/usage-tracker.ts +242 -0
  207. package/src/version-manager.ts +3 -2
  208. package/src/workflow-observation-engine.ts +2 -1
  209. package/src/workflow-observation-schema.ts +2 -1
  210. package/src/workflow-observation-store.ts +6 -2
  211. package/.env +0 -4
  212. package/CLAUDE.md +0 -46
  213. package/config.json +0 -55
  214. package/daily-recall.sh +0 -110
  215. package/logs/daily-recall.error.log +0 -0
  216. package/logs/daily-recall.log +0 -60
  217. package/logs/ingest-2026-04-11.log +0 -110
  218. package/logs/ingest-2026-04-12.log +0 -90
  219. package/logs/ingest-2026-04-13.log +0 -87
  220. package/logs/launchagent-stderr.log +0 -0
  221. package/logs/launchagent-stdout.log +0 -0
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { buildConflictCandidateRecord } from "../conflict-engine.js";
4
+ import { buildConflictEscalationItem, escalateConflicts } from "../conflict-escalation.js";
5
+
6
+ function createConflictRecord(overrides: Record<string, unknown> = {}) {
7
+ return {
8
+ ...buildConflictCandidateRecord({
9
+ canonicalKey: "user-reply-style",
10
+ category: "preferences",
11
+ fingerprint: "user-reply-style--durable-1--source-1--new-text",
12
+ reason: "promotion_conflicts_with_existing_durable",
13
+ existing: {
14
+ memoryId: "durable-1",
15
+ text: "User prefers concise, direct, technical replies.",
16
+ category: "preferences",
17
+ scope: "memory:agent",
18
+ importance: 0.84,
19
+ metadata: "{\"source\":\"agent\"}",
20
+ canonicalKey: "user-reply-style",
21
+ },
22
+ incoming: {
23
+ text: "User prefers colloquial, grounded technical replies.",
24
+ category: "preferences",
25
+ scope: "memory:agent",
26
+ importance: 0.78,
27
+ metadata: "{\"source\":\"agent\"}",
28
+ source: "agent",
29
+ sourceMemoryId: "source-1",
30
+ sourceCategory: "events",
31
+ },
32
+ createdAt: "2026-03-01T00:00:00.000Z",
33
+ updatedAt: "2026-03-01T00:00:00.000Z",
34
+ }),
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("buildConflictEscalationItem", () => {
40
+ it("only returns an escalation item for stale or escalated open conflicts", () => {
41
+ const staleItem = buildConflictEscalationItem(
42
+ createConflictRecord() as any,
43
+ new Date("2026-03-16T00:00:00.000Z"),
44
+ );
45
+ const freshItem = buildConflictEscalationItem(
46
+ createConflictRecord({
47
+ createdAt: "2026-03-16T00:00:00.000Z",
48
+ updatedAt: "2026-03-16T00:00:00.000Z",
49
+ }) as any,
50
+ new Date("2026-03-16T12:00:00.000Z"),
51
+ );
52
+
53
+ expect(staleItem?.attention).toBe("escalated");
54
+ expect(freshItem).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("escalateConflicts", () => {
59
+ it("previews eligible stale conflicts without mutating them", async () => {
60
+ const records = [
61
+ createConflictRecord({
62
+ conflictId: "00000000-0000-0000-0000-000000000001",
63
+ }),
64
+ createConflictRecord({
65
+ conflictId: "00000000-0000-0000-0000-000000000002",
66
+ createdAt: "2026-03-16T00:00:00.000Z",
67
+ updatedAt: "2026-03-16T00:00:00.000Z",
68
+ }),
69
+ ];
70
+ const replaced: any[] = [];
71
+
72
+ const result = await escalateConflicts({
73
+ conflictStore: {
74
+ async listRecent() {
75
+ return records as any;
76
+ },
77
+ async replace(record: any) {
78
+ replaced.push(record);
79
+ return record;
80
+ },
81
+ } as any,
82
+ }, {
83
+ attention: "escalated",
84
+ limit: 100,
85
+ top: 10,
86
+ apply: false,
87
+ }, { now: new Date("2026-03-17T00:00:00.000Z") });
88
+
89
+ expect(result.apply).toBe(false);
90
+ expect(result.eligible).toBe(1);
91
+ expect(result.escalated).toBe(0);
92
+ expect(result.items[0]?.action).toBe("pending");
93
+ expect(replaced).toHaveLength(0);
94
+ });
95
+
96
+ it("applies escalation metadata exactly once per attention bucket", async () => {
97
+ const records = [
98
+ createConflictRecord({
99
+ conflictId: "00000000-0000-0000-0000-000000000001",
100
+ }),
101
+ ];
102
+ const replaced: any[] = [];
103
+
104
+ const result = await escalateConflicts({
105
+ conflictStore: {
106
+ async listRecent() {
107
+ return records as any;
108
+ },
109
+ async replace(record: any) {
110
+ replaced.push(record);
111
+ return record;
112
+ },
113
+ } as any,
114
+ }, {
115
+ attention: "escalated",
116
+ limit: 100,
117
+ top: 10,
118
+ apply: true,
119
+ notes: "auto escalation test",
120
+ });
121
+
122
+ expect(result.escalated).toBe(1);
123
+ expect(result.items[0]?.action).toBe("escalated");
124
+ expect(replaced).toHaveLength(1);
125
+ expect(replaced[0]?.escalationCount).toBe(1);
126
+ expect(replaced[0]?.lastEscalationAttention).toBe("escalated");
127
+
128
+ const second = await escalateConflicts({
129
+ conflictStore: {
130
+ async listRecent() {
131
+ return replaced as any;
132
+ },
133
+ async replace(record: any) {
134
+ replaced[0] = record;
135
+ return record;
136
+ },
137
+ } as any,
138
+ }, {
139
+ attention: "escalated",
140
+ limit: 100,
141
+ top: 10,
142
+ apply: true,
143
+ });
144
+
145
+ expect(second.escalated).toBe(0);
146
+ expect(second.skipped).toBe(1);
147
+ expect(second.items[0]?.action).toBe("already-escalated");
148
+ });
149
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { summarizeConflictLifecycle } from "../conflict-lifecycle.js";
4
+ import { buildConflictCandidateRecord } from "../conflict-engine.js";
5
+
6
+ function createConflictRecord(overrides: Record<string, unknown> = {}) {
7
+ return {
8
+ ...buildConflictCandidateRecord({
9
+ canonicalKey: "user-reply-style",
10
+ category: "preferences",
11
+ fingerprint: "user-reply-style--durable-1--source-1--new-text",
12
+ reason: "promotion_conflicts_with_existing_durable",
13
+ existing: {
14
+ memoryId: "durable-1",
15
+ text: "User prefers concise, direct replies.",
16
+ category: "preferences",
17
+ scope: "memory:agent",
18
+ importance: 0.84,
19
+ metadata: "{\"source\":\"agent\"}",
20
+ canonicalKey: "user-reply-style",
21
+ },
22
+ incoming: {
23
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
24
+ category: "preferences",
25
+ scope: "memory:agent",
26
+ importance: 0.78,
27
+ metadata: "{\"source\":\"agent\"}",
28
+ source: "agent",
29
+ sourceMemoryId: "source-1",
30
+ sourceCategory: "events",
31
+ },
32
+ createdAt: "2026-03-10T00:00:00.000Z",
33
+ updatedAt: "2026-03-10T00:00:00.000Z",
34
+ }),
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("summarizeConflictLifecycle", () => {
40
+ it("marks newly opened conflicts as fresh", () => {
41
+ const record = createConflictRecord();
42
+ const summary = summarizeConflictLifecycle(record as any, new Date("2026-03-10T12:00:00.000Z"));
43
+
44
+ expect(summary.attention).toBe("fresh");
45
+ expect(summary.openAgeDays).toBe(0);
46
+ expect(summary.reopenCount).toBe(0);
47
+ expect(summary.needsAttention).toBe(false);
48
+ });
49
+
50
+ it("marks older or reopened conflicts as aging/stale/escalated", () => {
51
+ const aging = summarizeConflictLifecycle(
52
+ createConflictRecord({ createdAt: "2026-03-08T00:00:00.000Z" }) as any,
53
+ new Date("2026-03-10T00:00:00.000Z"),
54
+ );
55
+ const stale = summarizeConflictLifecycle(
56
+ createConflictRecord({ createdAt: "2026-03-05T00:00:00.000Z" }) as any,
57
+ new Date("2026-03-10T00:00:00.000Z"),
58
+ );
59
+ const escalated = summarizeConflictLifecycle(
60
+ createConflictRecord({ status: "open", reopenCount: 3 }) as any,
61
+ new Date("2026-03-10T12:00:00.000Z"),
62
+ );
63
+
64
+ expect(aging.attention).toBe("aging");
65
+ expect(stale.attention).toBe("stale");
66
+ expect(stale.needsAttention).toBe(true);
67
+ expect(escalated.attention).toBe("escalated");
68
+ expect(escalated.needsAttention).toBe(true);
69
+ });
70
+
71
+ it("marks resolved conflicts as resolved", () => {
72
+ const record = createConflictRecord({
73
+ status: "kept-existing",
74
+ resolvedAt: "2026-03-10T02:00:00.000Z",
75
+ reopenCount: 1,
76
+ });
77
+ const summary = summarizeConflictLifecycle(record as any, new Date("2026-03-10T12:00:00.000Z"));
78
+
79
+ expect(summary.attention).toBe("resolved");
80
+ expect(summary.isOpen).toBe(false);
81
+ expect(summary.reopenCount).toBe(1);
82
+ });
83
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { buildConflictAuditSummary } from "../conflict-advisor.js";
4
+ import { buildConflictCandidateRecord } from "../conflict-engine.js";
5
+ import { formatConflictAuditMarkdown } from "../conflict-output.js";
6
+
7
+ function createConflictRecord(overrides: Record<string, unknown> = {}) {
8
+ return {
9
+ ...buildConflictCandidateRecord({
10
+ canonicalKey: "user-reply-style",
11
+ category: "preferences",
12
+ fingerprint: "user-reply-style--durable-1--source-1--new-text",
13
+ reason: "promotion_conflicts_with_existing_durable",
14
+ existing: {
15
+ memoryId: "durable-1",
16
+ text: "User prefers concise, direct replies.",
17
+ category: "preferences",
18
+ scope: "memory:agent",
19
+ importance: 0.84,
20
+ metadata: "{\"source\":\"agent\"}",
21
+ canonicalKey: "user-reply-style",
22
+ },
23
+ incoming: {
24
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
25
+ category: "preferences",
26
+ scope: "memory:agent",
27
+ importance: 0.78,
28
+ metadata: "{\"source\":\"agent\"}",
29
+ source: "agent",
30
+ sourceMemoryId: "source-1",
31
+ sourceCategory: "events",
32
+ },
33
+ createdAt: "2026-03-10T00:00:00.000Z",
34
+ updatedAt: "2026-03-10T00:00:00.000Z",
35
+ }),
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe("formatConflictAuditMarkdown", () => {
41
+ it("renders audit counts, filters, and priority clusters", () => {
42
+ const summary = buildConflictAuditSummary([
43
+ createConflictRecord({
44
+ conflictId: "00000000-0000-0000-0000-000000000001",
45
+ createdAt: "2026-03-01T00:00:00.000Z",
46
+ updatedAt: "2026-03-01T00:00:00.000Z",
47
+ }) as any,
48
+ ], 3);
49
+
50
+ const markdown = formatConflictAuditMarkdown(summary, {
51
+ generatedAt: "2026-03-16T16:00:00.000Z",
52
+ limit: 100,
53
+ top: 3,
54
+ status: "open",
55
+ canonicalKey: "user-reply-style",
56
+ });
57
+
58
+ expect(markdown).toContain("# Conflict Audit");
59
+ expect(markdown).toContain("Generated: 2026-03-16T16:00:00.000Z");
60
+ expect(markdown).toContain("Filters: status=open, canonicalKey=user-reply-style, limit=100, top=3");
61
+ expect(markdown).toContain("## Suggested Actions");
62
+ expect(markdown).toContain("## Priority Clusters");
63
+ expect(markdown).toContain("Cluster key:");
64
+ expect(markdown).toContain("Advice:");
65
+ });
66
+
67
+ it("renders an empty priority-cluster section when nothing is open", () => {
68
+ const summary = buildConflictAuditSummary([
69
+ createConflictRecord({
70
+ conflictId: "00000000-0000-0000-0000-000000000001",
71
+ status: "kept-existing",
72
+ resolvedAt: "2026-03-16T00:00:00.000Z",
73
+ }) as any,
74
+ ], 3);
75
+
76
+ const markdown = formatConflictAuditMarkdown(summary, {
77
+ generatedAt: "2026-03-16T16:00:00.000Z",
78
+ limit: 50,
79
+ top: 5,
80
+ });
81
+
82
+ expect(markdown).toContain("## Priority Clusters");
83
+ expect(markdown).toContain("- None");
84
+ });
85
+ });
@@ -0,0 +1,374 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { summarizeConflictAdvice } from "../conflict-advisor.js";
7
+ import { buildConflictCandidateRecord, resolveConflictCandidate } from "../conflict-engine.js";
8
+ import { ConflictCandidateStore } from "../conflict-store.js";
9
+
10
+ const tempDirs: string[] = [];
11
+
12
+ function createTempConflictStore(): ConflictCandidateStore {
13
+ const dir = mkdtempSync(join(tmpdir(), "recallnest-conflicts-"));
14
+ tempDirs.push(dir);
15
+ return new ConflictCandidateStore(dir);
16
+ }
17
+
18
+ afterEach(() => {
19
+ while (tempDirs.length > 0) {
20
+ const dir = tempDirs.pop();
21
+ if (dir) rmSync(dir, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ function createConflictRecord() {
26
+ return buildConflictCandidateRecord({
27
+ canonicalKey: "user-reply-style",
28
+ category: "preferences",
29
+ fingerprint: "user-reply-style--durable-1--source-1--new-text",
30
+ reason: "promotion_conflicts_with_existing_durable",
31
+ existing: {
32
+ memoryId: "durable-1",
33
+ text: "User prefers concise, direct replies.",
34
+ category: "preferences",
35
+ scope: "memory:agent",
36
+ importance: 0.84,
37
+ metadata: JSON.stringify({
38
+ source: "agent",
39
+ canonicalKey: "user-reply-style",
40
+ boundary: {
41
+ layer: "durable",
42
+ authority: "structured-memory",
43
+ conflictPolicy: "latest-wins",
44
+ originalCategory: "preferences",
45
+ },
46
+ }),
47
+ canonicalKey: "user-reply-style",
48
+ },
49
+ incoming: {
50
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
51
+ category: "preferences",
52
+ scope: "memory:agent",
53
+ importance: 0.78,
54
+ metadata: JSON.stringify({
55
+ source: "agent",
56
+ canonicalKey: "user-reply-style",
57
+ boundary: {
58
+ layer: "durable",
59
+ authority: "structured-memory",
60
+ conflictPolicy: "latest-wins",
61
+ originalCategory: "preferences",
62
+ },
63
+ promotedFrom: {
64
+ memoryId: "source-1",
65
+ scope: "cc:session-1",
66
+ category: "events",
67
+ },
68
+ }),
69
+ source: "agent",
70
+ sourceMemoryId: "source-1",
71
+ sourceCategory: "events",
72
+ },
73
+ });
74
+ }
75
+
76
+ function createMergeableConflictRecord() {
77
+ return buildConflictCandidateRecord({
78
+ canonicalKey: "user-reply-style",
79
+ category: "preferences",
80
+ fingerprint: "user-reply-style--durable-1--source-1--mergeable",
81
+ reason: "promotion_conflicts_with_existing_durable",
82
+ existing: {
83
+ memoryId: "durable-1",
84
+ text: "User prefers concise grounded technical replies; avoid sales language.",
85
+ category: "preferences",
86
+ scope: "memory:agent",
87
+ importance: 0.84,
88
+ metadata: JSON.stringify({
89
+ source: "agent",
90
+ canonicalKey: "user-reply-style",
91
+ boundary: {
92
+ layer: "durable",
93
+ authority: "structured-memory",
94
+ conflictPolicy: "latest-wins",
95
+ originalCategory: "preferences",
96
+ },
97
+ }),
98
+ canonicalKey: "user-reply-style",
99
+ },
100
+ incoming: {
101
+ text: "User prefers concise grounded technical replies; keep the tone colloquial.",
102
+ category: "preferences",
103
+ scope: "memory:agent",
104
+ importance: 0.78,
105
+ metadata: JSON.stringify({
106
+ source: "agent",
107
+ canonicalKey: "user-reply-style",
108
+ boundary: {
109
+ layer: "durable",
110
+ authority: "structured-memory",
111
+ conflictPolicy: "latest-wins",
112
+ originalCategory: "preferences",
113
+ },
114
+ promotedFrom: {
115
+ memoryId: "source-1",
116
+ scope: "cc:session-1",
117
+ category: "events",
118
+ },
119
+ }),
120
+ source: "agent",
121
+ sourceMemoryId: "source-1",
122
+ sourceCategory: "events",
123
+ },
124
+ });
125
+ }
126
+
127
+ describe("ConflictCandidateStore", () => {
128
+ it("saves, lists, and fetches open conflicts by fingerprint", async () => {
129
+ const store = createTempConflictStore();
130
+ const record = createConflictRecord();
131
+ await store.save(record);
132
+
133
+ const fetched = await store.getById(record.conflictId);
134
+ expect(fetched?.conflictId).toBe(record.conflictId);
135
+
136
+ const byFingerprint = await store.getOpenByFingerprint(record.fingerprint);
137
+ expect(byFingerprint?.conflictId).toBe(record.conflictId);
138
+
139
+ const recent = await store.listRecent({
140
+ status: "open",
141
+ canonicalKey: "user-reply-style",
142
+ limit: 10,
143
+ });
144
+ expect(recent).toHaveLength(1);
145
+ expect(recent[0]?.conflictId).toBe(record.conflictId);
146
+ });
147
+
148
+ it("accepts a unique short conflict ID prefix", async () => {
149
+ const store = createTempConflictStore();
150
+ const record = createConflictRecord();
151
+ await store.save(record);
152
+
153
+ const fetched = await store.getById(record.conflictId.slice(0, 8));
154
+ expect(fetched?.conflictId).toBe(record.conflictId);
155
+ });
156
+ });
157
+
158
+ describe("resolveConflictCandidate", () => {
159
+ it("accepts incoming text and updates the existing durable memory", async () => {
160
+ const conflictStore = createTempConflictStore();
161
+ const record = createConflictRecord();
162
+ await conflictStore.save(record);
163
+
164
+ const updatedPayloads: any[] = [];
165
+ const existingMemory = {
166
+ id: "durable-1",
167
+ text: "User prefers concise, direct replies.",
168
+ vector: [1, 1, 1],
169
+ category: "preferences",
170
+ scope: "memory:agent",
171
+ importance: 0.84,
172
+ timestamp: 1_700_000_000_001,
173
+ metadata: record.existing.metadata,
174
+ };
175
+
176
+ const result = await resolveConflictCandidate({
177
+ store: {
178
+ async getById(id: string) {
179
+ return id === existingMemory.id ? existingMemory : null;
180
+ },
181
+ async update(id: string, updates: any) {
182
+ updatedPayloads.push({ id, updates });
183
+ Object.assign(existingMemory, updates);
184
+ return existingMemory;
185
+ },
186
+ },
187
+ embedder: {
188
+ async embedPassage(text: string) {
189
+ return [text.length, 9, 9];
190
+ },
191
+ },
192
+ conflictStore,
193
+ }, {
194
+ conflictId: record.conflictId,
195
+ resolution: "accept_incoming",
196
+ notes: "Promoted evidence is cleaner than the original durable wording.",
197
+ });
198
+
199
+ expect(result.status).toBe("accepted-incoming");
200
+ expect(result.updatedMemoryId).toBe("durable-1");
201
+ expect(updatedPayloads).toHaveLength(1);
202
+ expect(updatedPayloads[0]?.updates.text).toBe(record.incoming.text);
203
+ expect(updatedPayloads[0]?.updates.metadata).toBe(record.incoming.metadata);
204
+
205
+ const resolved = await conflictStore.getById(record.conflictId);
206
+ expect(resolved?.status).toBe("accepted-incoming");
207
+ expect(resolved?.resolutionNotes).toContain("cleaner");
208
+ });
209
+
210
+ it("can keep the existing durable memory without updating it", async () => {
211
+ const conflictStore = createTempConflictStore();
212
+ const record = createConflictRecord();
213
+ await conflictStore.save(record);
214
+
215
+ let updated = false;
216
+ const result = await resolveConflictCandidate({
217
+ store: {
218
+ async getById() {
219
+ return null;
220
+ },
221
+ async update() {
222
+ updated = true;
223
+ return null;
224
+ },
225
+ },
226
+ embedder: {
227
+ async embedPassage() {
228
+ return [0, 0, 0];
229
+ },
230
+ },
231
+ conflictStore,
232
+ }, {
233
+ conflictId: record.conflictId,
234
+ resolution: "keep_existing",
235
+ });
236
+
237
+ expect(result.status).toBe("kept-existing");
238
+ expect(updated).toBe(false);
239
+
240
+ const resolved = await conflictStore.getById(record.conflictId);
241
+ expect(resolved?.status).toBe("kept-existing");
242
+ });
243
+
244
+ it("merges same-category conflicts using the default merge suggestion", async () => {
245
+ const conflictStore = createTempConflictStore();
246
+ const record = createMergeableConflictRecord();
247
+ await conflictStore.save(record);
248
+
249
+ const updatedPayloads: any[] = [];
250
+ const existingMemory = {
251
+ id: "durable-1",
252
+ text: record.existing.text,
253
+ vector: [1, 1, 1],
254
+ category: "preferences",
255
+ scope: "memory:agent",
256
+ importance: 0.84,
257
+ timestamp: 1_700_000_000_001,
258
+ metadata: record.existing.metadata,
259
+ };
260
+
261
+ const mergeSuggestion = summarizeConflictAdvice(record).mergeSuggestion;
262
+ expect(mergeSuggestion).toBeTruthy();
263
+
264
+ const result = await resolveConflictCandidate({
265
+ store: {
266
+ async getById(id: string) {
267
+ return id === existingMemory.id ? existingMemory : null;
268
+ },
269
+ async update(id: string, updates: any) {
270
+ updatedPayloads.push({ id, updates });
271
+ Object.assign(existingMemory, updates);
272
+ return existingMemory;
273
+ },
274
+ },
275
+ embedder: {
276
+ async embedPassage(text: string) {
277
+ return [text.length, 7, 7];
278
+ },
279
+ },
280
+ conflictStore,
281
+ }, {
282
+ conflictId: record.conflictId,
283
+ resolution: "merge",
284
+ notes: "Combine the grounded style and tone guidance.",
285
+ });
286
+
287
+ expect(result.status).toBe("merged");
288
+ expect(result.updatedMemoryId).toBe("durable-1");
289
+ expect(updatedPayloads).toHaveLength(1);
290
+ expect(updatedPayloads[0]?.updates.text).toBe(mergeSuggestion);
291
+ expect(updatedPayloads[0]?.updates.importance).toBe(0.84);
292
+
293
+ const mergedMetadata = JSON.parse(updatedPayloads[0]?.updates.metadata || "{}");
294
+ expect(mergedMetadata.canonicalKey).toBe("user-reply-style");
295
+ expect(mergedMetadata.promotedFrom?.memoryId).toBe("source-1");
296
+ expect(mergedMetadata.mergedFrom?.conflictId).toBe(record.conflictId);
297
+
298
+ const resolved = await conflictStore.getById(record.conflictId);
299
+ expect(resolved?.status).toBe("merged");
300
+ expect(resolved?.resolutionNotes).toContain("grounded style");
301
+ });
302
+
303
+ it("rejects merge for cross-category conflicts", async () => {
304
+ const conflictStore = createTempConflictStore();
305
+ const record = buildConflictCandidateRecord({
306
+ canonicalKey: "project-owner",
307
+ category: "events",
308
+ fingerprint: "project-owner--durable-1--source-2--cross-category",
309
+ reason: "canonical_key_conflicts_with_existing_durable",
310
+ existing: {
311
+ memoryId: "durable-1",
312
+ text: "Project owner: RecallNest",
313
+ category: "entities",
314
+ scope: "memory:agent",
315
+ importance: 0.82,
316
+ metadata: recordLikeMetadata("project-owner", "entities"),
317
+ canonicalKey: "project-owner",
318
+ },
319
+ incoming: {
320
+ text: "Project owner observation imported as an event.",
321
+ category: "events",
322
+ scope: "memory:agent",
323
+ importance: 0.74,
324
+ metadata: recordLikeMetadata("project-owner", "events"),
325
+ source: "agent",
326
+ sourceMemoryId: "source-2",
327
+ sourceCategory: "events",
328
+ },
329
+ });
330
+ await conflictStore.save(record);
331
+
332
+ await expect(resolveConflictCandidate({
333
+ store: {
334
+ async getById() {
335
+ return {
336
+ id: "durable-1",
337
+ text: "Project owner: RecallNest",
338
+ vector: [1, 1, 1],
339
+ category: "entities",
340
+ scope: "memory:agent",
341
+ importance: 0.82,
342
+ timestamp: 1_700_000_000_002,
343
+ metadata: record.existing.metadata,
344
+ };
345
+ },
346
+ async update() {
347
+ throw new Error("update should not be called");
348
+ },
349
+ },
350
+ embedder: {
351
+ async embedPassage() {
352
+ return [0, 0, 0];
353
+ },
354
+ },
355
+ conflictStore,
356
+ }, {
357
+ conflictId: record.conflictId,
358
+ resolution: "merge",
359
+ })).rejects.toThrow("same-category durable conflicts");
360
+ });
361
+ });
362
+
363
+ function recordLikeMetadata(canonicalKey: string, category: string): string {
364
+ return JSON.stringify({
365
+ source: "agent",
366
+ canonicalKey,
367
+ boundary: {
368
+ layer: "durable",
369
+ authority: "structured-memory",
370
+ conflictPolicy: "latest-wins",
371
+ originalCategory: category,
372
+ },
373
+ });
374
+ }