recallnest 2.2.1 → 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.
- package/CHANGELOG.md +150 -0
- package/README.md +46 -35
- package/README_CN.md +41 -26
- 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/connectors/examples/email-adapter.ts +73 -0
- package/connectors/examples/logs-adapter.ts +103 -0
- package/connectors/examples/rss-adapter.ts +95 -0
- 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 +22 -1
- package/src/__tests__/access-tracker-novelty.test.ts +238 -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__/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__/canary-eval.test.ts +114 -0
- package/src/__tests__/capture-engine.test.ts +1238 -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 +33 -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 +782 -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 +374 -0
- package/src/__tests__/connector-v1.test.ts +181 -0
- package/src/__tests__/consolidation-engine.test.ts +333 -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 +216 -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 +84 -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 +321 -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__/graph-export.test.ts +489 -0
- package/src/__tests__/hotness-blend.test.ts +76 -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-consolidation.test.ts +226 -0
- package/src/__tests__/maintenance-locks.test.ts +174 -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 +250 -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__/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 +314 -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 +230 -0
- package/src/__tests__/preference-slots.test.ts +205 -0
- package/src/__tests__/prospective-memory.test.ts +283 -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-governor.test.ts +227 -0
- package/src/__tests__/recall-mode.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-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -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 +601 -0
- package/src/__tests__/skill-verifier.test.ts +122 -0
- package/src/__tests__/source-heartbeat.test.ts +250 -0
- package/src/__tests__/store-patch-metadata.test.ts +205 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/store-upsert.test.ts +184 -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.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__/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 +38 -32
- package/src/activity-counter.ts +46 -18
- package/src/aliases.ts +133 -0
- package/src/api-server.ts +27 -7
- package/src/audit-log.ts +4 -2
- package/src/auto-consolidation.ts +8 -3
- package/src/auto-gc.ts +85 -21
- package/src/capture-engine.ts +54 -21
- package/src/cli.ts +199 -37
- package/src/confidence-tracker.ts +36 -18
- package/src/connector-types.ts +57 -0
- package/src/consolidation-engine.ts +109 -11
- package/src/context-composer.ts +8 -1
- package/src/conversation-importer.ts +25 -1
- package/src/data-checkup.ts +126 -7
- package/src/distill-lock.ts +194 -15
- package/src/doctor.ts +38 -2
- package/src/dream-pipeline.ts +118 -34
- package/src/env-config.ts +77 -0
- package/src/error-signature.ts +153 -0
- package/src/error-taxonomy.ts +115 -0
- package/src/eval.ts +251 -6
- package/src/idempotency.ts +8 -0
- package/src/ingest.ts +234 -10
- package/src/kg-extractor.ts +6 -2
- package/src/kg-store.ts +236 -44
- package/src/llm-consolidation.ts +2 -1
- package/src/mcp-server.ts +85 -1799
- package/src/mcp-tool-deps.ts +48 -0
- package/src/mcp-tools-advanced.ts +941 -0
- package/src/mcp-tools-core.ts +633 -0
- package/src/mcp-tools-governance.ts +475 -0
- package/src/memory-boundaries.ts +3 -1
- package/src/memory-evolution.ts +73 -1
- package/src/memory-lint.ts +183 -10
- package/src/memory-output.ts +114 -3
- package/src/memory-promotion.ts +246 -0
- package/src/memory-schema.ts +25 -2
- package/src/memory-store-port.ts +43 -0
- package/src/multi-vector.ts +2 -1
- package/src/narrative-schema.ts +2 -1
- package/src/noise-filter.ts +57 -6
- package/src/obsidian-connector.ts +282 -0
- package/src/pii-detector.ts +157 -5
- package/src/query-expander.ts +90 -9
- package/src/result-synthesizer.ts +2 -1
- package/src/retention-policy.ts +2 -1
- package/src/retriever.ts +259 -139
- package/src/runtime-config.ts +8 -1
- 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 +3 -2
- package/src/session-store.ts +5 -0
- package/src/skill-engine.ts +78 -0
- package/src/skill-promotion.ts +247 -47
- package/src/skill-schema.ts +16 -3
- package/src/skill-verifier.ts +152 -0
- package/src/source-heartbeat.ts +108 -0
- package/src/store.ts +267 -38
- package/src/tool-output-compressor.ts +3 -1
- package/src/ui-server.ts +2 -1
- package/src/usage-tracker.ts +242 -0
- package/src/version-manager.ts +3 -2
- 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 -30
- package/logs/ingest-2026-04-11.log +0 -110
- package/logs/ingest-2026-04-12.log +0 -90
- package/logs/launchagent-stderr.log +0 -0
- package/logs/launchagent-stdout.log +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
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 { MemoryStore, deterministicId, type MemoryEntry } from "../store.js";
|
|
7
|
+
import { getWriteCount } from "../activity-counter.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* P0-2 回归:store()/storeBatch() 改走 upsert(delete+add 入全局 store-write 锁)后,
|
|
11
|
+
* 相同 deterministicId 的写入是幂等的 —— 同 (scope,text) 写两次只留一行,不再堆 dup-id。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const tmpDirs: string[] = [];
|
|
15
|
+
|
|
16
|
+
function makeStore(): MemoryStore {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), "recallnest-upsert-"));
|
|
18
|
+
tmpDirs.push(dir);
|
|
19
|
+
return new MemoryStore({ dbPath: dir, vectorDim: 3 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
while (tmpDirs.length > 0) {
|
|
24
|
+
try {
|
|
25
|
+
rmSync(tmpDirs.pop()!, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
/* ignore */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("store P0-2 idempotency (upsert-backed store/storeBatch)", () => {
|
|
33
|
+
it("store() of the same (scope,text) twice yields one row, latest wins", async () => {
|
|
34
|
+
const store = makeStore();
|
|
35
|
+
const scope = "project:upsert-test";
|
|
36
|
+
await store.store({ text: "同一条内容", vector: [1, 0, 0], category: "entities", scope, importance: 0.7, metadata: "{}" });
|
|
37
|
+
await store.store({ text: "同一条内容", vector: [1, 0, 0], category: "entities", scope, importance: 0.9, metadata: "{}" });
|
|
38
|
+
|
|
39
|
+
const rows = await store.list([scope]);
|
|
40
|
+
const target = rows.filter((r) => r.text === "同一条内容");
|
|
41
|
+
expect(target.length).toBe(1);
|
|
42
|
+
expect(target[0].importance).toBeCloseTo(0.9, 5); // upsert overwrote the 0.7 row
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("upsert() with the same id twice overwrites (single row, latest content)", async () => {
|
|
46
|
+
const store = makeStore();
|
|
47
|
+
const scope = "project:upsert-test";
|
|
48
|
+
const id = deterministicId(scope, "anchor");
|
|
49
|
+
const base: MemoryEntry = {
|
|
50
|
+
id,
|
|
51
|
+
text: "v1",
|
|
52
|
+
vector: [0, 1, 0],
|
|
53
|
+
category: "entities",
|
|
54
|
+
scope,
|
|
55
|
+
importance: 0.5,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
metadata: "{}",
|
|
58
|
+
language: "en",
|
|
59
|
+
fts_text: "v1",
|
|
60
|
+
};
|
|
61
|
+
await store.upsert(base);
|
|
62
|
+
await store.upsert({ ...base, text: "v2", fts_text: "v2", importance: 0.8 });
|
|
63
|
+
|
|
64
|
+
const rows = await store.list([scope]);
|
|
65
|
+
const byId = rows.filter((r) => r.id === id);
|
|
66
|
+
expect(byId.length).toBe(1);
|
|
67
|
+
expect(byId[0].text).toBe("v2");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("upsert failure leaves the prior row intact (mergeInsert is atomic — no delete+add loss)", async () => {
|
|
71
|
+
const store = makeStore(); // vectorDim 3
|
|
72
|
+
const scope = "project:upsert-test";
|
|
73
|
+
const id = deterministicId(scope, "atomic-anchor");
|
|
74
|
+
const good: MemoryEntry = {
|
|
75
|
+
id, text: "original", vector: [1, 0, 0], category: "entities", scope, importance: 0.5,
|
|
76
|
+
timestamp: Date.now(), metadata: "{}", language: "en", fts_text: "original",
|
|
77
|
+
};
|
|
78
|
+
await store.upsert(good);
|
|
79
|
+
|
|
80
|
+
// Inject a write failure. With the old delete-then-add path the delete would already
|
|
81
|
+
// have removed the row before add threw (row lost); the atomic mergeInsert touches
|
|
82
|
+
// nothing on failure, so the prior row must survive.
|
|
83
|
+
const table = (store as unknown as { table: { mergeInsert: unknown } }).table;
|
|
84
|
+
const orig = table.mergeInsert;
|
|
85
|
+
table.mergeInsert = () => {
|
|
86
|
+
throw new Error("simulated write failure");
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
await expect(store.upsert({ ...good, text: "corrupt", fts_text: "corrupt" })).rejects.toThrow("simulated write failure");
|
|
90
|
+
} finally {
|
|
91
|
+
table.mergeInsert = orig;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const survivor = (await store.list([scope])).filter((r) => r.id === id);
|
|
95
|
+
expect(survivor.length).toBe(1);
|
|
96
|
+
expect(survivor[0].text).toBe("original"); // prior row preserved, not lost
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("storeBatch() honors a caller-supplied id (idempotent re-run)", async () => {
|
|
100
|
+
const store = makeStore();
|
|
101
|
+
const scope = "project:upsert-test";
|
|
102
|
+
const fixedId = deterministicId(scope, "batch-anchor");
|
|
103
|
+
const entry = {
|
|
104
|
+
id: fixedId,
|
|
105
|
+
text: "batch-a",
|
|
106
|
+
vector: [0, 0, 1] as number[],
|
|
107
|
+
category: "entities" as const,
|
|
108
|
+
scope,
|
|
109
|
+
importance: 0.6,
|
|
110
|
+
metadata: "{}",
|
|
111
|
+
};
|
|
112
|
+
await store.storeBatch([entry]);
|
|
113
|
+
await store.storeBatch([{ ...entry, text: "batch-a-updated", fts_text: "batch-a-updated" }]);
|
|
114
|
+
|
|
115
|
+
const rows = await store.list([scope]);
|
|
116
|
+
const byId = rows.filter((r) => r.id === fixedId);
|
|
117
|
+
expect(byId.length).toBe(1);
|
|
118
|
+
expect(byId[0].text).toBe("batch-a-updated");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("storeBatch() collapses in-batch duplicate ids (latest-wins) into one row", async () => {
|
|
122
|
+
const store = makeStore();
|
|
123
|
+
const scope = "project:upsert-test";
|
|
124
|
+
const stored = await store.storeBatch([
|
|
125
|
+
{ text: "dup-in-batch", vector: [1, 0, 0], category: "entities", scope, importance: 0.3, metadata: "{}" },
|
|
126
|
+
{ text: "dup-in-batch", vector: [1, 0, 0], category: "entities", scope, importance: 0.8, metadata: "{}" },
|
|
127
|
+
]);
|
|
128
|
+
expect(stored).toBe(1); // two same-(scope,text) entries collapse to one
|
|
129
|
+
const rows = await store.list([scope]);
|
|
130
|
+
const target = rows.filter((r) => r.text === "dup-in-batch");
|
|
131
|
+
expect(target.length).toBe(1);
|
|
132
|
+
expect(target[0].importance).toBeCloseTo(0.8, 5); // last occurrence wins
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// A store whose activity-stats sits in its own unique dir (dirname(dbPath)), so the
|
|
136
|
+
// per-scope counter assertions are isolated from other tests' shared tmproot writes.
|
|
137
|
+
function makeIsolatedStore(): { store: MemoryStore; statsPath: string } {
|
|
138
|
+
const dir = mkdtempSync(join(tmpdir(), "recallnest-count-"));
|
|
139
|
+
tmpDirs.push(dir);
|
|
140
|
+
const dbPath = join(dir, "lancedb");
|
|
141
|
+
return { store: new MemoryStore({ dbPath, vectorDim: 3 }), statsPath: join(dir, "activity-stats.json") };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it("store() increments the per-scope write counter once each (no double-count via upsert)", async () => {
|
|
145
|
+
const { store, statsPath } = makeIsolatedStore();
|
|
146
|
+
const scope = "cc:project:count-a";
|
|
147
|
+
await store.store({ text: "m1", vector: [1, 0, 0], category: "entities", scope, importance: 0.5, metadata: "{}" });
|
|
148
|
+
await store.store({ text: "m2", vector: [0, 1, 0], category: "entities", scope, importance: 0.5, metadata: "{}" });
|
|
149
|
+
// store()→upsert() counts once per call; two stores ⇒ 2 (not 4).
|
|
150
|
+
expect(getWriteCount(scope, { statsPath })).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("storeBatch() counts per scope by number of entries in that scope", async () => {
|
|
154
|
+
const { store, statsPath } = makeIsolatedStore();
|
|
155
|
+
await store.storeBatch([
|
|
156
|
+
{ text: "a", vector: [1, 0, 0], category: "entities", scope: "cc:s1", importance: 0.5, metadata: "{}" },
|
|
157
|
+
{ text: "b", vector: [0, 1, 0], category: "entities", scope: "cc:s1", importance: 0.5, metadata: "{}" },
|
|
158
|
+
{ text: "c", vector: [0, 0, 1], category: "entities", scope: "cc:s2", importance: 0.5, metadata: "{}" },
|
|
159
|
+
]);
|
|
160
|
+
expect(getWriteCount("cc:s1", { statsPath })).toBe(2);
|
|
161
|
+
expect(getWriteCount("cc:s2", { statsPath })).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("storeBatch() with an in-batch duplicate counts the collapsed row once", async () => {
|
|
165
|
+
const { store, statsPath } = makeIsolatedStore();
|
|
166
|
+
await store.storeBatch([
|
|
167
|
+
{ text: "dup", vector: [1, 0, 0], category: "entities", scope: "cc:s3", importance: 0.3, metadata: "{}" },
|
|
168
|
+
{ text: "dup", vector: [1, 0, 0], category: "entities", scope: "cc:s3", importance: 0.8, metadata: "{}" },
|
|
169
|
+
]);
|
|
170
|
+
expect(getWriteCount("cc:s3", { statsPath })).toBe(1); // deduped to one row ⇒ counted once
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("storeBatch() without ids still stores distinct entries", async () => {
|
|
174
|
+
const store = makeStore();
|
|
175
|
+
const scope = "project:upsert-test";
|
|
176
|
+
const stored = await store.storeBatch([
|
|
177
|
+
{ text: "batch-x", vector: [1, 0, 0], category: "entities", scope, importance: 0.5, metadata: "{}" },
|
|
178
|
+
{ text: "batch-y", vector: [0, 1, 0], category: "entities", scope, importance: 0.5, metadata: "{}" },
|
|
179
|
+
]);
|
|
180
|
+
expect(stored).toBe(2);
|
|
181
|
+
const rows = await store.list([scope]);
|
|
182
|
+
expect(rows.filter((r) => r.text === "batch-x" || r.text === "batch-y").length).toBe(2);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildSupersedeMetadata,
|
|
4
|
+
buildSupersedeMetadataForNew,
|
|
5
|
+
parseEvolution,
|
|
6
|
+
traceEvolution,
|
|
7
|
+
patchEvolution,
|
|
8
|
+
defaultEvolution,
|
|
9
|
+
} from "../memory-evolution.js";
|
|
10
|
+
|
|
11
|
+
describe("HP-1: Superseded chain enhancement", () => {
|
|
12
|
+
describe("bidirectional linking", () => {
|
|
13
|
+
it("buildSupersedeMetadata marks old → supersededBy", () => {
|
|
14
|
+
const oldMeta = patchEvolution("{}", defaultEvolution());
|
|
15
|
+
const result = buildSupersedeMetadata(oldMeta, "new-id");
|
|
16
|
+
const evo = parseEvolution(result);
|
|
17
|
+
expect(evo.status).toBe("superseded");
|
|
18
|
+
expect(evo.supersededBy).toBe("new-id");
|
|
19
|
+
expect(evo.validUntil).toBeGreaterThan(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("buildSupersedeMetadataForNew marks new → supersedes + note", () => {
|
|
23
|
+
const newMeta = patchEvolution("{}", defaultEvolution());
|
|
24
|
+
const result = buildSupersedeMetadataForNew(newMeta, "old-id", "updated preference");
|
|
25
|
+
const evo = parseEvolution(result);
|
|
26
|
+
expect(evo.status).toBe("active"); // new memory stays active
|
|
27
|
+
expect(evo.supersedes).toBe("old-id");
|
|
28
|
+
expect(evo.evolutionNote).toBe("updated preference");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("bidirectional: old.supersededBy == new.id AND new.supersedes == old.id", () => {
|
|
32
|
+
const oldMeta = patchEvolution("{}", defaultEvolution());
|
|
33
|
+
const newMeta = patchEvolution("{}", defaultEvolution());
|
|
34
|
+
|
|
35
|
+
const updatedOld = buildSupersedeMetadata(oldMeta, "new-id");
|
|
36
|
+
const updatedNew = buildSupersedeMetadataForNew(newMeta, "old-id");
|
|
37
|
+
|
|
38
|
+
const oldEvo = parseEvolution(updatedOld);
|
|
39
|
+
const newEvo = parseEvolution(updatedNew);
|
|
40
|
+
|
|
41
|
+
expect(oldEvo.supersededBy).toBe("new-id");
|
|
42
|
+
expect(newEvo.supersedes).toBe("old-id");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("traceEvolution", () => {
|
|
47
|
+
it("traces a 3-node chain", async () => {
|
|
48
|
+
// v1 → v2 → v3
|
|
49
|
+
const entries: Record<string, { metadata: string; timestamp: number }> = {
|
|
50
|
+
v1: {
|
|
51
|
+
metadata: patchEvolution("{}", {
|
|
52
|
+
status: "superseded",
|
|
53
|
+
supersededBy: "v2",
|
|
54
|
+
validFrom: 1000,
|
|
55
|
+
validUntil: 2000,
|
|
56
|
+
}),
|
|
57
|
+
timestamp: 1000,
|
|
58
|
+
},
|
|
59
|
+
v2: {
|
|
60
|
+
metadata: patchEvolution("{}", {
|
|
61
|
+
status: "superseded",
|
|
62
|
+
supersedes: "v1",
|
|
63
|
+
supersededBy: "v3",
|
|
64
|
+
evolutionNote: "added detail",
|
|
65
|
+
validFrom: 2000,
|
|
66
|
+
validUntil: 3000,
|
|
67
|
+
}),
|
|
68
|
+
timestamp: 2000,
|
|
69
|
+
},
|
|
70
|
+
v3: {
|
|
71
|
+
metadata: patchEvolution("{}", {
|
|
72
|
+
status: "active",
|
|
73
|
+
supersedes: "v2",
|
|
74
|
+
evolutionNote: "corrected error",
|
|
75
|
+
validFrom: 3000,
|
|
76
|
+
}),
|
|
77
|
+
timestamp: 3000,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const getEntry = async (id: string) => entries[id] ?? null;
|
|
82
|
+
|
|
83
|
+
// Trace from v2 (middle)
|
|
84
|
+
const trace = await traceEvolution("v2", getEntry);
|
|
85
|
+
expect(trace.length).toBe(3);
|
|
86
|
+
expect(trace[0].id).toBe("v1");
|
|
87
|
+
expect(trace[0].direction).toBe("predecessor");
|
|
88
|
+
expect(trace[1].id).toBe("v2");
|
|
89
|
+
expect(trace[1].direction).toBe("self");
|
|
90
|
+
expect(trace[2].id).toBe("v3");
|
|
91
|
+
expect(trace[2].direction).toBe("successor");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles single node (no chain)", async () => {
|
|
95
|
+
const getEntry = async (id: string) => {
|
|
96
|
+
if (id === "solo") return { metadata: patchEvolution("{}", defaultEvolution()), timestamp: 1000 };
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const trace = await traceEvolution("solo", getEntry);
|
|
101
|
+
expect(trace.length).toBe(1);
|
|
102
|
+
expect(trace[0].direction).toBe("self");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("respects maxDepth", async () => {
|
|
106
|
+
// Create a long chain: v0 → v1 → v2 → ... → v20
|
|
107
|
+
const entries: Record<string, { metadata: string; timestamp: number }> = {};
|
|
108
|
+
for (let i = 0; i <= 20; i++) {
|
|
109
|
+
entries[`v${i}`] = {
|
|
110
|
+
metadata: patchEvolution("{}", {
|
|
111
|
+
status: i < 20 ? "superseded" : "active",
|
|
112
|
+
supersedes: i > 0 ? `v${i - 1}` : null,
|
|
113
|
+
supersededBy: i < 20 ? `v${i + 1}` : null,
|
|
114
|
+
validFrom: i * 1000,
|
|
115
|
+
}),
|
|
116
|
+
timestamp: i * 1000,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const getEntry = async (id: string) => entries[id] ?? null;
|
|
121
|
+
const trace = await traceEvolution("v10", getEntry, 3);
|
|
122
|
+
// maxDepth=3: at most 3 predecessors + self + 3 successors = 7
|
|
123
|
+
expect(trace.length).toBeLessThanOrEqual(7);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("handles missing entries gracefully", async () => {
|
|
127
|
+
const entries: Record<string, { metadata: string; timestamp: number }> = {
|
|
128
|
+
v1: {
|
|
129
|
+
metadata: patchEvolution("{}", {
|
|
130
|
+
status: "active",
|
|
131
|
+
supersedes: "missing",
|
|
132
|
+
validFrom: 1000,
|
|
133
|
+
}),
|
|
134
|
+
timestamp: 1000,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const getEntry = async (id: string) => entries[id] ?? null;
|
|
138
|
+
const trace = await traceEvolution("v1", getEntry);
|
|
139
|
+
expect(trace.length).toBe(1); // only self, missing predecessor stops chain
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("backward compatibility", () => {
|
|
144
|
+
it("parseEvolution defaults new fields to null", () => {
|
|
145
|
+
// Old metadata without supersedes/evolutionNote
|
|
146
|
+
const oldMeta = JSON.stringify({
|
|
147
|
+
evolution: { status: "active", version: 1, accessCount: 0 },
|
|
148
|
+
});
|
|
149
|
+
const evo = parseEvolution(oldMeta);
|
|
150
|
+
expect(evo.supersedes).toBeNull();
|
|
151
|
+
expect(evo.evolutionNote).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseTemporalQuery,
|
|
4
|
+
matchesTemporalConstraint,
|
|
5
|
+
temporalWhereClause,
|
|
6
|
+
type TemporalConstraint,
|
|
7
|
+
} from "../temporal-parser.js";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// parseTemporalQuery
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
describe("parseTemporalQuery", () => {
|
|
14
|
+
// --- Absolute year (ZH) ---
|
|
15
|
+
it("parses '2023年的记忆'", () => {
|
|
16
|
+
const result = parseTemporalQuery("2023年的记忆里有什么");
|
|
17
|
+
expect(result.constraint).not.toBeNull();
|
|
18
|
+
expect(result.constraint!.type).toBe("range");
|
|
19
|
+
expect(result.constraint!.startMs).toBe(new Date(2023, 0, 1).getTime());
|
|
20
|
+
expect(result.constraint!.endMs).toBe(new Date(2024, 0, 1).getTime() - 1);
|
|
21
|
+
// Time expression removed from cleaned query
|
|
22
|
+
expect(result.cleanedQuery).not.toContain("2023年");
|
|
23
|
+
expect(result.cleanedQuery).toContain("里有什么");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("parses standalone '2024年'", () => {
|
|
27
|
+
const result = parseTemporalQuery("关于 2024年 的项目");
|
|
28
|
+
expect(result.constraint).not.toBeNull();
|
|
29
|
+
expect(result.constraint!.type).toBe("range");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// --- Absolute year+month (ZH) ---
|
|
33
|
+
it("parses '2023年3月'", () => {
|
|
34
|
+
const result = parseTemporalQuery("2023年3月我们讨论了什么");
|
|
35
|
+
expect(result.constraint).not.toBeNull();
|
|
36
|
+
expect(result.constraint!.startMs).toBe(new Date(2023, 2, 1).getTime());
|
|
37
|
+
expect(result.constraint!.endMs).toBe(new Date(2023, 3, 1).getTime() - 1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("parses '2023年三月'", () => {
|
|
41
|
+
const result = parseTemporalQuery("2023年三月的对话");
|
|
42
|
+
expect(result.constraint).not.toBeNull();
|
|
43
|
+
expect(result.constraint!.startMs).toBe(new Date(2023, 2, 1).getTime());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// --- Absolute year+month (EN) ---
|
|
47
|
+
it("parses 'March 2023'", () => {
|
|
48
|
+
const result = parseTemporalQuery("What did we discuss in March 2023");
|
|
49
|
+
expect(result.constraint).not.toBeNull();
|
|
50
|
+
expect(result.constraint!.startMs).toBe(new Date(2023, 2, 1).getTime());
|
|
51
|
+
expect(result.constraint!.endMs).toBe(new Date(2023, 3, 1).getTime() - 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// --- "in YYYY" ---
|
|
55
|
+
it("parses 'in 2022'", () => {
|
|
56
|
+
const result = parseTemporalQuery("Projects discussed in 2022");
|
|
57
|
+
expect(result.constraint).not.toBeNull();
|
|
58
|
+
expect(result.constraint!.type).toBe("range");
|
|
59
|
+
expect(result.constraint!.startMs).toBe(new Date(2022, 0, 1).getTime());
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- Relative: 最近N天/周/月 ---
|
|
63
|
+
it("parses '最近7天'", () => {
|
|
64
|
+
const result = parseTemporalQuery("最近7天的记忆");
|
|
65
|
+
expect(result.constraint).not.toBeNull();
|
|
66
|
+
expect(result.constraint!.type).toBe("range");
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
expect(result.constraint!.startMs).toBeGreaterThan(now - 8 * 86_400_000);
|
|
69
|
+
expect(result.constraint!.endMs).toBeLessThanOrEqual(now + 1000);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("parses '最近2周'", () => {
|
|
73
|
+
const result = parseTemporalQuery("最近2周讨论过什么");
|
|
74
|
+
expect(result.constraint).not.toBeNull();
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
expect(result.constraint!.startMs).toBeGreaterThan(now - 15 * 86_400_000);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("parses '最近3个月'", () => {
|
|
80
|
+
const result = parseTemporalQuery("最近3个月的项目进展");
|
|
81
|
+
expect(result.constraint).not.toBeNull();
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
expect(result.constraint!.startMs).toBeGreaterThan(now - 100 * 86_400_000);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- Relative: last N days/weeks/months ---
|
|
87
|
+
it("parses 'last 30 days'", () => {
|
|
88
|
+
const result = parseTemporalQuery("What happened in the last 30 days");
|
|
89
|
+
expect(result.constraint).not.toBeNull();
|
|
90
|
+
expect(result.constraint!.type).toBe("range");
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
expect(result.constraint!.startMs).toBeGreaterThan(now - 31 * 86_400_000);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("parses 'last 2 weeks'", () => {
|
|
96
|
+
const result = parseTemporalQuery("Changes in the last 2 weeks");
|
|
97
|
+
expect(result.constraint).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// --- Relative: 上周/上个月/去年 ---
|
|
101
|
+
it("parses '去年'", () => {
|
|
102
|
+
const result = parseTemporalQuery("去年我们做了什么项目");
|
|
103
|
+
expect(result.constraint).not.toBeNull();
|
|
104
|
+
const lastYear = new Date().getFullYear() - 1;
|
|
105
|
+
expect(result.constraint!.startMs).toBe(new Date(lastYear, 0, 1).getTime());
|
|
106
|
+
expect(result.constraint!.endMs).toBe(new Date(lastYear + 1, 0, 1).getTime() - 1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("parses 'last year'", () => {
|
|
110
|
+
const result = parseTemporalQuery("Projects from last year");
|
|
111
|
+
expect(result.constraint).not.toBeNull();
|
|
112
|
+
expect(result.constraint!.type).toBe("range");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("parses '上个月'", () => {
|
|
116
|
+
const result = parseTemporalQuery("上个月的讨论");
|
|
117
|
+
expect(result.constraint).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("parses '去年3月'", () => {
|
|
121
|
+
const result = parseTemporalQuery("去年3月的事情");
|
|
122
|
+
expect(result.constraint).not.toBeNull();
|
|
123
|
+
const lastYear = new Date().getFullYear() - 1;
|
|
124
|
+
expect(result.constraint!.startMs).toBe(new Date(lastYear, 2, 1).getTime());
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// --- Operator syntax ---
|
|
128
|
+
it("parses 'after:2024-01'", () => {
|
|
129
|
+
const result = parseTemporalQuery("TypeScript projects after:2024-01");
|
|
130
|
+
expect(result.constraint).not.toBeNull();
|
|
131
|
+
expect(result.constraint!.type).toBe("after");
|
|
132
|
+
expect(result.constraint!.startMs).toBe(new Date(2024, 0, 1).getTime());
|
|
133
|
+
expect(result.cleanedQuery).toContain("TypeScript projects");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("parses 'before:2025-06'", () => {
|
|
137
|
+
const result = parseTemporalQuery("memory entries before:2025-06");
|
|
138
|
+
expect(result.constraint).not.toBeNull();
|
|
139
|
+
expect(result.constraint!.type).toBe("before");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// --- No temporal expression ---
|
|
143
|
+
it("returns null for non-temporal queries", () => {
|
|
144
|
+
const result = parseTemporalQuery("How to configure TypeScript");
|
|
145
|
+
expect(result.constraint).toBeNull();
|
|
146
|
+
expect(result.cleanedQuery).toBe("How to configure TypeScript");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns null for empty query", () => {
|
|
150
|
+
const result = parseTemporalQuery("");
|
|
151
|
+
expect(result.constraint).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// --- Cleaned query ---
|
|
155
|
+
it("removes temporal expression and preserves the rest", () => {
|
|
156
|
+
const result = parseTemporalQuery("2023年我们讨论了 Docker 配置");
|
|
157
|
+
expect(result.cleanedQuery).toContain("Docker");
|
|
158
|
+
expect(result.cleanedQuery).not.toContain("2023年");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// matchesTemporalConstraint
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
describe("matchesTemporalConstraint", () => {
|
|
167
|
+
it("matches timestamp within range", () => {
|
|
168
|
+
const constraint: TemporalConstraint = {
|
|
169
|
+
type: "range",
|
|
170
|
+
startMs: new Date(2023, 0, 1).getTime(),
|
|
171
|
+
endMs: new Date(2024, 0, 1).getTime() - 1,
|
|
172
|
+
anchor: "2023年",
|
|
173
|
+
};
|
|
174
|
+
const mid2023 = new Date(2023, 6, 1).getTime();
|
|
175
|
+
expect(matchesTemporalConstraint(mid2023, constraint)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("rejects timestamp outside range", () => {
|
|
179
|
+
const constraint: TemporalConstraint = {
|
|
180
|
+
type: "range",
|
|
181
|
+
startMs: new Date(2023, 0, 1).getTime(),
|
|
182
|
+
endMs: new Date(2024, 0, 1).getTime() - 1,
|
|
183
|
+
anchor: "2023年",
|
|
184
|
+
};
|
|
185
|
+
const mid2022 = new Date(2022, 6, 1).getTime();
|
|
186
|
+
expect(matchesTemporalConstraint(mid2022, constraint)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("handles 'after' constraint", () => {
|
|
190
|
+
const constraint: TemporalConstraint = {
|
|
191
|
+
type: "after",
|
|
192
|
+
startMs: new Date(2024, 0, 1).getTime(),
|
|
193
|
+
anchor: "after:2024-01",
|
|
194
|
+
};
|
|
195
|
+
expect(matchesTemporalConstraint(new Date(2024, 6, 1).getTime(), constraint)).toBe(true);
|
|
196
|
+
expect(matchesTemporalConstraint(new Date(2023, 6, 1).getTime(), constraint)).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("handles 'before' constraint", () => {
|
|
200
|
+
const constraint: TemporalConstraint = {
|
|
201
|
+
type: "before",
|
|
202
|
+
endMs: new Date(2024, 0, 1).getTime(),
|
|
203
|
+
anchor: "before:2024-01",
|
|
204
|
+
};
|
|
205
|
+
expect(matchesTemporalConstraint(new Date(2023, 6, 1).getTime(), constraint)).toBe(true);
|
|
206
|
+
expect(matchesTemporalConstraint(new Date(2025, 0, 1).getTime(), constraint)).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// temporalWhereClause
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
describe("temporalWhereClause", () => {
|
|
215
|
+
it("generates range clause", () => {
|
|
216
|
+
const clause = temporalWhereClause({
|
|
217
|
+
type: "range",
|
|
218
|
+
startMs: 1000,
|
|
219
|
+
endMs: 2000,
|
|
220
|
+
anchor: "test",
|
|
221
|
+
});
|
|
222
|
+
expect(clause).toBe("timestamp >= 1000 AND timestamp <= 2000");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("generates after-only clause", () => {
|
|
226
|
+
const clause = temporalWhereClause({
|
|
227
|
+
type: "after",
|
|
228
|
+
startMs: 1000,
|
|
229
|
+
anchor: "test",
|
|
230
|
+
});
|
|
231
|
+
expect(clause).toBe("timestamp >= 1000");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("returns null for empty constraint", () => {
|
|
235
|
+
const clause = temporalWhereClause({
|
|
236
|
+
type: "range",
|
|
237
|
+
anchor: "test",
|
|
238
|
+
});
|
|
239
|
+
expect(clause).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
});
|