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.
- package/CHANGELOG.md +187 -0
- package/README.md +49 -38
- package/README_CN.md +46 -31
- package/assets/ui/app.js +1031 -0
- package/assets/ui/index.html +224 -0
- package/assets/ui/styles.css +781 -0
- package/config.json.example +4 -1
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/connector-spec.md +126 -0
- package/eval/cases-canary.json +58 -0
- package/eval/cases-scope-robustness.json +278 -0
- package/eval/cases.json +117 -20
- package/package.json +25 -4
- package/src/__tests__/access-tracker-novelty.test.ts +304 -0
- package/src/__tests__/activity-counter.test.ts +125 -0
- package/src/__tests__/adaptive-fidelity.test.ts +102 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/babel-integration.test.ts +144 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/canary-eval.test.ts +114 -0
- package/src/__tests__/capture-engine.test.ts +1328 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +116 -0
- package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
- package/src/__tests__/circuit-breaker.test.ts +99 -0
- package/src/__tests__/cluster-consolidation.test.ts +945 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +261 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +418 -0
- package/src/__tests__/connector-v1.test.ts +181 -0
- package/src/__tests__/consolidation-engine.test.ts +430 -0
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +262 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/data-checkup.test.ts +276 -0
- package/src/__tests__/decay-exemption.test.ts +129 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +305 -0
- package/src/__tests__/distill-session-argshape.test.ts +79 -0
- package/src/__tests__/doctor.test.ts +319 -0
- package/src/__tests__/dream-pipeline.test.ts +780 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/emotion-decay.test.ts +46 -0
- package/src/__tests__/emotion-detector.test.ts +56 -0
- package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
- package/src/__tests__/emotion-retrieval.test.ts +56 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/env-config.test.ts +151 -0
- package/src/__tests__/error-signature-boost.test.ts +87 -0
- package/src/__tests__/error-signature.test.ts +95 -0
- package/src/__tests__/error-taxonomy.test.ts +129 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/feature-flags-integration.test.ts +54 -0
- package/src/__tests__/frequency-tracker.test.ts +113 -0
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/hotness-blend.test.ts +283 -0
- package/src/__tests__/ingest-chunking.test.ts +44 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/kg-extractor.test.ts +192 -0
- package/src/__tests__/kg-store.test.ts +300 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/maintenance-locks.test.ts +189 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/mcp-related-scopes.test.ts +151 -0
- package/src/__tests__/memory-boundaries.test.ts +282 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-lint.test.ts +421 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/memory-promotion.test.ts +315 -0
- package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/noise-filter.test.ts +205 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/obsidian-connector.test.ts +363 -0
- package/src/__tests__/omitted-reporting.test.ts +98 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/pii-redact.test.ts +333 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +282 -0
- package/src/__tests__/preference-slots.test.ts +205 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander-alias.test.ts +146 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4898 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/scope-suggester.test.ts +80 -0
- package/src/__tests__/session-checkpoint.test.ts +291 -0
- package/src/__tests__/session-distiller.test.ts +640 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/skill-engine.test.ts +552 -0
- package/src/__tests__/skill-promotion.test.ts +654 -0
- package/src/__tests__/skill-verifier.test.ts +122 -0
- package/src/__tests__/source-heartbeat.test.ts +250 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-patch-metadata.test.ts +205 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/store-upsert.test.ts +201 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-error-surfacing.test.ts +98 -0
- package/src/__tests__/tool-output-compressor.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +839 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/__tests__/usage-tracker.test.ts +287 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
- package/src/__tests__/workflow-observation.test.ts +433 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- package/src/access-tracker.ts +90 -34
- package/src/activity-counter.ts +46 -18
- package/src/adaptive-retrieval.ts +11 -0
- package/src/aliases.ts +133 -0
- package/src/api-server.ts +36 -9
- package/src/audit-log.ts +4 -2
- package/src/auto-consolidation.ts +8 -3
- package/src/auto-gc.ts +85 -21
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +142 -32
- package/src/cli.ts +268 -41
- package/src/confidence-tracker.ts +36 -18
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +255 -23
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/context-composer.ts +8 -1
- package/src/data-checkup.ts +96 -7
- package/src/decay-engine.ts +59 -7
- package/src/distill-lock.ts +194 -15
- package/src/doctor.ts +13 -4
- package/src/dream-pipeline.ts +473 -41
- package/src/env-config.ts +118 -0
- package/src/error-signature.ts +153 -0
- package/src/error-taxonomy.ts +115 -0
- package/src/eval.ts +402 -14
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/idempotency.ts +8 -0
- package/src/ingest.ts +308 -12
- package/src/kg-extractor.ts +13 -3
- package/src/kg-store.ts +236 -44
- package/src/llm-client.ts +106 -11
- package/src/llm-consolidation.ts +2 -1
- package/src/mcp-server.ts +87 -1800
- package/src/mcp-tool-deps.ts +48 -0
- package/src/mcp-tools-advanced.ts +954 -0
- package/src/mcp-tools-core.ts +637 -0
- package/src/mcp-tools-governance.ts +475 -0
- package/src/memory-boundaries.ts +26 -3
- package/src/memory-evolution.ts +73 -1
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-lint.ts +183 -10
- package/src/memory-output.ts +129 -4
- package/src/memory-promotion.ts +246 -0
- package/src/memory-schema.ts +25 -2
- package/src/memory-store-port.ts +46 -0
- package/src/multi-vector.ts +2 -1
- package/src/narrative-schema.ts +2 -1
- package/src/noise-filter.ts +57 -6
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +207 -5
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/query-expander.ts +90 -9
- package/src/recall-gate.ts +305 -0
- package/src/result-synthesizer.ts +2 -1
- package/src/retention-policy.ts +2 -1
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +354 -157
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +78 -6
- package/src/scope-suggester.ts +113 -0
- package/src/session-distiller.ts +23 -2
- package/src/session-engine.ts +2 -1
- package/src/session-schema.ts +1 -0
- package/src/session-store.ts +5 -0
- package/src/skill-engine.ts +78 -0
- package/src/skill-promotion.ts +304 -48
- package/src/skill-schema.ts +16 -3
- package/src/skill-verifier.ts +152 -0
- package/src/store.ts +321 -57
- package/src/term-registry.ts +35 -0
- package/src/tool-output-compressor.ts +3 -1
- package/src/ui-server.ts +9 -1
- package/src/usage-tracker.ts +242 -0
- package/src/version-manager.ts +3 -2
- package/src/version.ts +19 -0
- package/src/workflow-observation-engine.ts +2 -1
- package/src/workflow-observation-schema.ts +2 -1
- package/src/workflow-observation-store.ts +6 -2
- package/.env +0 -4
- package/CLAUDE.md +0 -46
- package/config.json +0 -55
- package/daily-recall.sh +0 -110
- package/logs/daily-recall.error.log +0 -0
- package/logs/daily-recall.log +0 -60
- package/logs/ingest-2026-04-11.log +0 -110
- package/logs/ingest-2026-04-12.log +0 -90
- package/logs/ingest-2026-04-13.log +0 -87
- package/logs/launchagent-stderr.log +0 -0
- package/logs/launchagent-stdout.log +0 -0
|
@@ -0,0 +1,418 @@
|
|
|
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
|
+
import { parseEvolution } from "../memory-evolution.js";
|
|
10
|
+
|
|
11
|
+
const tempDirs: string[] = [];
|
|
12
|
+
|
|
13
|
+
function createTempConflictStore(): ConflictCandidateStore {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "recallnest-conflicts-"));
|
|
15
|
+
tempDirs.push(dir);
|
|
16
|
+
return new ConflictCandidateStore(dir);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
while (tempDirs.length > 0) {
|
|
21
|
+
const dir = tempDirs.pop();
|
|
22
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function createConflictRecord() {
|
|
27
|
+
return buildConflictCandidateRecord({
|
|
28
|
+
canonicalKey: "user-reply-style",
|
|
29
|
+
category: "preferences",
|
|
30
|
+
fingerprint: "user-reply-style--durable-1--source-1--new-text",
|
|
31
|
+
reason: "promotion_conflicts_with_existing_durable",
|
|
32
|
+
existing: {
|
|
33
|
+
memoryId: "durable-1",
|
|
34
|
+
text: "User prefers concise, direct replies.",
|
|
35
|
+
category: "preferences",
|
|
36
|
+
scope: "memory:agent",
|
|
37
|
+
importance: 0.84,
|
|
38
|
+
metadata: JSON.stringify({
|
|
39
|
+
source: "agent",
|
|
40
|
+
canonicalKey: "user-reply-style",
|
|
41
|
+
boundary: {
|
|
42
|
+
layer: "durable",
|
|
43
|
+
authority: "structured-memory",
|
|
44
|
+
conflictPolicy: "latest-wins",
|
|
45
|
+
originalCategory: "preferences",
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
canonicalKey: "user-reply-style",
|
|
49
|
+
},
|
|
50
|
+
incoming: {
|
|
51
|
+
text: "User prefers colloquial writing that stays grounded and non-salesy.",
|
|
52
|
+
category: "preferences",
|
|
53
|
+
scope: "memory:agent",
|
|
54
|
+
importance: 0.78,
|
|
55
|
+
metadata: JSON.stringify({
|
|
56
|
+
source: "agent",
|
|
57
|
+
canonicalKey: "user-reply-style",
|
|
58
|
+
boundary: {
|
|
59
|
+
layer: "durable",
|
|
60
|
+
authority: "structured-memory",
|
|
61
|
+
conflictPolicy: "latest-wins",
|
|
62
|
+
originalCategory: "preferences",
|
|
63
|
+
},
|
|
64
|
+
promotedFrom: {
|
|
65
|
+
memoryId: "source-1",
|
|
66
|
+
scope: "cc:session-1",
|
|
67
|
+
category: "events",
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
source: "agent",
|
|
71
|
+
sourceMemoryId: "source-1",
|
|
72
|
+
sourceCategory: "events",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createMergeableConflictRecord() {
|
|
78
|
+
return buildConflictCandidateRecord({
|
|
79
|
+
canonicalKey: "user-reply-style",
|
|
80
|
+
category: "preferences",
|
|
81
|
+
fingerprint: "user-reply-style--durable-1--source-1--mergeable",
|
|
82
|
+
reason: "promotion_conflicts_with_existing_durable",
|
|
83
|
+
existing: {
|
|
84
|
+
memoryId: "durable-1",
|
|
85
|
+
text: "User prefers concise grounded technical replies; avoid sales language.",
|
|
86
|
+
category: "preferences",
|
|
87
|
+
scope: "memory:agent",
|
|
88
|
+
importance: 0.84,
|
|
89
|
+
metadata: JSON.stringify({
|
|
90
|
+
source: "agent",
|
|
91
|
+
canonicalKey: "user-reply-style",
|
|
92
|
+
boundary: {
|
|
93
|
+
layer: "durable",
|
|
94
|
+
authority: "structured-memory",
|
|
95
|
+
conflictPolicy: "latest-wins",
|
|
96
|
+
originalCategory: "preferences",
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
canonicalKey: "user-reply-style",
|
|
100
|
+
},
|
|
101
|
+
incoming: {
|
|
102
|
+
text: "User prefers concise grounded technical replies; keep the tone colloquial.",
|
|
103
|
+
category: "preferences",
|
|
104
|
+
scope: "memory:agent",
|
|
105
|
+
importance: 0.78,
|
|
106
|
+
metadata: JSON.stringify({
|
|
107
|
+
source: "agent",
|
|
108
|
+
canonicalKey: "user-reply-style",
|
|
109
|
+
boundary: {
|
|
110
|
+
layer: "durable",
|
|
111
|
+
authority: "structured-memory",
|
|
112
|
+
conflictPolicy: "latest-wins",
|
|
113
|
+
originalCategory: "preferences",
|
|
114
|
+
},
|
|
115
|
+
promotedFrom: {
|
|
116
|
+
memoryId: "source-1",
|
|
117
|
+
scope: "cc:session-1",
|
|
118
|
+
category: "events",
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
source: "agent",
|
|
122
|
+
sourceMemoryId: "source-1",
|
|
123
|
+
sourceCategory: "events",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("ConflictCandidateStore", () => {
|
|
129
|
+
it("saves, lists, and fetches open conflicts by fingerprint", async () => {
|
|
130
|
+
const store = createTempConflictStore();
|
|
131
|
+
const record = createConflictRecord();
|
|
132
|
+
await store.save(record);
|
|
133
|
+
|
|
134
|
+
const fetched = await store.getById(record.conflictId);
|
|
135
|
+
expect(fetched?.conflictId).toBe(record.conflictId);
|
|
136
|
+
|
|
137
|
+
const byFingerprint = await store.getOpenByFingerprint(record.fingerprint);
|
|
138
|
+
expect(byFingerprint?.conflictId).toBe(record.conflictId);
|
|
139
|
+
|
|
140
|
+
const recent = await store.listRecent({
|
|
141
|
+
status: "open",
|
|
142
|
+
canonicalKey: "user-reply-style",
|
|
143
|
+
limit: 10,
|
|
144
|
+
});
|
|
145
|
+
expect(recent).toHaveLength(1);
|
|
146
|
+
expect(recent[0]?.conflictId).toBe(record.conflictId);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("accepts a unique short conflict ID prefix", async () => {
|
|
150
|
+
const store = createTempConflictStore();
|
|
151
|
+
const record = createConflictRecord();
|
|
152
|
+
await store.save(record);
|
|
153
|
+
|
|
154
|
+
const fetched = await store.getById(record.conflictId.slice(0, 8));
|
|
155
|
+
expect(fetched?.conflictId).toBe(record.conflictId);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("resolveConflictCandidate", () => {
|
|
160
|
+
it("accepts incoming text and updates the existing durable memory", async () => {
|
|
161
|
+
const conflictStore = createTempConflictStore();
|
|
162
|
+
const record = createConflictRecord();
|
|
163
|
+
await conflictStore.save(record);
|
|
164
|
+
|
|
165
|
+
const updatedPayloads: any[] = [];
|
|
166
|
+
const archivedRows: any[] = [];
|
|
167
|
+
const originalExistingText = "User prefers concise, direct replies.";
|
|
168
|
+
const existingMemory = {
|
|
169
|
+
id: "durable-1",
|
|
170
|
+
text: originalExistingText,
|
|
171
|
+
vector: [1, 1, 1],
|
|
172
|
+
category: "preferences",
|
|
173
|
+
scope: "memory:agent",
|
|
174
|
+
importance: 0.84,
|
|
175
|
+
timestamp: 1_700_000_000_001,
|
|
176
|
+
metadata: record.existing.metadata,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const result = await resolveConflictCandidate({
|
|
180
|
+
store: {
|
|
181
|
+
async getById(id: string) {
|
|
182
|
+
return id === existingMemory.id ? existingMemory : null;
|
|
183
|
+
},
|
|
184
|
+
async update(id: string, updates: any) {
|
|
185
|
+
updatedPayloads.push({ id, updates });
|
|
186
|
+
Object.assign(existingMemory, updates);
|
|
187
|
+
return existingMemory;
|
|
188
|
+
},
|
|
189
|
+
async store(entry: any) {
|
|
190
|
+
archivedRows.push(entry);
|
|
191
|
+
return entry;
|
|
192
|
+
},
|
|
193
|
+
async upsert(entry: any) {
|
|
194
|
+
archivedRows.push(entry);
|
|
195
|
+
return entry;
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
embedder: {
|
|
199
|
+
async embedPassage(text: string) {
|
|
200
|
+
return [text.length, 9, 9];
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
conflictStore,
|
|
204
|
+
}, {
|
|
205
|
+
conflictId: record.conflictId,
|
|
206
|
+
resolution: "accept_incoming",
|
|
207
|
+
notes: "Promoted evidence is cleaner than the original durable wording.",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(result.status).toBe("accepted-incoming");
|
|
211
|
+
expect(result.updatedMemoryId).toBe("durable-1");
|
|
212
|
+
expect(updatedPayloads).toHaveLength(1);
|
|
213
|
+
expect(updatedPayloads[0]?.updates.text).toBe(record.incoming.text);
|
|
214
|
+
|
|
215
|
+
// The incoming metadata is carried through, with an evolution block layered on top
|
|
216
|
+
// that links the live row back to the archived version (it is no longer passed verbatim).
|
|
217
|
+
const incomingMeta = JSON.parse(record.incoming.metadata);
|
|
218
|
+
const writtenMeta = JSON.parse(updatedPayloads[0]?.updates.metadata);
|
|
219
|
+
for (const [key, value] of Object.entries(incomingMeta)) {
|
|
220
|
+
if (key === "evolution") continue;
|
|
221
|
+
expect(writtenMeta[key]).toEqual(value);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Accepting the incoming version must not destroy the belief it replaced.
|
|
225
|
+
expect(archivedRows).toHaveLength(1);
|
|
226
|
+
expect(archivedRows[0].text).toBe(originalExistingText);
|
|
227
|
+
const archivedEvo = parseEvolution(archivedRows[0].metadata);
|
|
228
|
+
expect(archivedEvo.status).toBe("superseded");
|
|
229
|
+
expect(archivedEvo.supersededBy).toBe("durable-1");
|
|
230
|
+
expect(parseEvolution(updatedPayloads[0]?.updates.metadata).supersedes).toBe(archivedRows[0].id);
|
|
231
|
+
|
|
232
|
+
const resolved = await conflictStore.getById(record.conflictId);
|
|
233
|
+
expect(resolved?.status).toBe("accepted-incoming");
|
|
234
|
+
expect(resolved?.resolutionNotes).toContain("cleaner");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("can keep the existing durable memory without updating it", async () => {
|
|
238
|
+
const conflictStore = createTempConflictStore();
|
|
239
|
+
const record = createConflictRecord();
|
|
240
|
+
await conflictStore.save(record);
|
|
241
|
+
|
|
242
|
+
let updated = false;
|
|
243
|
+
const result = await resolveConflictCandidate({
|
|
244
|
+
store: {
|
|
245
|
+
async getById() {
|
|
246
|
+
return null;
|
|
247
|
+
},
|
|
248
|
+
async update() {
|
|
249
|
+
updated = true;
|
|
250
|
+
return null;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
embedder: {
|
|
254
|
+
async embedPassage() {
|
|
255
|
+
return [0, 0, 0];
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
conflictStore,
|
|
259
|
+
}, {
|
|
260
|
+
conflictId: record.conflictId,
|
|
261
|
+
resolution: "keep_existing",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result.status).toBe("kept-existing");
|
|
265
|
+
expect(updated).toBe(false);
|
|
266
|
+
|
|
267
|
+
const resolved = await conflictStore.getById(record.conflictId);
|
|
268
|
+
expect(resolved?.status).toBe("kept-existing");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("merges same-category conflicts using the default merge suggestion", async () => {
|
|
272
|
+
const conflictStore = createTempConflictStore();
|
|
273
|
+
const record = createMergeableConflictRecord();
|
|
274
|
+
await conflictStore.save(record);
|
|
275
|
+
|
|
276
|
+
const updatedPayloads: any[] = [];
|
|
277
|
+
const existingMemory = {
|
|
278
|
+
id: "durable-1",
|
|
279
|
+
text: record.existing.text,
|
|
280
|
+
vector: [1, 1, 1],
|
|
281
|
+
category: "preferences",
|
|
282
|
+
scope: "memory:agent",
|
|
283
|
+
importance: 0.84,
|
|
284
|
+
timestamp: 1_700_000_000_001,
|
|
285
|
+
metadata: record.existing.metadata,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const mergeSuggestion = summarizeConflictAdvice(record).mergeSuggestion;
|
|
289
|
+
expect(mergeSuggestion).toBeTruthy();
|
|
290
|
+
const archivedRows: any[] = [];
|
|
291
|
+
const originalExistingText = existingMemory.text;
|
|
292
|
+
|
|
293
|
+
const result = await resolveConflictCandidate({
|
|
294
|
+
store: {
|
|
295
|
+
async getById(id: string) {
|
|
296
|
+
return id === existingMemory.id ? existingMemory : null;
|
|
297
|
+
},
|
|
298
|
+
async update(id: string, updates: any) {
|
|
299
|
+
updatedPayloads.push({ id, updates });
|
|
300
|
+
Object.assign(existingMemory, updates);
|
|
301
|
+
return existingMemory;
|
|
302
|
+
},
|
|
303
|
+
async store(entry: any) {
|
|
304
|
+
archivedRows.push(entry);
|
|
305
|
+
return entry;
|
|
306
|
+
},
|
|
307
|
+
async upsert(entry: any) {
|
|
308
|
+
archivedRows.push(entry);
|
|
309
|
+
return entry;
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
embedder: {
|
|
313
|
+
async embedPassage(text: string) {
|
|
314
|
+
return [text.length, 7, 7];
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
conflictStore,
|
|
318
|
+
}, {
|
|
319
|
+
conflictId: record.conflictId,
|
|
320
|
+
resolution: "merge",
|
|
321
|
+
notes: "Combine the grounded style and tone guidance.",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(result.status).toBe("merged");
|
|
325
|
+
expect(result.updatedMemoryId).toBe("durable-1");
|
|
326
|
+
expect(updatedPayloads).toHaveLength(1);
|
|
327
|
+
expect(updatedPayloads[0]?.updates.text).toBe(mergeSuggestion);
|
|
328
|
+
expect(updatedPayloads[0]?.updates.importance).toBe(0.84);
|
|
329
|
+
|
|
330
|
+
const mergedMetadata = JSON.parse(updatedPayloads[0]?.updates.metadata || "{}");
|
|
331
|
+
expect(mergedMetadata.canonicalKey).toBe("user-reply-style");
|
|
332
|
+
expect(mergedMetadata.promotedFrom?.memoryId).toBe("source-1");
|
|
333
|
+
expect(mergedMetadata.mergedFrom?.conflictId).toBe(record.conflictId);
|
|
334
|
+
|
|
335
|
+
// A merge still replaces the existing row's text, so the pre-merge belief must survive
|
|
336
|
+
// as history — otherwise the "before" half of the merge decision is unrecoverable.
|
|
337
|
+
expect(archivedRows).toHaveLength(1);
|
|
338
|
+
expect(archivedRows[0].text).toBe(originalExistingText);
|
|
339
|
+
expect(parseEvolution(archivedRows[0].metadata).status).toBe("superseded");
|
|
340
|
+
expect(parseEvolution(updatedPayloads[0]?.updates.metadata).supersedes).toBe(archivedRows[0].id);
|
|
341
|
+
|
|
342
|
+
const resolved = await conflictStore.getById(record.conflictId);
|
|
343
|
+
expect(resolved?.status).toBe("merged");
|
|
344
|
+
expect(resolved?.resolutionNotes).toContain("grounded style");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("rejects merge for cross-category conflicts", async () => {
|
|
348
|
+
const conflictStore = createTempConflictStore();
|
|
349
|
+
const record = buildConflictCandidateRecord({
|
|
350
|
+
canonicalKey: "project-owner",
|
|
351
|
+
category: "events",
|
|
352
|
+
fingerprint: "project-owner--durable-1--source-2--cross-category",
|
|
353
|
+
reason: "canonical_key_conflicts_with_existing_durable",
|
|
354
|
+
existing: {
|
|
355
|
+
memoryId: "durable-1",
|
|
356
|
+
text: "Project owner: RecallNest",
|
|
357
|
+
category: "entities",
|
|
358
|
+
scope: "memory:agent",
|
|
359
|
+
importance: 0.82,
|
|
360
|
+
metadata: recordLikeMetadata("project-owner", "entities"),
|
|
361
|
+
canonicalKey: "project-owner",
|
|
362
|
+
},
|
|
363
|
+
incoming: {
|
|
364
|
+
text: "Project owner observation imported as an event.",
|
|
365
|
+
category: "events",
|
|
366
|
+
scope: "memory:agent",
|
|
367
|
+
importance: 0.74,
|
|
368
|
+
metadata: recordLikeMetadata("project-owner", "events"),
|
|
369
|
+
source: "agent",
|
|
370
|
+
sourceMemoryId: "source-2",
|
|
371
|
+
sourceCategory: "events",
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
await conflictStore.save(record);
|
|
375
|
+
|
|
376
|
+
await expect(resolveConflictCandidate({
|
|
377
|
+
store: {
|
|
378
|
+
async getById() {
|
|
379
|
+
return {
|
|
380
|
+
id: "durable-1",
|
|
381
|
+
text: "Project owner: RecallNest",
|
|
382
|
+
vector: [1, 1, 1],
|
|
383
|
+
category: "entities",
|
|
384
|
+
scope: "memory:agent",
|
|
385
|
+
importance: 0.82,
|
|
386
|
+
timestamp: 1_700_000_000_002,
|
|
387
|
+
metadata: record.existing.metadata,
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
async update() {
|
|
391
|
+
throw new Error("update should not be called");
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
embedder: {
|
|
395
|
+
async embedPassage() {
|
|
396
|
+
return [0, 0, 0];
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
conflictStore,
|
|
400
|
+
}, {
|
|
401
|
+
conflictId: record.conflictId,
|
|
402
|
+
resolution: "merge",
|
|
403
|
+
})).rejects.toThrow("same-category durable conflicts");
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
function recordLikeMetadata(canonicalKey: string, category: string): string {
|
|
408
|
+
return JSON.stringify({
|
|
409
|
+
source: "agent",
|
|
410
|
+
canonicalKey,
|
|
411
|
+
boundary: {
|
|
412
|
+
layer: "durable",
|
|
413
|
+
authority: "structured-memory",
|
|
414
|
+
conflictPolicy: "latest-wins",
|
|
415
|
+
originalCategory: category,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
detectFormat,
|
|
5
|
+
normalizeConnectorV1,
|
|
6
|
+
normalizeConversation,
|
|
7
|
+
} from "../conversation-importer.js";
|
|
8
|
+
import { isConnectorOutputV1, type ConnectorOutputV1 } from "../connector-types.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// isConnectorOutputV1 validator
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
describe("isConnectorOutputV1", () => {
|
|
15
|
+
it("validates a correct connector-v1 payload", () => {
|
|
16
|
+
const valid: ConnectorOutputV1 = {
|
|
17
|
+
version: "connector-v1",
|
|
18
|
+
source: "obsidian",
|
|
19
|
+
scope: "vault:notes",
|
|
20
|
+
producedAt: "2026-04-13T00:00:00Z",
|
|
21
|
+
records: [{ id: "r1", text: "hello" }],
|
|
22
|
+
};
|
|
23
|
+
expect(isConnectorOutputV1(valid)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects null / undefined / non-objects", () => {
|
|
27
|
+
expect(isConnectorOutputV1(null)).toBe(false);
|
|
28
|
+
expect(isConnectorOutputV1(undefined)).toBe(false);
|
|
29
|
+
expect(isConnectorOutputV1("string")).toBe(false);
|
|
30
|
+
expect(isConnectorOutputV1(42)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects objects with wrong version", () => {
|
|
34
|
+
expect(isConnectorOutputV1({ version: "v2", source: "x", scope: "y", producedAt: "z", records: [] })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects objects missing required fields", () => {
|
|
38
|
+
expect(isConnectorOutputV1({ version: "connector-v1" })).toBe(false);
|
|
39
|
+
expect(isConnectorOutputV1({ version: "connector-v1", source: "x" })).toBe(false);
|
|
40
|
+
expect(isConnectorOutputV1({ version: "connector-v1", source: "x", scope: "y", producedAt: "z" })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("accepts empty records array", () => {
|
|
44
|
+
expect(isConnectorOutputV1({
|
|
45
|
+
version: "connector-v1",
|
|
46
|
+
source: "test",
|
|
47
|
+
scope: "test:scope",
|
|
48
|
+
producedAt: "2026-04-13T00:00:00Z",
|
|
49
|
+
records: [],
|
|
50
|
+
})).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// detectFormat — connector-v1
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe("detectFormat — connector-v1", () => {
|
|
59
|
+
it("detects connector-v1 JSON", () => {
|
|
60
|
+
const payload = JSON.stringify({
|
|
61
|
+
version: "connector-v1",
|
|
62
|
+
source: "obsidian",
|
|
63
|
+
scope: "vault:my-notes",
|
|
64
|
+
producedAt: "2026-04-13T12:00:00Z",
|
|
65
|
+
records: [{ id: "r1", text: "test" }],
|
|
66
|
+
});
|
|
67
|
+
expect(detectFormat(payload)).toBe("connector-v1");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("detects connector-v1 with optional fields", () => {
|
|
71
|
+
const payload = JSON.stringify({
|
|
72
|
+
version: "connector-v1",
|
|
73
|
+
source: "email",
|
|
74
|
+
scope: "inbox:main",
|
|
75
|
+
batchId: "batch-001",
|
|
76
|
+
producedAt: "2026-04-13T12:00:00Z",
|
|
77
|
+
records: [],
|
|
78
|
+
});
|
|
79
|
+
expect(detectFormat(payload)).toBe("connector-v1");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not misidentify non-connector JSON as connector-v1", () => {
|
|
83
|
+
// claude-ai format
|
|
84
|
+
const claudeAi = JSON.stringify({ chat_conversations: [{ chat_messages: [] }] });
|
|
85
|
+
expect(detectFormat(claudeAi)).toBe("claude-ai");
|
|
86
|
+
|
|
87
|
+
// chatgpt format
|
|
88
|
+
const chatgpt = JSON.stringify({ mapping: {} });
|
|
89
|
+
expect(detectFormat(chatgpt)).toBe("chatgpt");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("connector-v1 takes priority over other single-object formats", () => {
|
|
93
|
+
// An object that has both connector-v1 fields AND a mapping field
|
|
94
|
+
const hybrid = JSON.stringify({
|
|
95
|
+
version: "connector-v1",
|
|
96
|
+
source: "test",
|
|
97
|
+
scope: "test:s",
|
|
98
|
+
producedAt: "2026-04-13T12:00:00Z",
|
|
99
|
+
records: [],
|
|
100
|
+
mapping: {}, // chatgpt field — should not override
|
|
101
|
+
});
|
|
102
|
+
expect(detectFormat(hybrid)).toBe("connector-v1");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// normalizeConnectorV1
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("normalizeConnectorV1", () => {
|
|
111
|
+
it("normalizes records with title and text", () => {
|
|
112
|
+
const payload: ConnectorOutputV1 = {
|
|
113
|
+
version: "connector-v1",
|
|
114
|
+
source: "obsidian",
|
|
115
|
+
scope: "vault:notes",
|
|
116
|
+
producedAt: "2026-04-13T12:00:00Z",
|
|
117
|
+
records: [
|
|
118
|
+
{ id: "r1", text: "Some content", title: "My Note" },
|
|
119
|
+
{ id: "r2", text: "Another note" },
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
const messages = normalizeConnectorV1(JSON.stringify(payload));
|
|
123
|
+
expect(messages).toHaveLength(2);
|
|
124
|
+
expect(messages[0].content).toBe("[My Note] Some content");
|
|
125
|
+
expect(messages[0].role).toBe("user");
|
|
126
|
+
expect(messages[1].content).toBe("Another note");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("preserves timestamps", () => {
|
|
130
|
+
const payload: ConnectorOutputV1 = {
|
|
131
|
+
version: "connector-v1",
|
|
132
|
+
source: "test",
|
|
133
|
+
scope: "test:s",
|
|
134
|
+
producedAt: "2026-04-13T12:00:00Z",
|
|
135
|
+
records: [
|
|
136
|
+
{ id: "r1", text: "hello", timestamp: "2026-04-12T10:00:00Z" },
|
|
137
|
+
{ id: "r2", text: "world" },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
const messages = normalizeConnectorV1(JSON.stringify(payload));
|
|
141
|
+
expect(messages[0].timestamp).toBe("2026-04-12T10:00:00Z");
|
|
142
|
+
expect(messages[1].timestamp).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns empty array for empty records", () => {
|
|
146
|
+
const payload: ConnectorOutputV1 = {
|
|
147
|
+
version: "connector-v1",
|
|
148
|
+
source: "test",
|
|
149
|
+
scope: "test:s",
|
|
150
|
+
producedAt: "2026-04-13T12:00:00Z",
|
|
151
|
+
records: [],
|
|
152
|
+
};
|
|
153
|
+
const messages = normalizeConnectorV1(JSON.stringify(payload));
|
|
154
|
+
expect(messages).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("throws on invalid connector-v1 JSON", () => {
|
|
158
|
+
expect(() => normalizeConnectorV1(JSON.stringify({ version: "v2" }))).toThrow();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// normalizeConversation dispatch
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe("normalizeConversation — connector-v1 dispatch", () => {
|
|
167
|
+
it("dispatches connector-v1 through normalizeConversation", () => {
|
|
168
|
+
const payload: ConnectorOutputV1 = {
|
|
169
|
+
version: "connector-v1",
|
|
170
|
+
source: "rss",
|
|
171
|
+
scope: "feed:tech",
|
|
172
|
+
producedAt: "2026-04-13T12:00:00Z",
|
|
173
|
+
records: [
|
|
174
|
+
{ id: "r1", text: "Article body", title: "Cool Article" },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
const messages = normalizeConversation(JSON.stringify(payload), "connector-v1");
|
|
178
|
+
expect(messages).toHaveLength(1);
|
|
179
|
+
expect(messages[0].content).toBe("[Cool Article] Article body");
|
|
180
|
+
});
|
|
181
|
+
});
|