recallnest 1.4.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/.dockerignore +11 -0
  2. package/CLAUDE.md +7 -3
  3. package/Dockerfile +20 -0
  4. package/README.md +202 -130
  5. package/README_CN.md +263 -190
  6. package/ROADMAP.md +128 -81
  7. package/SKILL.md +250 -0
  8. package/assets/dashboard.png +0 -0
  9. package/assets/knowledge-graph.png +0 -0
  10. package/assets/screenshots/ui-full.png +0 -0
  11. package/assets/screenshots/ui-overview.png +0 -0
  12. package/assets/screenshots/ui-search.png +0 -0
  13. package/assets/ui/app.js +227 -3
  14. package/assets/ui/index.html +81 -11
  15. package/assets/ui/styles.css +178 -2
  16. package/bin/recallnest.mjs +62 -0
  17. package/bun.lock +337 -0
  18. package/daily-recall.sh +23 -0
  19. package/daily-reflections/2026-04-02.md +35 -0
  20. package/daily-reflections/2026-04-03.md +37 -0
  21. package/daily-reflections/2026-04-04.md +37 -0
  22. package/daily-reflections/2026-04-05.md +35 -0
  23. package/daily-reflections/2026-04-06.md +36 -0
  24. package/daily-reflections/2026-04-07.md +35 -0
  25. package/daily-reflections/2026-04-08.md +35 -0
  26. package/daily-reflections/2026-04-12.md +35 -0
  27. package/data/alias-map.example.json +22 -0
  28. package/docs/plans/2026-04-01-recall-boost.md +100 -0
  29. package/docs/superpowers/plans/2026-04-11-constructive-retrieval-emotion.md +1195 -0
  30. package/docs/superpowers/specs/2026-04-11-constructive-retrieval-emotion-design.md +398 -0
  31. package/glama.json +6 -0
  32. package/integrations/setup-all.sh +31 -0
  33. package/package.json +10 -3
  34. package/plan-philosophy-upgrades.md +363 -0
  35. package/plan-product-upgrades.md +155 -0
  36. package/plan.md +140 -0
  37. package/research-memory-evolution.md +440 -0
  38. package/research-rl-agent-memory.md +330 -0
  39. package/scripts/backfill-anchors.ts +130 -0
  40. package/scripts/diagnose-vector-search.ts +132 -0
  41. package/scripts/migrate-language.ts +75 -0
  42. package/scripts/verify-emotion-decay.ts +148 -0
  43. package/src/__tests__/activity-counter.test.ts +96 -0
  44. package/src/__tests__/admission-control.test.ts +206 -0
  45. package/src/__tests__/anchor-generator.test.ts +67 -0
  46. package/src/__tests__/babel-integration.test.ts +139 -0
  47. package/src/__tests__/capture-engine.test.ts +5 -3
  48. package/src/__tests__/cascade-forget.test.ts +1 -1
  49. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  50. package/src/__tests__/circuit-breaker.test.ts +99 -0
  51. package/src/__tests__/cluster-consolidation.test.ts +712 -0
  52. package/src/__tests__/confidence-metadata.test.ts +125 -0
  53. package/src/__tests__/confidence-tracker.test.ts +53 -0
  54. package/src/__tests__/consolidation-engine.test.ts +1 -1
  55. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  56. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  57. package/src/__tests__/context-reconstructor.test.ts +65 -0
  58. package/src/__tests__/conversation-importer.test.ts +692 -0
  59. package/src/__tests__/data-checkup.test.ts +225 -0
  60. package/src/__tests__/decay-exemption.test.ts +84 -0
  61. package/src/__tests__/dedup-guard.test.ts +39 -0
  62. package/src/__tests__/distill-lock.test.ts +176 -0
  63. package/src/__tests__/dream-pipeline.test.ts +232 -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__/ethics-layer.test.ts +476 -0
  69. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  70. package/src/__tests__/frequency-tracker.test.ts +113 -0
  71. package/src/__tests__/graph-export.test.ts +489 -0
  72. package/src/__tests__/interference-detector.test.ts +203 -0
  73. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  74. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +243 -0
  75. package/src/__tests__/memory-evolution.test.ts +271 -0
  76. package/src/__tests__/memory-lint.test.ts +271 -0
  77. package/src/__tests__/narrative-architecture.test.ts +456 -0
  78. package/src/__tests__/pending-review.test.ts +77 -0
  79. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  80. package/src/__tests__/prediction-engine.test.ts +349 -0
  81. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  82. package/src/__tests__/preference-slots.test.ts +99 -0
  83. package/src/__tests__/query-expander-alias.test.ts +89 -0
  84. package/src/__tests__/recall-governor.test.ts +227 -0
  85. package/src/__tests__/resume-context.test.ts +156 -1
  86. package/src/__tests__/salience-scoring.test.ts +106 -0
  87. package/src/__tests__/session-distiller.test.ts +611 -0
  88. package/src/__tests__/skill-engine.test.ts +546 -0
  89. package/src/__tests__/skill-promotion.test.ts +349 -0
  90. package/src/__tests__/supersede-chain.test.ts +154 -0
  91. package/src/__tests__/temporal-validity.test.ts +160 -0
  92. package/src/__tests__/topic-tag.test.ts +113 -0
  93. package/src/__tests__/trusted-memory.test.ts +828 -0
  94. package/src/__tests__/write-verifier.test.ts +122 -0
  95. package/src/access-tracker.ts +3 -2
  96. package/src/activity-counter.ts +109 -0
  97. package/src/adaptive-retrieval.ts +6 -5
  98. package/src/admission-control.ts +163 -0
  99. package/src/anchor-generator.ts +80 -0
  100. package/src/api-server.ts +10 -0
  101. package/src/audit-log.ts +122 -0
  102. package/src/auto-gc.ts +98 -29
  103. package/src/auto-recall.ts +33 -4
  104. package/src/capture-engine.ts +372 -23
  105. package/src/cascade-forget.ts +3 -2
  106. package/src/cli.ts +107 -5
  107. package/src/compat.ts +22 -0
  108. package/src/confidence-tracker.ts +210 -47
  109. package/src/conflict-store.ts +12 -3
  110. package/src/consolidation-engine.ts +296 -8
  111. package/src/context-collapse-renderer.ts +222 -0
  112. package/src/context-composer-stable-selection.ts +5 -1
  113. package/src/context-composer.ts +239 -4
  114. package/src/context-reconstructor.ts +404 -0
  115. package/src/conversation-importer.ts +441 -0
  116. package/src/data-checkup.ts +232 -0
  117. package/src/decay-engine.ts +84 -0
  118. package/src/distill-lock.ts +122 -0
  119. package/src/doctor.ts +8 -10
  120. package/src/dream-pipeline.ts +227 -0
  121. package/src/emotion-detector.ts +90 -0
  122. package/src/eval.ts +3 -2
  123. package/src/forget-engine.ts +269 -0
  124. package/src/frequency-tracker.ts +175 -0
  125. package/src/graph-export.ts +644 -0
  126. package/src/ingest.ts +36 -6
  127. package/src/interference-detector.ts +187 -0
  128. package/src/kg-extractor.ts +15 -24
  129. package/src/kg-store.ts +2 -1
  130. package/src/language-hook.ts +67 -0
  131. package/src/llm-client.ts +289 -6
  132. package/src/mcp-server.ts +707 -80
  133. package/src/memory-assets.ts +22 -5
  134. package/src/memory-evolution.ts +365 -0
  135. package/src/memory-lint.ts +429 -0
  136. package/src/memory-output.ts +69 -0
  137. package/src/memory-schema.ts +74 -2
  138. package/src/narrative-schema.ts +79 -0
  139. package/src/narrative-tagger.ts +164 -0
  140. package/src/pii-detector.ts +98 -0
  141. package/src/post-retrieval-filter.ts +76 -0
  142. package/src/prediction-engine.ts +253 -0
  143. package/src/preference-slots.ts +65 -2
  144. package/src/prospective-memory.ts +215 -0
  145. package/src/query-expander.ts +67 -0
  146. package/src/recall-governor.ts +183 -0
  147. package/src/retention-policy.ts +90 -0
  148. package/src/retriever.ts +828 -55
  149. package/src/rif.ts +34 -8
  150. package/src/runtime-config.ts +23 -8
  151. package/src/scope-inventory.ts +4 -3
  152. package/src/scope-policy.ts +4 -0
  153. package/src/session-distiller.ts +307 -0
  154. package/src/session-output.ts +40 -0
  155. package/src/session-schema.ts +37 -0
  156. package/src/session-store.ts +7 -2
  157. package/src/skill-engine.ts +208 -0
  158. package/src/skill-promotion.ts +293 -0
  159. package/src/skill-schema.ts +39 -0
  160. package/src/store.ts +96 -10
  161. package/src/topic-tag.ts +66 -0
  162. package/src/tracker.ts +3 -1
  163. package/src/ui-server.ts +80 -8
  164. package/src/workflow-observation-scope-review.ts +2 -1
  165. package/src/workflow-observation-store.ts +2 -1
  166. package/src/write-verifier.ts +126 -0
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { runDataCheckup, formatCheckupReport, type CheckupReport } from "../data-checkup.js";
3
+ import type { MemoryEntry, MemoryStore } from "../store.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeEntry(overrides: Partial<MemoryEntry> & { id: string }): MemoryEntry {
10
+ return {
11
+ text: "test memory",
12
+ vector: [1, 0, 0, 0, 0],
13
+ category: "events",
14
+ scope: "project:test",
15
+ importance: 0.7,
16
+ timestamp: Date.now(),
17
+ metadata: JSON.stringify({
18
+ evolution: {
19
+ status: "active",
20
+ version: 1,
21
+ accessCount: 0,
22
+ lastAccessedAt: null,
23
+ supersededBy: null,
24
+ consolidatedInto: null,
25
+ contributedToPattern: null,
26
+ sourceMemories: [],
27
+ validFrom: Date.now(),
28
+ validUntil: null,
29
+ },
30
+ }),
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list" | "stats"> {
36
+ return {
37
+ async list() { return entries; },
38
+ async stats() {
39
+ return { totalCount: entries.length, scopeCounts: {}, categoryCounts: {} };
40
+ },
41
+ } as Pick<MemoryStore, "list" | "stats">;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Tests
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe("runDataCheckup", () => {
49
+ it("returns all-ok for healthy data", async () => {
50
+ const entries = [
51
+ makeEntry({ id: "a" }),
52
+ makeEntry({ id: "b" }),
53
+ makeEntry({ id: "c" }),
54
+ ];
55
+
56
+ const report = await runDataCheckup({
57
+ store: createMockStore(entries),
58
+ openConflictCount: 0,
59
+ });
60
+
61
+ expect(report.checks.length).toBe(6);
62
+ expect(report.checks.every(c => c.status === "ok")).toBe(true);
63
+ expect(report.totalEntries).toBe(3);
64
+ });
65
+
66
+ it("detects vector dimension inconsistency", async () => {
67
+ const entries = [
68
+ makeEntry({ id: "a", vector: [1, 0, 0, 0, 0] }),
69
+ makeEntry({ id: "b", vector: [1, 0, 0] }), // wrong dim
70
+ makeEntry({ id: "c", vector: [1, 0, 0, 0, 0] }),
71
+ ];
72
+
73
+ const report = await runDataCheckup({
74
+ store: createMockStore(entries),
75
+ openConflictCount: 0,
76
+ });
77
+
78
+ const dimCheck = report.checks.find(c => c.name === "vector_dimensions")!;
79
+ expect(dimCheck.status).toBe("error");
80
+ expect(dimCheck.detail).toContain("1 entries have wrong dimension");
81
+ });
82
+
83
+ it("detects orphan memories with empty scope", async () => {
84
+ const entries = [
85
+ makeEntry({ id: "a", scope: "project:test" }),
86
+ makeEntry({ id: "b", scope: "" }),
87
+ makeEntry({ id: "c", scope: "__schema__" }),
88
+ ];
89
+
90
+ const report = await runDataCheckup({
91
+ store: createMockStore(entries),
92
+ openConflictCount: 0,
93
+ });
94
+
95
+ const orphanCheck = report.checks.find(c => c.name === "orphan_memories")!;
96
+ expect(orphanCheck.status).toBe("warning");
97
+ expect(orphanCheck.detail).toContain("2 memories");
98
+ });
99
+
100
+ it("warns about unhealthy tier distribution — too many core", async () => {
101
+ // Make 501 core entries (tier resolved from metadata top-level importance >= 0.95)
102
+ const entries = Array.from({ length: 501 }, (_, i) =>
103
+ makeEntry({
104
+ id: `core-${i}`,
105
+ importance: 0.99,
106
+ metadata: JSON.stringify({
107
+ importance: 0.99,
108
+ accessCount: 10,
109
+ evolution: { status: "active", version: 1, accessCount: 10, lastAccessedAt: null, supersededBy: null, consolidatedInto: null, contributedToPattern: null, sourceMemories: [], validFrom: Date.now(), validUntil: null },
110
+ }),
111
+ })
112
+ );
113
+
114
+ const report = await runDataCheckup({
115
+ store: createMockStore(entries),
116
+ openConflictCount: 0,
117
+ });
118
+
119
+ const tierCheck = report.checks.find(c => c.name === "tier_distribution")!;
120
+ expect(tierCheck.status).toBe("warning");
121
+ expect(tierCheck.detail).toContain("core tier has 501");
122
+ });
123
+
124
+ it("reports conflict backlog levels", async () => {
125
+ // 0 = ok
126
+ const r0 = await runDataCheckup({ store: createMockStore([]), openConflictCount: 0 });
127
+ expect(r0.checks.find(c => c.name === "conflict_backlog")!.status).toBe("ok");
128
+
129
+ // 3 = still ok
130
+ const r3 = await runDataCheckup({ store: createMockStore([]), openConflictCount: 3 });
131
+ expect(r3.checks.find(c => c.name === "conflict_backlog")!.status).toBe("ok");
132
+
133
+ // 10 = warning
134
+ const r10 = await runDataCheckup({ store: createMockStore([]), openConflictCount: 10 });
135
+ expect(r10.checks.find(c => c.name === "conflict_backlog")!.status).toBe("warning");
136
+
137
+ // 25 = error
138
+ const r25 = await runDataCheckup({ store: createMockStore([]), openConflictCount: 25 });
139
+ expect(r25.checks.find(c => c.name === "conflict_backlog")!.status).toBe("error");
140
+ });
141
+
142
+ it("detects version group issues — single-member group", async () => {
143
+ const entries = [
144
+ makeEntry({
145
+ id: "a",
146
+ metadata: JSON.stringify({
147
+ version_group: "vg-abc",
148
+ version_rank: 2.5,
149
+ evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, supersededBy: null, consolidatedInto: null, contributedToPattern: null, sourceMemories: [], validFrom: Date.now(), validUntil: null },
150
+ }),
151
+ }),
152
+ // vg-abc has only 1 member — suspicious
153
+ ];
154
+
155
+ const report = await runDataCheckup({
156
+ store: createMockStore(entries),
157
+ openConflictCount: 0,
158
+ });
159
+
160
+ const vgCheck = report.checks.find(c => c.name === "version_groups")!;
161
+ expect(vgCheck.status).toBe("warning");
162
+ expect(vgCheck.detail).toContain("only 1 member");
163
+ });
164
+
165
+ it("detects version group with missing rank", async () => {
166
+ const entries = [
167
+ makeEntry({
168
+ id: "a",
169
+ metadata: JSON.stringify({
170
+ version_group: "vg-xyz",
171
+ version_rank: 2.0,
172
+ evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, supersededBy: null, consolidatedInto: null, contributedToPattern: null, sourceMemories: [], validFrom: Date.now(), validUntil: null },
173
+ }),
174
+ }),
175
+ makeEntry({
176
+ id: "b",
177
+ metadata: JSON.stringify({
178
+ version_group: "vg-xyz",
179
+ // version_rank missing
180
+ evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, supersededBy: null, consolidatedInto: null, contributedToPattern: null, sourceMemories: [], validFrom: Date.now(), validUntil: null },
181
+ }),
182
+ }),
183
+ ];
184
+
185
+ const report = await runDataCheckup({
186
+ store: createMockStore(entries),
187
+ openConflictCount: 0,
188
+ });
189
+
190
+ const vgCheck = report.checks.find(c => c.name === "version_groups")!;
191
+ expect(vgCheck.status).toBe("warning");
192
+ expect(vgCheck.detail).toContain("missing rank");
193
+ });
194
+
195
+ it("handles empty database", async () => {
196
+ const report = await runDataCheckup({
197
+ store: createMockStore([]),
198
+ openConflictCount: 0,
199
+ });
200
+
201
+ expect(report.totalEntries).toBe(0);
202
+ expect(report.checks.every(c => c.status === "ok")).toBe(true);
203
+ });
204
+ });
205
+
206
+ describe("formatCheckupReport", () => {
207
+ it("formats report with mix of statuses", () => {
208
+ const report: CheckupReport = {
209
+ checks: [
210
+ { name: "check_a", status: "ok", detail: "all good" },
211
+ { name: "check_b", status: "warning", detail: "minor issue" },
212
+ { name: "check_c", status: "error", detail: "big problem" },
213
+ ],
214
+ totalEntries: 100,
215
+ timestamp: "2026-04-03T12:00:00.000Z",
216
+ };
217
+
218
+ const output = formatCheckupReport(report);
219
+ expect(output).toContain("[OK] check_a");
220
+ expect(output).toContain("[WARN] check_b");
221
+ expect(output).toContain("[ERR] check_c");
222
+ expect(output).toContain("1 error(s), 1 warning(s), 1 ok");
223
+ expect(output).toContain("Total entries scanned: 100");
224
+ });
225
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { isDecayExempt } from "../decay-engine.js";
3
+
4
+ describe("HP-7: Decay exemption rules", () => {
5
+ describe("Rule 1: core tier + importance ≥ 0.95", () => {
6
+ it("exempts explicit core with high importance", () => {
7
+ const meta = JSON.stringify({ tier: "core", importance: 0.95 });
8
+ expect(isDecayExempt(meta, 0.95)).toBe(true);
9
+ });
10
+
11
+ it("exempts inferred core (importance ≥ 0.95)", () => {
12
+ const meta = JSON.stringify({ importance: 0.96 });
13
+ expect(isDecayExempt(meta, 0.96)).toBe(true);
14
+ });
15
+
16
+ it("does NOT exempt core with importance < 0.95", () => {
17
+ const meta = JSON.stringify({ tier: "core", importance: 0.90 });
18
+ expect(isDecayExempt(meta, 0.90)).toBe(false);
19
+ });
20
+
21
+ it("does NOT exempt working tier even with high importance", () => {
22
+ const meta = JSON.stringify({ tier: "working", importance: 0.96 });
23
+ expect(isDecayExempt(meta, 0.96)).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("Rule 2: recently accessed (within 7 days)", () => {
28
+ it("exempts memory accessed 1 day ago", () => {
29
+ const meta = JSON.stringify({
30
+ lastAccessedAt: Date.now() - 1 * 86_400_000,
31
+ });
32
+ expect(isDecayExempt(meta, 0.50)).toBe(true);
33
+ });
34
+
35
+ it("does NOT exempt memory accessed 8 days ago", () => {
36
+ const meta = JSON.stringify({
37
+ lastAccessedAt: Date.now() - 8 * 86_400_000,
38
+ });
39
+ expect(isDecayExempt(meta, 0.50)).toBe(false);
40
+ });
41
+
42
+ it("does NOT exempt when lastAccessedAt is 0", () => {
43
+ const meta = JSON.stringify({ lastAccessedAt: 0 });
44
+ expect(isDecayExempt(meta, 0.50)).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("Rule 3: pinned tag", () => {
49
+ it("exempts memory with pinned tag", () => {
50
+ const meta = JSON.stringify({ tags: ["workflow", "pinned"] });
51
+ expect(isDecayExempt(meta, 0.40)).toBe(true);
52
+ });
53
+
54
+ it("does NOT exempt without pinned tag", () => {
55
+ const meta = JSON.stringify({ tags: ["workflow", "pattern"] });
56
+ expect(isDecayExempt(meta, 0.40)).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("edge cases", () => {
61
+ it("returns false for undefined metadata", () => {
62
+ expect(isDecayExempt(undefined, 0.95)).toBe(false);
63
+ });
64
+
65
+ it("returns false for empty metadata", () => {
66
+ expect(isDecayExempt("{}", 0.50)).toBe(false);
67
+ });
68
+
69
+ it("returns false for corrupt metadata", () => {
70
+ expect(isDecayExempt("not-json", 0.95)).toBe(false);
71
+ });
72
+
73
+ it("multiple rules: first match wins", () => {
74
+ // Core + pinned + recent — all three rules match
75
+ const meta = JSON.stringify({
76
+ tier: "core",
77
+ importance: 0.98,
78
+ lastAccessedAt: Date.now() - 3600000,
79
+ tags: ["pinned"],
80
+ });
81
+ expect(isDecayExempt(meta, 0.98)).toBe(true);
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { isTemporalOrActionContent } from "../ingest.js";
3
+
4
+ describe("Dedup guard: isTemporalOrActionContent", () => {
5
+ describe("date-stamped events (must NOT be deduped)", () => {
6
+ it("ISO date", () => expect(isTemporalOrActionContent("2026-03-25 部署了新版本")).toBe(true));
7
+ it("今天", () => expect(isTemporalOrActionContent("今天跑了 benchmark")).toBe(true));
8
+ it("昨天", () => expect(isTemporalOrActionContent("昨天讨论了架构")).toBe(true));
9
+ it("上次", () => expect(isTemporalOrActionContent("上次的方案不行")).toBe(true));
10
+ it("刚才", () => expect(isTemporalOrActionContent("刚才改了配置")).toBe(true));
11
+ });
12
+
13
+ describe("file operations (must NOT be deduped)", () => {
14
+ it("file path", () => expect(isTemporalOrActionContent("修改了 /Users/xxx/src/foo.ts")).toBe(true));
15
+ it("home path", () => expect(isTemporalOrActionContent("保存到 ~/recallnest/data/")).toBe(true));
16
+ it("md file", () => expect(isTemporalOrActionContent("更新了 README.md")).toBe(true));
17
+ it("mv command", () => expect(isTemporalOrActionContent("mv old.json new.json")).toBe(true));
18
+ it("归档", () => expect(isTemporalOrActionContent("把旧文件归档了")).toBe(true));
19
+ });
20
+
21
+ describe("memory instructions (must NOT be deduped)", () => {
22
+ it("记住", () => expect(isTemporalOrActionContent("记住不要用 rm")).toBe(true));
23
+ it("remember", () => expect(isTemporalOrActionContent("remember to check tests")).toBe(true));
24
+ it("别忘了", () => expect(isTemporalOrActionContent("别忘了跑测试")).toBe(true));
25
+ it("务必", () => expect(isTemporalOrActionContent("务必先备份")).toBe(true));
26
+ });
27
+
28
+ describe("feedback/corrections (must NOT be deduped)", () => {
29
+ it("不要再", () => expect(isTemporalOrActionContent("不要再用这个方法")).toBe(true));
30
+ it("stop doing", () => expect(isTemporalOrActionContent("stop doing that")).toBe(true));
31
+ it("don't", () => expect(isTemporalOrActionContent("don't mock the database")).toBe(true));
32
+ });
33
+
34
+ describe("generic content (CAN be deduped normally)", () => {
35
+ it("plain description", () => expect(isTemporalOrActionContent("RecallNest 是一个记忆服务")).toBe(false));
36
+ it("technical fact", () => expect(isTemporalOrActionContent("LanceDB 支持向量搜索")).toBe(false));
37
+ it("preference without instruction marker", () => expect(isTemporalOrActionContent("我喜欢用 dark mode")).toBe(false));
38
+ });
39
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, writeFileSync, mkdirSync, statSync, utimesSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ import {
7
+ acquireLock,
8
+ releaseLock,
9
+ rollbackLock,
10
+ shouldDistill,
11
+ getLastDistillTime,
12
+ type DistillLockConfig,
13
+ } from "../distill-lock.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ let tempDir: string;
20
+ let lockPath: string;
21
+
22
+ function cfg(overrides?: Partial<DistillLockConfig>): Partial<DistillLockConfig> {
23
+ return { lockPath, ...overrides };
24
+ }
25
+
26
+ beforeEach(() => {
27
+ tempDir = join(tmpdir(), `distill-lock-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
28
+ mkdirSync(tempDir, { recursive: true });
29
+ lockPath = join(tempDir, "distill.lock");
30
+ });
31
+
32
+ afterEach(() => {
33
+ // Clean up lock file if it exists
34
+ if (existsSync(lockPath)) {
35
+ try {
36
+ const { unlinkSync } = require("node:fs");
37
+ unlinkSync(lockPath);
38
+ } catch { /* ignore */ }
39
+ }
40
+ });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // acquireLock
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe("acquireLock", () => {
47
+ it("creates lock file and returns true when no lock exists", () => {
48
+ const result = acquireLock(cfg());
49
+ expect(result).toBe(true);
50
+ expect(existsSync(lockPath)).toBe(true);
51
+ });
52
+
53
+ it("writes current PID into lock file", () => {
54
+ acquireLock(cfg());
55
+ const { readFileSync } = require("node:fs");
56
+ const content = readFileSync(lockPath, "utf-8").trim();
57
+ expect(content).toBe(String(process.pid));
58
+ });
59
+
60
+ it("returns false when lock held by alive PID and not expired", () => {
61
+ // Write current process PID (which is alive)
62
+ writeFileSync(lockPath, String(process.pid), "utf-8");
63
+
64
+ const result = acquireLock(cfg());
65
+ expect(result).toBe(false);
66
+ });
67
+
68
+ it("overwrites lock when PID is dead", () => {
69
+ // PID 999999 is almost certainly not running
70
+ writeFileSync(lockPath, "999999", "utf-8");
71
+
72
+ const result = acquireLock(cfg());
73
+ expect(result).toBe(true);
74
+
75
+ const { readFileSync } = require("node:fs");
76
+ const content = readFileSync(lockPath, "utf-8").trim();
77
+ expect(content).toBe(String(process.pid));
78
+ });
79
+
80
+ it("overwrites lock when mtime is expired even if PID alive", () => {
81
+ writeFileSync(lockPath, String(process.pid), "utf-8");
82
+ // Set mtime to 2 hours ago
83
+ const twoHoursAgo = new Date(Date.now() - 7_200_000);
84
+ utimesSync(lockPath, twoHoursAgo, twoHoursAgo);
85
+
86
+ const result = acquireLock(cfg({ expireMs: 3_600_000 }));
87
+ expect(result).toBe(true);
88
+ });
89
+
90
+ it("creates parent directories if missing", () => {
91
+ const deepPath = join(tempDir, "nested", "dir", "distill.lock");
92
+ const result = acquireLock(cfg({ lockPath: deepPath }));
93
+ expect(result).toBe(true);
94
+ expect(existsSync(deepPath)).toBe(true);
95
+ });
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // releaseLock
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe("releaseLock", () => {
103
+ it("deletes lock file", () => {
104
+ writeFileSync(lockPath, String(process.pid), "utf-8");
105
+ expect(existsSync(lockPath)).toBe(true);
106
+
107
+ releaseLock(cfg());
108
+ expect(existsSync(lockPath)).toBe(false);
109
+ });
110
+
111
+ it("is a no-op when lock file does not exist", () => {
112
+ expect(() => releaseLock(cfg())).not.toThrow();
113
+ });
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // rollbackLock
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("rollbackLock", () => {
121
+ it("sets lock mtime to the given previous value", () => {
122
+ writeFileSync(lockPath, String(process.pid), "utf-8");
123
+ const pastDate = new Date(Date.now() - 600_000); // 10 min ago
124
+
125
+ rollbackLock(pastDate, cfg());
126
+
127
+ const st = statSync(lockPath);
128
+ // Allow 1 second tolerance for filesystem granularity
129
+ expect(Math.abs(st.mtimeMs - pastDate.getTime())).toBeLessThan(1000);
130
+ });
131
+
132
+ it("is a no-op when lock file does not exist", () => {
133
+ expect(() => rollbackLock(new Date(), cfg())).not.toThrow();
134
+ });
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // shouldDistill
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe("shouldDistill", () => {
142
+ it("returns true when checkpoint count >= minCheckpoints (default 3)", () => {
143
+ expect(shouldDistill(3, cfg())).toBe(true);
144
+ expect(shouldDistill(5, cfg())).toBe(true);
145
+ });
146
+
147
+ it("returns false when checkpoint count < minCheckpoints", () => {
148
+ expect(shouldDistill(0, cfg())).toBe(false);
149
+ expect(shouldDistill(1, cfg())).toBe(false);
150
+ expect(shouldDistill(2, cfg())).toBe(false);
151
+ });
152
+
153
+ it("respects custom minCheckpoints", () => {
154
+ expect(shouldDistill(4, cfg({ minCheckpoints: 5 }))).toBe(false);
155
+ expect(shouldDistill(5, cfg({ minCheckpoints: 5 }))).toBe(true);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // getLastDistillTime
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("getLastDistillTime", () => {
164
+ it("returns lock file mtime when lock exists", () => {
165
+ writeFileSync(lockPath, String(process.pid), "utf-8");
166
+ const st = statSync(lockPath);
167
+
168
+ const result = getLastDistillTime(cfg());
169
+ expect(Math.abs(result - st.mtimeMs)).toBeLessThan(100);
170
+ });
171
+
172
+ it("returns 0 when lock file does not exist", () => {
173
+ const result = getLastDistillTime(cfg());
174
+ expect(result).toBe(0);
175
+ });
176
+ });