@malindar/whyline 0.1.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 (164) hide show
  1. package/.claude/settings.local.json +33 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +37 -0
  4. package/.prettierrc.json +7 -0
  5. package/CLAUDE.md +74 -0
  6. package/LICENSE +21 -0
  7. package/README.md +359 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +125 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/commands/delete.d.ts +3 -0
  12. package/dist/commands/delete.js +42 -0
  13. package/dist/commands/delete.js.map +1 -0
  14. package/dist/commands/doctor.d.ts +1 -0
  15. package/dist/commands/doctor.js +111 -0
  16. package/dist/commands/doctor.js.map +1 -0
  17. package/dist/commands/edit.d.ts +1 -0
  18. package/dist/commands/edit.js +78 -0
  19. package/dist/commands/edit.js.map +1 -0
  20. package/dist/commands/export.d.ts +8 -0
  21. package/dist/commands/export.js +90 -0
  22. package/dist/commands/export.js.map +1 -0
  23. package/dist/commands/import.d.ts +1 -0
  24. package/dist/commands/import.js +110 -0
  25. package/dist/commands/import.js.map +1 -0
  26. package/dist/commands/init.d.ts +5 -0
  27. package/dist/commands/init.js +23 -0
  28. package/dist/commands/init.js.map +1 -0
  29. package/dist/commands/install-claude.d.ts +3 -0
  30. package/dist/commands/install-claude.js +180 -0
  31. package/dist/commands/install-claude.js.map +1 -0
  32. package/dist/commands/list.d.ts +4 -0
  33. package/dist/commands/list.js +35 -0
  34. package/dist/commands/list.js.map +1 -0
  35. package/dist/commands/mcp.d.ts +1 -0
  36. package/dist/commands/mcp.js +10 -0
  37. package/dist/commands/mcp.js.map +1 -0
  38. package/dist/commands/save.d.ts +4 -0
  39. package/dist/commands/save.js +74 -0
  40. package/dist/commands/save.js.map +1 -0
  41. package/dist/commands/search.d.ts +7 -0
  42. package/dist/commands/search.js +46 -0
  43. package/dist/commands/search.js.map +1 -0
  44. package/dist/commands/show.d.ts +3 -0
  45. package/dist/commands/show.js +30 -0
  46. package/dist/commands/show.js.map +1 -0
  47. package/dist/commands/stats.d.ts +1 -0
  48. package/dist/commands/stats.js +27 -0
  49. package/dist/commands/stats.js.map +1 -0
  50. package/dist/commands/summarize.d.ts +3 -0
  51. package/dist/commands/summarize.js +140 -0
  52. package/dist/commands/summarize.js.map +1 -0
  53. package/dist/config.d.ts +11 -0
  54. package/dist/config.js +17 -0
  55. package/dist/config.js.map +1 -0
  56. package/dist/db/connection.d.ts +2 -0
  57. package/dist/db/connection.js +8 -0
  58. package/dist/db/connection.js.map +1 -0
  59. package/dist/db/migrations.d.ts +2 -0
  60. package/dist/db/migrations.js +19 -0
  61. package/dist/db/migrations.js.map +1 -0
  62. package/dist/db/schema.d.ts +5 -0
  63. package/dist/db/schema.js +64 -0
  64. package/dist/db/schema.js.map +1 -0
  65. package/dist/git/diff.d.ts +2 -0
  66. package/dist/git/diff.js +45 -0
  67. package/dist/git/diff.js.map +1 -0
  68. package/dist/git/git.d.ts +3 -0
  69. package/dist/git/git.js +25 -0
  70. package/dist/git/git.js.map +1 -0
  71. package/dist/git/repoId.d.ts +3 -0
  72. package/dist/git/repoId.js +49 -0
  73. package/dist/git/repoId.js.map +1 -0
  74. package/dist/mcp/server.d.ts +1 -0
  75. package/dist/mcp/server.js +296 -0
  76. package/dist/mcp/server.js.map +1 -0
  77. package/dist/mcp/tools.d.ts +119 -0
  78. package/dist/mcp/tools.js +43 -0
  79. package/dist/mcp/tools.js.map +1 -0
  80. package/dist/memory/parseSummary.d.ts +14 -0
  81. package/dist/memory/parseSummary.js +53 -0
  82. package/dist/memory/parseSummary.js.map +1 -0
  83. package/dist/memory/qualityCheck.d.ts +13 -0
  84. package/dist/memory/qualityCheck.js +78 -0
  85. package/dist/memory/qualityCheck.js.map +1 -0
  86. package/dist/memory/redactSecrets.d.ts +7 -0
  87. package/dist/memory/redactSecrets.js +29 -0
  88. package/dist/memory/redactSecrets.js.map +1 -0
  89. package/dist/memory/repoContext.d.ts +2 -0
  90. package/dist/memory/repoContext.js +23 -0
  91. package/dist/memory/repoContext.js.map +1 -0
  92. package/dist/memory/saveMemory.d.ts +40 -0
  93. package/dist/memory/saveMemory.js +223 -0
  94. package/dist/memory/saveMemory.js.map +1 -0
  95. package/dist/memory/searchMemory.d.ts +17 -0
  96. package/dist/memory/searchMemory.js +122 -0
  97. package/dist/memory/searchMemory.js.map +1 -0
  98. package/dist/memory/types.d.ts +48 -0
  99. package/dist/memory/types.js +2 -0
  100. package/dist/memory/types.js.map +1 -0
  101. package/dist/output/format.d.ts +3 -0
  102. package/dist/output/format.js +43 -0
  103. package/dist/output/format.js.map +1 -0
  104. package/docs/architecture.md +387 -0
  105. package/docs/ec6ab3bf-60cf-4629-ad9e-3048e8e3c43a.png +0 -0
  106. package/docs/logo.png +0 -0
  107. package/eslint.config.js +16 -0
  108. package/how-to-run/01-install.md +69 -0
  109. package/how-to-run/02-wire-up-your-repo.md +80 -0
  110. package/how-to-run/03-test-it-manually.md +91 -0
  111. package/how-to-run/04-test-with-claude-code.md +70 -0
  112. package/how-to-run/CLAUDE.md.template +72 -0
  113. package/how-to-run/README.md +49 -0
  114. package/package.json +60 -0
  115. package/src/cli.ts +142 -0
  116. package/src/commands/delete.ts +47 -0
  117. package/src/commands/doctor.ts +128 -0
  118. package/src/commands/edit.ts +80 -0
  119. package/src/commands/export.ts +95 -0
  120. package/src/commands/import.ts +119 -0
  121. package/src/commands/init.ts +31 -0
  122. package/src/commands/install-claude.ts +203 -0
  123. package/src/commands/list.ts +41 -0
  124. package/src/commands/mcp.ts +12 -0
  125. package/src/commands/save.ts +85 -0
  126. package/src/commands/search.ts +56 -0
  127. package/src/commands/show.ts +37 -0
  128. package/src/commands/stats.ts +31 -0
  129. package/src/commands/summarize.ts +183 -0
  130. package/src/config.ts +26 -0
  131. package/src/db/connection.ts +8 -0
  132. package/src/db/migrations.ts +26 -0
  133. package/src/db/schema.ts +68 -0
  134. package/src/git/diff.ts +43 -0
  135. package/src/git/git.ts +25 -0
  136. package/src/git/repoId.ts +49 -0
  137. package/src/hooks/post-commit.sample.sh +9 -0
  138. package/src/mcp/server.ts +326 -0
  139. package/src/mcp/tools.ts +53 -0
  140. package/src/memory/parseSummary.ts +72 -0
  141. package/src/memory/qualityCheck.ts +102 -0
  142. package/src/memory/redactSecrets.ts +32 -0
  143. package/src/memory/repoContext.ts +25 -0
  144. package/src/memory/saveMemory.ts +369 -0
  145. package/src/memory/searchMemory.ts +153 -0
  146. package/src/memory/types.ts +57 -0
  147. package/src/output/format.ts +44 -0
  148. package/src/skill/SKILL.md +95 -0
  149. package/tests/cliV02.test.ts +213 -0
  150. package/tests/doctor.test.ts +253 -0
  151. package/tests/exportImport.test.ts +248 -0
  152. package/tests/fileRename.test.ts +156 -0
  153. package/tests/gitHelpers.test.ts +94 -0
  154. package/tests/init.test.ts +93 -0
  155. package/tests/installClaude.test.ts +157 -0
  156. package/tests/parseSummary.test.ts +111 -0
  157. package/tests/qualityCheck.test.ts +182 -0
  158. package/tests/redactSecrets.test.ts +75 -0
  159. package/tests/saveMemory.test.ts +196 -0
  160. package/tests/searchFilters.test.ts +139 -0
  161. package/tests/searchMemory.test.ts +273 -0
  162. package/tests/stale.test.ts +47 -0
  163. package/tsconfig.json +18 -0
  164. package/vitest.config.ts +8 -0
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import Database from "better-sqlite3";
3
+ import { openDb } from "../src/db/connection.js";
4
+ import { runMigrations } from "../src/db/migrations.js";
5
+ import { saveMemory, getMemoryById, getMemoryByCommit, generateMemoryId, buildEmbeddingText } from "../src/memory/saveMemory.js";
6
+ import type { CodingMemory } from "../src/memory/types.js";
7
+
8
+ function makeTestDb(): Database.Database {
9
+ const db = openDb(":memory:");
10
+ runMigrations(db);
11
+ return db;
12
+ }
13
+
14
+ function makeMemory(overrides: Partial<CodingMemory> = {}): CodingMemory {
15
+ const id = generateMemoryId();
16
+ const base: CodingMemory = {
17
+ id,
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ repoId: "testrepo123",
21
+ repoPath: "/home/user/my-app",
22
+ repoName: "my-app",
23
+ branch: "main",
24
+ commitSha: "abc1234567890",
25
+ files: ["src/comments/sync.ts", "src/comments/render.ts"],
26
+ tags: ["comments", "optimistic-ui"],
27
+ intent: "Add optimistic comment rendering",
28
+ summary: "Implemented optimistic rendering",
29
+ decision: "Render immediately, reconcile after ack",
30
+ why: "Server wait made UI feel slow",
31
+ alternativesRejected: ["Server-confirmed only"],
32
+ risks: ["Duplicate comments during reconnect"],
33
+ followUps: ["Add dedupe tests"],
34
+ source: "cli",
35
+ embeddingText: "Add optimistic comment rendering Implemented optimistic rendering",
36
+ ...overrides,
37
+ };
38
+ return base;
39
+ }
40
+
41
+ describe("saveMemory", () => {
42
+ let db: Database.Database;
43
+
44
+ beforeEach(() => {
45
+ db = makeTestDb();
46
+ });
47
+
48
+ it("saves a memory to the memories table", () => {
49
+ const memory = makeMemory();
50
+ saveMemory(db, memory);
51
+ const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(memory.id) as { id: string } | undefined;
52
+ expect(row).toBeDefined();
53
+ expect(row?.id).toBe(memory.id);
54
+ });
55
+
56
+ it("saves linked files to memory_files", () => {
57
+ const memory = makeMemory();
58
+ saveMemory(db, memory);
59
+ const files = db
60
+ .prepare<[string], { file_path: string }>(
61
+ "SELECT file_path FROM memory_files WHERE memory_id = ?"
62
+ )
63
+ .all(memory.id)
64
+ .map((r) => r.file_path);
65
+ expect(files).toEqual(expect.arrayContaining(memory.files));
66
+ expect(files.length).toBe(memory.files.length);
67
+ });
68
+
69
+ it("saves linked tags to memory_tags", () => {
70
+ const memory = makeMemory();
71
+ saveMemory(db, memory);
72
+ const tags = db
73
+ .prepare<[string], { tag: string }>("SELECT tag FROM memory_tags WHERE memory_id = ?")
74
+ .all(memory.id)
75
+ .map((r) => r.tag);
76
+ expect(tags).toEqual(expect.arrayContaining(memory.tags));
77
+ });
78
+
79
+ it("saves alternatives to memory_alternatives", () => {
80
+ const memory = makeMemory();
81
+ saveMemory(db, memory);
82
+ const alts = db
83
+ .prepare<[string], { value: string }>(
84
+ "SELECT value FROM memory_alternatives WHERE memory_id = ?"
85
+ )
86
+ .all(memory.id)
87
+ .map((r) => r.value);
88
+ expect(alts).toEqual(expect.arrayContaining(memory.alternativesRejected));
89
+ });
90
+
91
+ it("saves risks to memory_risks", () => {
92
+ const memory = makeMemory();
93
+ saveMemory(db, memory);
94
+ const risks = db
95
+ .prepare<[string], { value: string }>(
96
+ "SELECT value FROM memory_risks WHERE memory_id = ?"
97
+ )
98
+ .all(memory.id)
99
+ .map((r) => r.value);
100
+ expect(risks).toEqual(expect.arrayContaining(memory.risks));
101
+ });
102
+
103
+ it("saves followUps to memory_followups", () => {
104
+ const memory = makeMemory();
105
+ saveMemory(db, memory);
106
+ const followUps = db
107
+ .prepare<[string], { value: string }>(
108
+ "SELECT value FROM memory_followups WHERE memory_id = ?"
109
+ )
110
+ .all(memory.id)
111
+ .map((r) => r.value);
112
+ expect(followUps).toEqual(expect.arrayContaining(memory.followUps));
113
+ });
114
+
115
+ it("prevents duplicate junction rows on re-insert attempt", () => {
116
+ const memory = makeMemory();
117
+ saveMemory(db, memory);
118
+ // Manually try to insert a duplicate file — should be silently ignored
119
+ db.prepare("INSERT OR IGNORE INTO memory_files (memory_id, file_path) VALUES (?, ?)").run(
120
+ memory.id,
121
+ memory.files[0]
122
+ );
123
+ const files = db
124
+ .prepare<[string], { file_path: string }>(
125
+ "SELECT file_path FROM memory_files WHERE memory_id = ?"
126
+ )
127
+ .all(memory.id);
128
+ expect(files.length).toBe(memory.files.length);
129
+ });
130
+ });
131
+
132
+ describe("getMemoryById", () => {
133
+ let db: Database.Database;
134
+
135
+ beforeEach(() => {
136
+ db = makeTestDb();
137
+ });
138
+
139
+ it("retrieves a memory by ID with all junction data", () => {
140
+ const memory = makeMemory();
141
+ saveMemory(db, memory);
142
+ const retrieved = getMemoryById(db, memory.id);
143
+ expect(retrieved).not.toBeNull();
144
+ expect(retrieved?.id).toBe(memory.id);
145
+ expect(retrieved?.files).toEqual(expect.arrayContaining(memory.files));
146
+ expect(retrieved?.tags).toEqual(expect.arrayContaining(memory.tags));
147
+ expect(retrieved?.risks).toEqual(expect.arrayContaining(memory.risks));
148
+ expect(retrieved?.followUps).toEqual(expect.arrayContaining(memory.followUps));
149
+ });
150
+
151
+ it("returns null for unknown ID", () => {
152
+ expect(getMemoryById(db, "nonexistent")).toBeNull();
153
+ });
154
+ });
155
+
156
+ describe("getMemoryByCommit", () => {
157
+ let db: Database.Database;
158
+
159
+ beforeEach(() => {
160
+ db = makeTestDb();
161
+ });
162
+
163
+ it("retrieves memory by commit SHA", () => {
164
+ const memory = makeMemory({ commitSha: "deadbeef1234" });
165
+ saveMemory(db, memory);
166
+ const retrieved = getMemoryByCommit(db, "deadbeef1234");
167
+ expect(retrieved?.id).toBe(memory.id);
168
+ });
169
+
170
+ it("returns null for unknown commit", () => {
171
+ expect(getMemoryByCommit(db, "unknown")).toBeNull();
172
+ });
173
+ });
174
+
175
+ describe("generateMemoryId", () => {
176
+ it("generates IDs with mem_ prefix", () => {
177
+ expect(generateMemoryId()).toMatch(/^mem_/);
178
+ });
179
+
180
+ it("generates unique IDs", () => {
181
+ const ids = new Set(Array.from({ length: 100 }, generateMemoryId));
182
+ expect(ids.size).toBe(100);
183
+ });
184
+ });
185
+
186
+ describe("buildEmbeddingText", () => {
187
+ it("concatenates all content fields", () => {
188
+ const memory = makeMemory();
189
+ const text = buildEmbeddingText(memory);
190
+ expect(text).toContain(memory.intent);
191
+ expect(text).toContain(memory.decision);
192
+ expect(text).toContain(memory.why);
193
+ expect(text).toContain(memory.files[0]);
194
+ expect(text).toContain(memory.tags[0]);
195
+ });
196
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { openDb } from "../src/db/connection.js";
3
+ import { runMigrations } from "../src/db/migrations.js";
4
+ import { saveMemory, generateMemoryId } from "../src/memory/saveMemory.js";
5
+ import { searchMemory } from "../src/memory/searchMemory.js";
6
+ import type { CodingMemory } from "../src/memory/types.js";
7
+ import type Database from "better-sqlite3";
8
+
9
+ function makeTestDb(): Database.Database {
10
+ const db = openDb(":memory:");
11
+ runMigrations(db);
12
+ return db;
13
+ }
14
+
15
+ function makeMemory(overrides: Partial<CodingMemory> = {}): CodingMemory {
16
+ return {
17
+ id: generateMemoryId(),
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ repoId: "repo-abc",
21
+ repoPath: "/home/user/my-app",
22
+ repoName: "my-app",
23
+ branch: "main",
24
+ commitSha: "abc123",
25
+ files: ["src/auth/session.ts"],
26
+ tags: ["auth", "security"],
27
+ intent: "Add refresh token rotation to prevent replay attacks",
28
+ summary: "Implemented refresh token rotation",
29
+ decision: "Rotate tokens on every request",
30
+ why: "One-time-use tokens prevent replay attacks",
31
+ alternativesRejected: [],
32
+ risks: [],
33
+ followUps: [],
34
+ source: "cli",
35
+ embeddingText: "Add refresh token rotation to prevent replay attacks",
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe("search — tag filtering", () => {
41
+ let db: Database.Database;
42
+ beforeEach(() => { db = makeTestDb(); });
43
+
44
+ it("returns only memories that have the requested tag", () => {
45
+ saveMemory(db, makeMemory({ id: generateMemoryId(), tags: ["auth", "security"] }));
46
+ saveMemory(db, makeMemory({ id: generateMemoryId(), tags: ["payments"], intent: "Add payment gateway retry logic for failed transactions", decision: "Retry with exponential backoff", why: "Transient failures should not block checkout permanently" }));
47
+
48
+ const results = searchMemory(db, { query: "", tags: ["auth"] });
49
+ expect(results.length).toBe(1);
50
+ expect(results[0].memory.tags).toContain("auth");
51
+ });
52
+
53
+ it("returns only memories that have ALL requested tags", () => {
54
+ saveMemory(db, makeMemory({ id: generateMemoryId(), tags: ["auth", "security"] }));
55
+ saveMemory(db, makeMemory({ id: generateMemoryId(), tags: ["auth"], intent: "Add auth session expiry handling for idle users", decision: "Expire after 30 minutes idle", why: "Reduces attack surface for stolen sessions" }));
56
+
57
+ const results = searchMemory(db, { query: "", tags: ["auth", "security"] });
58
+ expect(results.length).toBe(1);
59
+ expect(results[0].memory.tags).toContain("security");
60
+ });
61
+
62
+ it("is case-insensitive for tags", () => {
63
+ saveMemory(db, makeMemory({ tags: ["Auth", "Security"] }));
64
+ const results = searchMemory(db, { query: "", tags: ["auth"] });
65
+ expect(results.length).toBe(1);
66
+ });
67
+
68
+ it("returns empty when no memory matches the tag", () => {
69
+ saveMemory(db, makeMemory({ tags: ["auth"] }));
70
+ const results = searchMemory(db, { query: "", tags: ["payments"] });
71
+ expect(results.length).toBe(0);
72
+ });
73
+
74
+ it("works alongside a keyword query", () => {
75
+ saveMemory(db, makeMemory({ tags: ["auth"], intent: "Add refresh token rotation to prevent replay attacks", decision: "Rotate tokens on every request", why: "One-time-use tokens prevent replay attacks" }));
76
+ saveMemory(db, makeMemory({ id: generateMemoryId(), tags: ["payments"], intent: "Add retry logic for payment gateway token failures in checkout", decision: "Retry three times with backoff", why: "Token failures are transient and retryable" }));
77
+
78
+ const results = searchMemory(db, { query: "token", tags: ["auth"] });
79
+ expect(results.length).toBe(1);
80
+ expect(results[0].memory.tags).toContain("auth");
81
+ });
82
+ });
83
+
84
+ describe("search — date filters", () => {
85
+ let db: Database.Database;
86
+ beforeEach(() => { db = makeTestDb(); });
87
+
88
+ it("--since filters out memories before the date", () => {
89
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-01-15T00:00:00.000Z" }));
90
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-06-15T00:00:00.000Z" }));
91
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2025-01-15T00:00:00.000Z" }));
92
+
93
+ const results = searchMemory(db, { query: "", since: "2024-06-01" });
94
+ expect(results.length).toBe(2);
95
+ for (const r of results) {
96
+ expect(new Date(r.memory.createdAt).getTime()).toBeGreaterThanOrEqual(new Date("2024-06-01").getTime());
97
+ }
98
+ });
99
+
100
+ it("--before filters out memories after the date", () => {
101
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-01-15T00:00:00.000Z" }));
102
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-06-15T00:00:00.000Z" }));
103
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2025-01-15T00:00:00.000Z" }));
104
+
105
+ const results = searchMemory(db, { query: "", before: "2024-12-31" });
106
+ expect(results.length).toBe(2);
107
+ for (const r of results) {
108
+ expect(new Date(r.memory.createdAt).getTime()).toBeLessThanOrEqual(new Date("2024-12-31").getTime());
109
+ }
110
+ });
111
+
112
+ it("--since and --before can be combined", () => {
113
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-01-15T00:00:00.000Z" }));
114
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-06-15T00:00:00.000Z" }));
115
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2025-01-15T00:00:00.000Z" }));
116
+
117
+ const results = searchMemory(db, { query: "", since: "2024-03-01", before: "2024-12-31" });
118
+ expect(results.length).toBe(1);
119
+ expect(results[0].memory.createdAt).toBe("2024-06-15T00:00:00.000Z");
120
+ });
121
+
122
+ it("returns all when no date filters given", () => {
123
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-01-15T00:00:00.000Z" }));
124
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2025-01-15T00:00:00.000Z" }));
125
+ const results = searchMemory(db, { query: "" });
126
+ expect(results.length).toBe(2);
127
+ });
128
+
129
+ it("date and tag filters can be combined", () => {
130
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-06-15T00:00:00.000Z", tags: ["auth"] }));
131
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2024-06-15T00:00:00.000Z", tags: ["payments"], intent: "Add payment gateway retry logic for failed transactions", decision: "Retry with exponential backoff", why: "Transient failures should not block checkout permanently" }));
132
+ saveMemory(db, makeMemory({ id: generateMemoryId(), createdAt: "2023-01-01T00:00:00.000Z", tags: ["auth"], intent: "Implement auth session expiry for security compliance", decision: "Expire sessions after 30 minutes idle", why: "Reduces attack surface for stolen session tokens" }));
133
+
134
+ const results = searchMemory(db, { query: "", tags: ["auth"], since: "2024-01-01" });
135
+ expect(results.length).toBe(1);
136
+ expect(results[0].memory.tags).toContain("auth");
137
+ expect(results[0].memory.createdAt).toBe("2024-06-15T00:00:00.000Z");
138
+ });
139
+ });
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { openDb } from "../src/db/connection.js";
3
+ import { runMigrations } from "../src/db/migrations.js";
4
+ import { saveMemory, generateMemoryId } from "../src/memory/saveMemory.js";
5
+ import { searchMemory, scoreMemory, explainRelevance } from "../src/memory/searchMemory.js";
6
+ import type { CodingMemory, ScoreBreakdown } from "../src/memory/types.js";
7
+ import Database from "better-sqlite3";
8
+
9
+ function makeTestDb(): Database.Database {
10
+ const db = openDb(":memory:");
11
+ runMigrations(db);
12
+ return db;
13
+ }
14
+
15
+ function makeMemory(overrides: Partial<CodingMemory> = {}): CodingMemory {
16
+ return {
17
+ id: generateMemoryId(),
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ repoId: "default-repo",
21
+ repoPath: "/home/user/default-app",
22
+ repoName: "default-app",
23
+ branch: "main",
24
+ commitSha: "abc123",
25
+ files: ["src/app.ts"],
26
+ tags: ["feature"],
27
+ intent: "Default intent",
28
+ summary: "Default summary",
29
+ decision: "Default decision",
30
+ why: "Default rationale",
31
+ alternativesRejected: [],
32
+ risks: [],
33
+ followUps: [],
34
+ source: "cli",
35
+ embeddingText: "Default intent Default summary Default decision Default rationale",
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe("scoreMemory", () => {
41
+ it("gives +10 for same repo", () => {
42
+ const memory = makeMemory({ repoId: "my-repo" });
43
+ const score = scoreMemory(memory, "query", "my-repo", []);
44
+ expect(score.sameRepo).toBe(10);
45
+ });
46
+
47
+ it("gives 0 for different repo", () => {
48
+ const memory = makeMemory({ repoId: "other-repo" });
49
+ const score = scoreMemory(memory, "query", "my-repo", []);
50
+ expect(score.sameRepo).toBe(0);
51
+ });
52
+
53
+ it("gives +8 for file overlap", () => {
54
+ const memory = makeMemory({ files: ["src/auth.ts"] });
55
+ const score = scoreMemory(memory, "query", null, ["src/auth.ts"]);
56
+ expect(score.fileOverlap).toBe(8);
57
+ });
58
+
59
+ it("gives +5 for tag match", () => {
60
+ const memory = makeMemory({ tags: ["optimistic-ui"] });
61
+ const score = scoreMemory(memory, "optimistic", null, []);
62
+ expect(score.tagMatch).toBe(5);
63
+ });
64
+
65
+ it("gives +4 for keyword in decision", () => {
66
+ const memory = makeMemory({ decision: "Use optimistic rendering" });
67
+ const score = scoreMemory(memory, "optimistic", null, []);
68
+ expect(score.decisionMatch).toBe(4);
69
+ });
70
+
71
+ it("gives +3 for keyword in why", () => {
72
+ const memory = makeMemory({ why: "Server was too slow" });
73
+ const score = scoreMemory(memory, "slow", null, []);
74
+ expect(score.whyMatch).toBe(3);
75
+ });
76
+
77
+ it("gives +2 for keyword in summary", () => {
78
+ const memory = makeMemory({ summary: "Implemented caching layer" });
79
+ const score = scoreMemory(memory, "caching", null, []);
80
+ expect(score.summaryMatch).toBe(2);
81
+ });
82
+
83
+ it("gives +2 for keyword in file path", () => {
84
+ const memory = makeMemory({ files: ["src/comments/sync.ts"] });
85
+ const score = scoreMemory(memory, "comments", null, []);
86
+ expect(score.filePathMatch).toBe(2);
87
+ });
88
+
89
+ it("gives +1 recency for recent memory", () => {
90
+ const memory = makeMemory({ createdAt: new Date().toISOString() });
91
+ const score = scoreMemory(memory, "query", null, []);
92
+ expect(score.recency).toBe(1);
93
+ });
94
+
95
+ it("gives 0 recency for old memory", () => {
96
+ const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
97
+ const memory = makeMemory({ createdAt: oldDate });
98
+ const score = scoreMemory(memory, "query", null, []);
99
+ expect(score.recency).toBe(0);
100
+ });
101
+
102
+ it("computes total as sum of all components", () => {
103
+ const memory = makeMemory({
104
+ repoId: "my-repo",
105
+ tags: ["comments"],
106
+ decision: "Use optimistic rendering",
107
+ files: ["src/comments/sync.ts"],
108
+ createdAt: new Date().toISOString(),
109
+ });
110
+ const score = scoreMemory(memory, "comments optimistic", "my-repo", []);
111
+ expect(score.total).toBe(
112
+ score.sameRepo +
113
+ score.fileOverlap +
114
+ score.tagMatch +
115
+ score.decisionMatch +
116
+ score.whyMatch +
117
+ score.summaryMatch +
118
+ score.filePathMatch +
119
+ score.recency
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("explainRelevance", () => {
125
+ it("returns non-empty string for any non-zero score", () => {
126
+ const score: ScoreBreakdown = {
127
+ total: 10,
128
+ sameRepo: 10,
129
+ fileOverlap: 0,
130
+ tagMatch: 0,
131
+ decisionMatch: 0,
132
+ whyMatch: 0,
133
+ summaryMatch: 0,
134
+ filePathMatch: 0,
135
+ recency: 0,
136
+ };
137
+ expect(explainRelevance(score)).toContain("same repo");
138
+ });
139
+
140
+ it("returns 'No specific match factors' for zero score", () => {
141
+ const score: ScoreBreakdown = {
142
+ total: 0,
143
+ sameRepo: 0,
144
+ fileOverlap: 0,
145
+ tagMatch: 0,
146
+ decisionMatch: 0,
147
+ whyMatch: 0,
148
+ summaryMatch: 0,
149
+ filePathMatch: 0,
150
+ recency: 0,
151
+ };
152
+ expect(explainRelevance(score)).toBe("No specific match factors");
153
+ });
154
+
155
+ it("includes all non-zero factors in the explanation", () => {
156
+ const score: ScoreBreakdown = {
157
+ total: 15,
158
+ sameRepo: 10,
159
+ fileOverlap: 0,
160
+ tagMatch: 5,
161
+ decisionMatch: 0,
162
+ whyMatch: 0,
163
+ summaryMatch: 0,
164
+ filePathMatch: 0,
165
+ recency: 0,
166
+ };
167
+ const reason = explainRelevance(score);
168
+ expect(reason).toContain("same repo");
169
+ expect(reason).toContain("tag match");
170
+ });
171
+ });
172
+
173
+ describe("searchMemory", () => {
174
+ let db: Database.Database;
175
+
176
+ beforeEach(() => {
177
+ db = makeTestDb();
178
+ // Insert 4 memories for testing
179
+ saveMemory(
180
+ db,
181
+ makeMemory({
182
+ repoId: "my-repo",
183
+ tags: ["optimistic-ui"],
184
+ decision: "Use optimistic rendering for comments",
185
+ why: "Server wait was too slow",
186
+ files: ["src/comments/sync.ts"],
187
+ commitSha: "commit1",
188
+ })
189
+ );
190
+ saveMemory(
191
+ db,
192
+ makeMemory({
193
+ repoId: "my-repo",
194
+ tags: ["auth"],
195
+ decision: "Use JWT tokens for auth",
196
+ why: "Simple and stateless",
197
+ files: ["src/auth/session.ts"],
198
+ commitSha: "commit2",
199
+ })
200
+ );
201
+ saveMemory(
202
+ db,
203
+ makeMemory({
204
+ repoId: "other-repo",
205
+ tags: ["caching"],
206
+ decision: "Redis cache for hot data",
207
+ why: "DB was bottleneck",
208
+ files: ["src/cache.ts"],
209
+ commitSha: "commit3",
210
+ })
211
+ );
212
+ saveMemory(
213
+ db,
214
+ makeMemory({
215
+ repoId: "my-repo",
216
+ tags: ["checkout"],
217
+ decision: "Validate cart before checkout",
218
+ why: "Prevent invalid orders",
219
+ files: ["src/checkout.ts"],
220
+ commitSha: "commit4",
221
+ })
222
+ );
223
+ });
224
+
225
+ it("returns results matching the query keyword", () => {
226
+ const results = searchMemory(db, { query: "optimistic", repoId: "my-repo" });
227
+ expect(results.length).toBeGreaterThan(0);
228
+ expect(results[0].memory.tags).toContain("optimistic-ui");
229
+ });
230
+
231
+ it("ranks same-repo memories higher than cross-repo memories", () => {
232
+ const results = searchMemory(db, { query: "cache", repoId: "my-repo" });
233
+ const repoIds = results.map((r) => r.memory.repoId);
234
+ if (repoIds.includes("my-repo") && repoIds.includes("other-repo")) {
235
+ const myRepoIdx = repoIds.indexOf("my-repo");
236
+ const otherRepoIdx = repoIds.indexOf("other-repo");
237
+ expect(myRepoIdx).toBeLessThan(otherRepoIdx);
238
+ }
239
+ });
240
+
241
+ it("respects --limit", () => {
242
+ const results = searchMemory(db, { query: "", repoId: "my-repo", limit: 2 });
243
+ expect(results.length).toBeLessThanOrEqual(2);
244
+ });
245
+
246
+ it("returns empty array when no memories match query", () => {
247
+ const results = searchMemory(db, { query: "xyznonexistentterm", repoId: "my-repo" });
248
+ expect(results.length).toBe(0);
249
+ });
250
+
251
+ it("falls back to repoPath search when repoId yields no results", () => {
252
+ const results = searchMemory(db, {
253
+ query: "optimistic",
254
+ repoId: "unknown-repo-id",
255
+ repoPath: "/home/user/default-app",
256
+ });
257
+ expect(results.length).toBeGreaterThanOrEqual(0);
258
+ });
259
+
260
+ it("includes relevanceReason in each result", () => {
261
+ const results = searchMemory(db, { query: "optimistic", repoId: "my-repo" });
262
+ for (const result of results) {
263
+ expect(result.relevanceReason).toBeTruthy();
264
+ }
265
+ });
266
+
267
+ it("results are sorted by score descending", () => {
268
+ const results = searchMemory(db, { query: "optimistic comments", repoId: "my-repo" });
269
+ for (let i = 1; i < results.length; i++) {
270
+ expect(results[i - 1].score.total).toBeGreaterThanOrEqual(results[i].score.total);
271
+ }
272
+ });
273
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isStale, STALE_THRESHOLD_DAYS } from "../src/memory/searchMemory.js";
3
+ import type { CodingMemory } from "../src/memory/types.js";
4
+
5
+ function makeMemory(daysAgo: number): CodingMemory {
6
+ const createdAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString();
7
+ return {
8
+ id: "mem_test",
9
+ createdAt,
10
+ updatedAt: createdAt,
11
+ repoId: "repo1",
12
+ files: [],
13
+ tags: [],
14
+ intent: "test",
15
+ summary: "test",
16
+ decision: "test",
17
+ why: "test",
18
+ alternativesRejected: [],
19
+ risks: [],
20
+ followUps: [],
21
+ source: "cli",
22
+ embeddingText: "",
23
+ };
24
+ }
25
+
26
+ describe("isStale", () => {
27
+ it("returns false for a memory created today", () => {
28
+ expect(isStale(makeMemory(0))).toBe(false);
29
+ });
30
+
31
+ it("returns false for a memory 89 days old", () => {
32
+ expect(isStale(makeMemory(89))).toBe(false);
33
+ });
34
+
35
+ it("returns true for a memory exactly at threshold", () => {
36
+ expect(isStale(makeMemory(STALE_THRESHOLD_DAYS + 1))).toBe(true);
37
+ });
38
+
39
+ it("returns true for a memory 180 days old", () => {
40
+ expect(isStale(makeMemory(180))).toBe(true);
41
+ });
42
+
43
+ it("respects a custom threshold", () => {
44
+ expect(isStale(makeMemory(10), 7)).toBe(true);
45
+ expect(isStale(makeMemory(5), 7)).toBe(false);
46
+ });
47
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "sourceMap": true,
13
+ "resolveJsonModule": true,
14
+ "types": ["node"]
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist", "tests"]
18
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["tests/**/*.test.ts"],
7
+ },
8
+ });