@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,326 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import { resolveConfig } from "../config.js";
8
+ import { openDb } from "../db/connection.js";
9
+ import { searchMemory, isStale } from "../memory/searchMemory.js";
10
+ import {
11
+ saveMemory,
12
+ generateMemoryId,
13
+ buildEmbeddingText,
14
+ getMemoriesByCommit,
15
+ getMemoriesByFile,
16
+ listMemories,
17
+ } from "../memory/saveMemory.js";
18
+ import { getRepoId } from "../git/repoId.js";
19
+ import { getFileRenameHistory } from "../git/diff.js";
20
+ import { redactSecrets } from "../memory/redactSecrets.js";
21
+ import { checkQuality, checkDuplicates } from "../memory/qualityCheck.js";
22
+ import {
23
+ SearchMemoryInput,
24
+ SaveMemoryInput,
25
+ GetCommitMemoryInput,
26
+ GetFileMemoriesInput,
27
+ GetRecentMemoriesInput,
28
+ } from "./tools.js";
29
+ import type { CodingMemory } from "../memory/types.js";
30
+
31
+ function resolveRepoId(repoPath?: string, repoId?: string): string | undefined {
32
+ if (repoId) return repoId;
33
+ if (repoPath) {
34
+ try {
35
+ return getRepoId(repoPath);
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ export async function createMcpServer(): Promise<void> {
44
+ const server = new Server(
45
+ { name: "whyline", version: "0.1.0" },
46
+ { capabilities: { tools: {} } }
47
+ );
48
+
49
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
50
+ tools: [
51
+ {
52
+ name: "search_coding_memory",
53
+ description:
54
+ "Search stored coding memories by keyword. Returns relevant memories with reasoning about why and tradeoffs made.",
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ query: { type: "string", description: "Search query" },
59
+ repoPath: { type: "string", description: "Absolute path to the git repository" },
60
+ repoId: { type: "string", description: "Repository ID (hash)" },
61
+ files: {
62
+ type: "array",
63
+ items: { type: "string" },
64
+ description: "Filter by file paths",
65
+ },
66
+ tags: {
67
+ type: "array",
68
+ items: { type: "string" },
69
+ description: "Filter by tags (memory must have all listed tags)",
70
+ },
71
+ since: { type: "string", description: "ISO date — only memories created after this (e.g. 2025-01-01)" },
72
+ before: { type: "string", description: "ISO date — only memories created before this (e.g. 2025-12-31)" },
73
+ limit: { type: "number", description: "Max results (default 10)" },
74
+ },
75
+ required: ["query"],
76
+ },
77
+ },
78
+ {
79
+ name: "save_coding_memory",
80
+ description: "Save a new coding memory with reasoning, decisions, and context. Returns warnings if fields are too short, contain filler text, or a similar memory already exists — surface these warnings to the user so they can enrich the memory.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ repoPath: { type: "string" },
85
+ commitSha: { type: "string" },
86
+ files: { type: "array", items: { type: "string" } },
87
+ task: { type: "string" },
88
+ intent: { type: "string" },
89
+ summary: { type: "string" },
90
+ decision: { type: "string" },
91
+ why: { type: "string" },
92
+ alternativesRejected: { type: "array", items: { type: "string" } },
93
+ risks: { type: "array", items: { type: "string" } },
94
+ followUps: { type: "array", items: { type: "string" } },
95
+ tags: { type: "array", items: { type: "string" } },
96
+ source: {
97
+ type: "string",
98
+ enum: ["manual", "claude-code", "cli", "hook"],
99
+ },
100
+ },
101
+ required: ["files", "intent", "summary", "decision", "why"],
102
+ },
103
+ },
104
+ {
105
+ name: "get_commit_memory",
106
+ description: "Get coding memories associated with a specific git commit.",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ commitSha: { type: "string" },
111
+ repoPath: { type: "string" },
112
+ repoId: { type: "string" },
113
+ },
114
+ required: ["commitSha"],
115
+ },
116
+ },
117
+ {
118
+ name: "get_file_memories",
119
+ description: "Get coding memories that touch a specific file path.",
120
+ inputSchema: {
121
+ type: "object",
122
+ properties: {
123
+ filePath: { type: "string" },
124
+ repoPath: { type: "string" },
125
+ repoId: { type: "string" },
126
+ limit: { type: "number" },
127
+ },
128
+ required: ["filePath"],
129
+ },
130
+ },
131
+ {
132
+ name: "get_recent_memories",
133
+ description: "Get the most recent coding memories for a repo without requiring a search query. Use this at session start when the task is not yet defined, to surface relevant recent context.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ repoPath: { type: "string", description: "Absolute path to the git repository" },
138
+ repoId: { type: "string", description: "Repository ID (hash)" },
139
+ limit: { type: "number", description: "Max results (default 5)" },
140
+ },
141
+ required: [],
142
+ },
143
+ },
144
+ ],
145
+ }));
146
+
147
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
148
+ const db = openDb(resolveConfig().storage.dbPath);
149
+ try {
150
+ switch (request.params.name) {
151
+ case "search_coding_memory": {
152
+ const input = SearchMemoryInput.parse(request.params.arguments);
153
+ const resolvedRepoId = resolveRepoId(input.repoPath, input.repoId);
154
+ const results = searchMemory(db, {
155
+ query: input.query,
156
+ repoId: resolvedRepoId,
157
+ repoPath: input.repoPath,
158
+ files: input.files,
159
+ tags: input.tags,
160
+ since: input.since,
161
+ before: input.before,
162
+ limit: input.limit,
163
+ });
164
+ return {
165
+ content: [
166
+ {
167
+ type: "text",
168
+ text: JSON.stringify({
169
+ memories: results.map((r) => ({
170
+ id: r.memory.id,
171
+ commitSha: r.memory.commitSha,
172
+ createdAt: r.memory.createdAt,
173
+ files: r.memory.files,
174
+ intent: r.memory.intent,
175
+ summary: r.memory.summary,
176
+ decision: r.memory.decision,
177
+ why: r.memory.why,
178
+ alternativesRejected: r.memory.alternativesRejected,
179
+ risks: r.memory.risks,
180
+ followUps: r.memory.followUps,
181
+ tags: r.memory.tags,
182
+ relevanceReason: r.relevanceReason,
183
+ isStale: isStale(r.memory),
184
+ })),
185
+ }),
186
+ },
187
+ ],
188
+ };
189
+ }
190
+
191
+ case "save_coding_memory": {
192
+ const input = SaveMemoryInput.parse(request.params.arguments);
193
+ const resolvedRepoId = resolveRepoId(input.repoPath, undefined);
194
+ const now = new Date().toISOString();
195
+ const id = generateMemoryId();
196
+
197
+ const memory: CodingMemory = {
198
+ id,
199
+ createdAt: now,
200
+ updatedAt: now,
201
+ repoId: resolvedRepoId ?? "unknown",
202
+ repoPath: input.repoPath,
203
+ commitSha: input.commitSha,
204
+ files: input.files,
205
+ tags: input.tags.map(redactSecrets),
206
+ task: input.task ? redactSecrets(input.task) : undefined,
207
+ intent: redactSecrets(input.intent),
208
+ summary: redactSecrets(input.summary),
209
+ decision: redactSecrets(input.decision),
210
+ why: redactSecrets(input.why),
211
+ alternativesRejected: input.alternativesRejected.map(redactSecrets),
212
+ risks: input.risks.map(redactSecrets),
213
+ followUps: input.followUps.map(redactSecrets),
214
+ source: input.source,
215
+ embeddingText: "",
216
+ };
217
+ memory.embeddingText = buildEmbeddingText(memory);
218
+
219
+ const qualityWarnings = checkQuality(memory);
220
+ const duplicateWarnings = checkDuplicates(db, memory);
221
+
222
+ saveMemory(db, memory);
223
+ return {
224
+ content: [{
225
+ type: "text",
226
+ text: JSON.stringify({
227
+ id,
228
+ saved: true,
229
+ warnings: [
230
+ ...qualityWarnings.map((w) => w.message),
231
+ ...duplicateWarnings.map((w) => w.message),
232
+ ],
233
+ }),
234
+ }],
235
+ };
236
+ }
237
+
238
+ case "get_commit_memory": {
239
+ const input = GetCommitMemoryInput.parse(request.params.arguments);
240
+ const memories = getMemoriesByCommit(db, input.commitSha);
241
+ return {
242
+ content: [
243
+ {
244
+ type: "text",
245
+ text: JSON.stringify({
246
+ memories: memories.map((m) => ({
247
+ id: m.id,
248
+ intent: m.intent,
249
+ decision: m.decision,
250
+ why: m.why,
251
+ files: m.files,
252
+ risks: m.risks,
253
+ followUps: m.followUps,
254
+ })),
255
+ }),
256
+ },
257
+ ],
258
+ };
259
+ }
260
+
261
+ case "get_file_memories": {
262
+ const input = GetFileMemoriesInput.parse(request.params.arguments);
263
+ const resolvedRepoId = resolveRepoId(input.repoPath, input.repoId) ?? null;
264
+ const filePaths = input.repoPath
265
+ ? getFileRenameHistory(input.repoPath, input.filePath)
266
+ : [input.filePath];
267
+ const memories = getMemoriesByFile(db, resolvedRepoId, filePaths, input.limit);
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text",
272
+ text: JSON.stringify({
273
+ memories: memories.map((m) => ({
274
+ id: m.id,
275
+ commitSha: m.commitSha,
276
+ intent: m.intent,
277
+ decision: m.decision,
278
+ why: m.why,
279
+ risks: m.risks,
280
+ followUps: m.followUps,
281
+ })),
282
+ }),
283
+ },
284
+ ],
285
+ };
286
+ }
287
+
288
+ case "get_recent_memories": {
289
+ const input = GetRecentMemoriesInput.parse(request.params.arguments);
290
+ const resolvedRepoId = resolveRepoId(input.repoPath, input.repoId);
291
+ const memories = listMemories(db, { repoId: resolvedRepoId, limit: input.limit });
292
+ return {
293
+ content: [
294
+ {
295
+ type: "text",
296
+ text: JSON.stringify({
297
+ memories: memories.map((m) => ({
298
+ id: m.id,
299
+ commitSha: m.commitSha,
300
+ createdAt: m.createdAt,
301
+ files: m.files,
302
+ intent: m.intent,
303
+ decision: m.decision,
304
+ why: m.why,
305
+ risks: m.risks,
306
+ followUps: m.followUps,
307
+ tags: m.tags,
308
+ isStale: isStale(m),
309
+ })),
310
+ }),
311
+ },
312
+ ],
313
+ };
314
+ }
315
+
316
+ default:
317
+ throw new Error(`Unknown tool: ${request.params.name}`);
318
+ }
319
+ } finally {
320
+ db.close();
321
+ }
322
+ });
323
+
324
+ const transport = new StdioServerTransport();
325
+ await server.connect(transport);
326
+ }
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+
3
+ export const SearchMemoryInput = z.object({
4
+ repoPath: z.string().optional(),
5
+ repoId: z.string().optional(),
6
+ query: z.string(),
7
+ files: z.array(z.string()).optional(),
8
+ tags: z.array(z.string()).optional(),
9
+ since: z.string().optional(),
10
+ before: z.string().optional(),
11
+ limit: z.number().int().positive().default(10),
12
+ });
13
+
14
+ export const SaveMemoryInput = z.object({
15
+ repoPath: z.string().optional(),
16
+ commitSha: z.string().optional(),
17
+ files: z.array(z.string()),
18
+ task: z.string().optional(),
19
+ intent: z.string(),
20
+ summary: z.string(),
21
+ decision: z.string(),
22
+ why: z.string(),
23
+ alternativesRejected: z.array(z.string()).default([]),
24
+ risks: z.array(z.string()).default([]),
25
+ followUps: z.array(z.string()).default([]),
26
+ tags: z.array(z.string()).default([]),
27
+ source: z.enum(["manual", "claude-code", "cli", "hook"]).default("claude-code"),
28
+ });
29
+
30
+ export const GetCommitMemoryInput = z.object({
31
+ repoPath: z.string().optional(),
32
+ repoId: z.string().optional(),
33
+ commitSha: z.string(),
34
+ });
35
+
36
+ export const GetFileMemoriesInput = z.object({
37
+ repoPath: z.string().optional(),
38
+ repoId: z.string().optional(),
39
+ filePath: z.string(),
40
+ limit: z.number().int().positive().default(10),
41
+ });
42
+
43
+ export const GetRecentMemoriesInput = z.object({
44
+ repoPath: z.string().optional(),
45
+ repoId: z.string().optional(),
46
+ limit: z.number().int().positive().default(5),
47
+ });
48
+
49
+ export type SearchMemoryInputType = z.infer<typeof SearchMemoryInput>;
50
+ export type SaveMemoryInputType = z.infer<typeof SaveMemoryInput>;
51
+ export type GetCommitMemoryInputType = z.infer<typeof GetCommitMemoryInput>;
52
+ export type GetFileMemoriesInputType = z.infer<typeof GetFileMemoriesInput>;
53
+ export type GetRecentMemoriesInputType = z.infer<typeof GetRecentMemoriesInput>;
@@ -0,0 +1,72 @@
1
+ export type ParsedSummary = {
2
+ task?: string;
3
+ intent: string;
4
+ summary: string;
5
+ decision: string;
6
+ why: string;
7
+ alternativesRejected: string[];
8
+ risks: string[];
9
+ followUps: string[];
10
+ tags: string[];
11
+ };
12
+
13
+ const HEADING_REGEX = /^([A-Za-z][A-Za-z\- ]+):\s*$/m;
14
+
15
+ function extractSections(text: string): Record<string, string> {
16
+ const lines = text.split("\n");
17
+ const sections: Record<string, string> = {};
18
+ let currentHeading: string | null = null;
19
+ const buffer: string[] = [];
20
+
21
+ const flush = () => {
22
+ if (currentHeading !== null) {
23
+ sections[currentHeading] = buffer.join("\n").trim();
24
+ buffer.length = 0;
25
+ }
26
+ };
27
+
28
+ for (const line of lines) {
29
+ const match = line.match(/^([A-Za-z][A-Za-z\- ]+):\s*$/);
30
+ if (match) {
31
+ flush();
32
+ currentHeading = match[1].toLowerCase();
33
+ } else {
34
+ if (currentHeading !== null) {
35
+ buffer.push(line);
36
+ }
37
+ }
38
+ }
39
+ flush();
40
+
41
+ return sections;
42
+ }
43
+
44
+ function parseBullets(text: string): string[] {
45
+ return text
46
+ .split("\n")
47
+ .map((l) => l.replace(/^[-*]\s*/, "").trim())
48
+ .filter(Boolean);
49
+ }
50
+
51
+ export function parseSummary(markdown: string): ParsedSummary {
52
+ // Remove markdown h1 title lines before parsing sections
53
+ const text = markdown.replace(/^#[^#].*$/m, "").trim();
54
+ const sections = extractSections(text);
55
+
56
+ const get = (key: string): string | undefined => sections[key];
57
+
58
+ return {
59
+ task: get("task")?.trim() || undefined,
60
+ intent: get("intent")?.trim() || "Unspecified intent",
61
+ summary: get("summary")?.trim() || markdown.trim(),
62
+ decision: get("decision")?.trim() || "Unspecified decision",
63
+ why: get("why")?.trim() || "Unspecified rationale",
64
+ alternativesRejected: parseBullets(get("alternatives rejected") ?? ""),
65
+ risks: parseBullets(get("risks") ?? ""),
66
+ followUps: parseBullets(get("follow-ups") ?? get("follow ups") ?? ""),
67
+ tags: parseBullets(get("tags") ?? ""),
68
+ };
69
+ }
70
+
71
+ // Keep HEADING_REGEX exported for potential reuse
72
+ export { HEADING_REGEX };
@@ -0,0 +1,102 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { CodingMemory } from "./types.js";
3
+ import { getMemoriesByRepoId } from "./saveMemory.js";
4
+
5
+ const FILLER_PHRASES = [
6
+ "done", "fixed", "fixed it", "updated", "changed", "refactored",
7
+ "wip", "misc", "test", "testing", "cleanup", "fix", "update",
8
+ "unspecified intent", "unspecified decision", "unspecified rationale",
9
+ ];
10
+
11
+ const MIN_FIELD_LENGTH = 20;
12
+
13
+ export type QualityWarning = {
14
+ field: string;
15
+ message: string;
16
+ };
17
+
18
+ export type DuplicateWarning = {
19
+ type: "same-commit" | "similar-intent";
20
+ existingId: string;
21
+ message: string;
22
+ };
23
+
24
+ export function checkQuality(memory: Pick<CodingMemory, "intent" | "decision" | "why">): QualityWarning[] {
25
+ const warnings: QualityWarning[] = [];
26
+
27
+ const checks: Array<{ field: string; value: string }> = [
28
+ { field: "intent", value: memory.intent },
29
+ { field: "decision", value: memory.decision },
30
+ { field: "why", value: memory.why },
31
+ ];
32
+
33
+ for (const { field, value } of checks) {
34
+ const trimmed = value.trim().toLowerCase();
35
+
36
+ if (trimmed.length < MIN_FIELD_LENGTH) {
37
+ warnings.push({
38
+ field,
39
+ message: `"${field}" is very short (${trimmed.length} chars) — future searches may not find this memory`,
40
+ });
41
+ continue;
42
+ }
43
+
44
+ if (FILLER_PHRASES.some((phrase) => trimmed === phrase || trimmed.startsWith(phrase + " "))) {
45
+ warnings.push({
46
+ field,
47
+ message: `"${field}" looks like filler text ("${value.trim()}") — consider adding more detail`,
48
+ });
49
+ }
50
+ }
51
+
52
+ return warnings;
53
+ }
54
+
55
+ function jaccardSimilarity(a: string, b: string): number {
56
+ const setA = new Set(a.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
57
+ const setB = new Set(b.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
58
+ if (setA.size === 0 && setB.size === 0) return 1;
59
+ if (setA.size === 0 || setB.size === 0) return 0;
60
+ const intersection = [...setA].filter((w) => setB.has(w)).length;
61
+ const union = new Set([...setA, ...setB]).size;
62
+ return intersection / union;
63
+ }
64
+
65
+ export function checkDuplicates(
66
+ db: Database.Database,
67
+ memory: Pick<CodingMemory, "repoId" | "commitSha" | "files" | "intent">
68
+ ): DuplicateWarning[] {
69
+ const warnings: DuplicateWarning[] = [];
70
+ const existing = getMemoriesByRepoId(db, memory.repoId);
71
+
72
+ // Same commit SHA
73
+ if (memory.commitSha) {
74
+ const sameCommit = existing.find((m) => m.commitSha === memory.commitSha);
75
+ if (sameCommit) {
76
+ warnings.push({
77
+ type: "same-commit",
78
+ existingId: sameCommit.id,
79
+ message: `A memory already exists for commit ${memory.commitSha.slice(0, 8)} (${sameCommit.id}) — saving anyway but you may want to delete the old one`,
80
+ });
81
+ }
82
+ }
83
+
84
+ // Similar intent + overlapping files
85
+ const fileSet = new Set(memory.files);
86
+ for (const m of existing) {
87
+ if (memory.commitSha && m.commitSha === memory.commitSha) continue; // already caught above
88
+ const fileOverlap = m.files.filter((f) => fileSet.has(f)).length;
89
+ if (fileOverlap === 0) continue;
90
+ const similarity = jaccardSimilarity(memory.intent, m.intent);
91
+ if (similarity >= 0.4) {
92
+ warnings.push({
93
+ type: "similar-intent",
94
+ existingId: m.id,
95
+ message: `A similar memory exists (${m.id}) with ${Math.round(similarity * 100)}% intent similarity on the same files — consider editing it instead`,
96
+ });
97
+ break;
98
+ }
99
+ }
100
+
101
+ return warnings;
102
+ }
@@ -0,0 +1,32 @@
1
+ type SecretPattern = { name: string; pattern: RegExp };
2
+
3
+ export const SECRET_PATTERNS: SecretPattern[] = [
4
+ { name: "github_token", pattern: /ghp_[A-Za-z0-9]{36}/g },
5
+ { name: "github_oauth", pattern: /gho_[A-Za-z0-9]{36}/g },
6
+ { name: "github_app_token", pattern: /ghs_[A-Za-z0-9]{36}/g },
7
+ { name: "npm_token", pattern: /npm_[A-Za-z0-9]{36}/g },
8
+ { name: "aws_access_key", pattern: /AKIA[0-9A-Z]{16}/g },
9
+ {
10
+ name: "bearer_token",
11
+ pattern: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
12
+ },
13
+ {
14
+ name: "dotenv_secret",
15
+ pattern:
16
+ /(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY|ACCESS_KEY|AUTH_KEY)\s*=\s*\S+/gi,
17
+ },
18
+ {
19
+ name: "private_key_block",
20
+ pattern: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g,
21
+ },
22
+ ];
23
+
24
+ export function redactSecrets(input: string): string {
25
+ let output = input;
26
+ for (const { pattern } of SECRET_PATTERNS) {
27
+ // Reset lastIndex for global regexes to avoid stateful bugs
28
+ pattern.lastIndex = 0;
29
+ output = output.replace(pattern, "[REDACTED_SECRET]");
30
+ }
31
+ return output;
32
+ }
@@ -0,0 +1,25 @@
1
+ import { getRepoRoot, getCurrentBranch, resolveCommit } from "../git/git.js";
2
+ import { getRepoId, getRepoName } from "../git/repoId.js";
3
+ import { getChangedFilesForCommit } from "../git/diff.js";
4
+ import type { RepoContext } from "./types.js";
5
+
6
+ export function getRepoContext(cwd: string, commitRef: string): RepoContext {
7
+ const repoRoot = getRepoRoot(cwd);
8
+ if (!repoRoot) throw new Error("Not inside a git repository");
9
+
10
+ const commitSha = resolveCommit(repoRoot, commitRef);
11
+ const repoId = getRepoId(repoRoot);
12
+ const repoName = getRepoName(repoRoot);
13
+ const branch = getCurrentBranch(repoRoot);
14
+ const changedFiles = getChangedFilesForCommit(repoRoot, commitSha);
15
+
16
+ return {
17
+ repoRoot,
18
+ repoId,
19
+ repoPath: repoRoot,
20
+ repoName,
21
+ branch,
22
+ commitSha,
23
+ changedFiles,
24
+ };
25
+ }