@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.
- package/.claude/settings.local.json +33 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +37 -0
- package/.prettierrc.json +7 -0
- package/CLAUDE.md +74 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +125 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/delete.d.ts +3 -0
- package/dist/commands/delete.js +42 -0
- package/dist/commands/delete.js.map +1 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +111 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/edit.d.ts +1 -0
- package/dist/commands/edit.js +78 -0
- package/dist/commands/edit.js.map +1 -0
- package/dist/commands/export.d.ts +8 -0
- package/dist/commands/export.js +90 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/import.d.ts +1 -0
- package/dist/commands/import.js +110 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +23 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install-claude.d.ts +3 -0
- package/dist/commands/install-claude.js +180 -0
- package/dist/commands/install-claude.js.map +1 -0
- package/dist/commands/list.d.ts +4 -0
- package/dist/commands/list.js +35 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/mcp.d.ts +1 -0
- package/dist/commands/mcp.js +10 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/save.d.ts +4 -0
- package/dist/commands/save.js +74 -0
- package/dist/commands/save.js.map +1 -0
- package/dist/commands/search.d.ts +7 -0
- package/dist/commands/search.js +46 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/show.d.ts +3 -0
- package/dist/commands/show.js +30 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/stats.d.ts +1 -0
- package/dist/commands/stats.js +27 -0
- package/dist/commands/stats.js.map +1 -0
- package/dist/commands/summarize.d.ts +3 -0
- package/dist/commands/summarize.js +140 -0
- package/dist/commands/summarize.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +17 -0
- package/dist/config.js.map +1 -0
- package/dist/db/connection.d.ts +2 -0
- package/dist/db/connection.js +8 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.js +19 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema.d.ts +5 -0
- package/dist/db/schema.js +64 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/git/diff.d.ts +2 -0
- package/dist/git/diff.js +45 -0
- package/dist/git/diff.js.map +1 -0
- package/dist/git/git.d.ts +3 -0
- package/dist/git/git.js +25 -0
- package/dist/git/git.js.map +1 -0
- package/dist/git/repoId.d.ts +3 -0
- package/dist/git/repoId.js +49 -0
- package/dist/git/repoId.js.map +1 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +296 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +119 -0
- package/dist/mcp/tools.js +43 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/memory/parseSummary.d.ts +14 -0
- package/dist/memory/parseSummary.js +53 -0
- package/dist/memory/parseSummary.js.map +1 -0
- package/dist/memory/qualityCheck.d.ts +13 -0
- package/dist/memory/qualityCheck.js +78 -0
- package/dist/memory/qualityCheck.js.map +1 -0
- package/dist/memory/redactSecrets.d.ts +7 -0
- package/dist/memory/redactSecrets.js +29 -0
- package/dist/memory/redactSecrets.js.map +1 -0
- package/dist/memory/repoContext.d.ts +2 -0
- package/dist/memory/repoContext.js +23 -0
- package/dist/memory/repoContext.js.map +1 -0
- package/dist/memory/saveMemory.d.ts +40 -0
- package/dist/memory/saveMemory.js +223 -0
- package/dist/memory/saveMemory.js.map +1 -0
- package/dist/memory/searchMemory.d.ts +17 -0
- package/dist/memory/searchMemory.js +122 -0
- package/dist/memory/searchMemory.js.map +1 -0
- package/dist/memory/types.d.ts +48 -0
- package/dist/memory/types.js +2 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/output/format.d.ts +3 -0
- package/dist/output/format.js +43 -0
- package/dist/output/format.js.map +1 -0
- package/docs/architecture.md +387 -0
- package/docs/ec6ab3bf-60cf-4629-ad9e-3048e8e3c43a.png +0 -0
- package/docs/logo.png +0 -0
- package/eslint.config.js +16 -0
- package/how-to-run/01-install.md +69 -0
- package/how-to-run/02-wire-up-your-repo.md +80 -0
- package/how-to-run/03-test-it-manually.md +91 -0
- package/how-to-run/04-test-with-claude-code.md +70 -0
- package/how-to-run/CLAUDE.md.template +72 -0
- package/how-to-run/README.md +49 -0
- package/package.json +60 -0
- package/src/cli.ts +142 -0
- package/src/commands/delete.ts +47 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/edit.ts +80 -0
- package/src/commands/export.ts +95 -0
- package/src/commands/import.ts +119 -0
- package/src/commands/init.ts +31 -0
- package/src/commands/install-claude.ts +203 -0
- package/src/commands/list.ts +41 -0
- package/src/commands/mcp.ts +12 -0
- package/src/commands/save.ts +85 -0
- package/src/commands/search.ts +56 -0
- package/src/commands/show.ts +37 -0
- package/src/commands/stats.ts +31 -0
- package/src/commands/summarize.ts +183 -0
- package/src/config.ts +26 -0
- package/src/db/connection.ts +8 -0
- package/src/db/migrations.ts +26 -0
- package/src/db/schema.ts +68 -0
- package/src/git/diff.ts +43 -0
- package/src/git/git.ts +25 -0
- package/src/git/repoId.ts +49 -0
- package/src/hooks/post-commit.sample.sh +9 -0
- package/src/mcp/server.ts +326 -0
- package/src/mcp/tools.ts +53 -0
- package/src/memory/parseSummary.ts +72 -0
- package/src/memory/qualityCheck.ts +102 -0
- package/src/memory/redactSecrets.ts +32 -0
- package/src/memory/repoContext.ts +25 -0
- package/src/memory/saveMemory.ts +369 -0
- package/src/memory/searchMemory.ts +153 -0
- package/src/memory/types.ts +57 -0
- package/src/output/format.ts +44 -0
- package/src/skill/SKILL.md +95 -0
- package/tests/cliV02.test.ts +213 -0
- package/tests/doctor.test.ts +253 -0
- package/tests/exportImport.test.ts +248 -0
- package/tests/fileRename.test.ts +156 -0
- package/tests/gitHelpers.test.ts +94 -0
- package/tests/init.test.ts +93 -0
- package/tests/installClaude.test.ts +157 -0
- package/tests/parseSummary.test.ts +111 -0
- package/tests/qualityCheck.test.ts +182 -0
- package/tests/redactSecrets.test.ts +75 -0
- package/tests/saveMemory.test.ts +196 -0
- package/tests/searchFilters.test.ts +139 -0
- package/tests/searchMemory.test.ts +273 -0
- package/tests/stale.test.ts +47 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import type { CodingMemory } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function generateMemoryId(): string {
|
|
6
|
+
const timestamp = Date.now().toString(36);
|
|
7
|
+
const random = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
|
|
8
|
+
return `mem_${timestamp}${random}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildEmbeddingText(
|
|
12
|
+
memory: Pick<
|
|
13
|
+
CodingMemory,
|
|
14
|
+
| "intent"
|
|
15
|
+
| "summary"
|
|
16
|
+
| "decision"
|
|
17
|
+
| "why"
|
|
18
|
+
| "alternativesRejected"
|
|
19
|
+
| "risks"
|
|
20
|
+
| "followUps"
|
|
21
|
+
| "tags"
|
|
22
|
+
| "files"
|
|
23
|
+
| "commitSha"
|
|
24
|
+
>
|
|
25
|
+
): string {
|
|
26
|
+
return [
|
|
27
|
+
memory.intent,
|
|
28
|
+
memory.summary,
|
|
29
|
+
memory.decision,
|
|
30
|
+
memory.why,
|
|
31
|
+
...memory.alternativesRejected,
|
|
32
|
+
...memory.risks,
|
|
33
|
+
...memory.followUps,
|
|
34
|
+
...memory.tags,
|
|
35
|
+
...memory.files,
|
|
36
|
+
memory.commitSha ?? "",
|
|
37
|
+
]
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join(" ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function saveMemory(db: Database.Database, memory: CodingMemory): void {
|
|
43
|
+
const insertMemory = db.prepare(`
|
|
44
|
+
INSERT INTO memories (
|
|
45
|
+
id, created_at, updated_at,
|
|
46
|
+
repo_id, repo_path, repo_name, branch, commit_sha,
|
|
47
|
+
task, intent, summary, decision, why,
|
|
48
|
+
source, raw_transcript_path, embedding_text
|
|
49
|
+
) VALUES (
|
|
50
|
+
?, ?, ?,
|
|
51
|
+
?, ?, ?, ?, ?,
|
|
52
|
+
?, ?, ?, ?, ?,
|
|
53
|
+
?, ?, ?
|
|
54
|
+
)
|
|
55
|
+
`);
|
|
56
|
+
|
|
57
|
+
const insertFile = db.prepare(
|
|
58
|
+
"INSERT OR IGNORE INTO memory_files (memory_id, file_path) VALUES (?, ?)"
|
|
59
|
+
);
|
|
60
|
+
const insertTag = db.prepare(
|
|
61
|
+
"INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)"
|
|
62
|
+
);
|
|
63
|
+
const insertAlt = db.prepare(
|
|
64
|
+
"INSERT OR IGNORE INTO memory_alternatives (memory_id, value) VALUES (?, ?)"
|
|
65
|
+
);
|
|
66
|
+
const insertRisk = db.prepare(
|
|
67
|
+
"INSERT OR IGNORE INTO memory_risks (memory_id, value) VALUES (?, ?)"
|
|
68
|
+
);
|
|
69
|
+
const insertFollowup = db.prepare(
|
|
70
|
+
"INSERT OR IGNORE INTO memory_followups (memory_id, value) VALUES (?, ?)"
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const run = db.transaction(() => {
|
|
74
|
+
insertMemory.run(
|
|
75
|
+
memory.id,
|
|
76
|
+
memory.createdAt,
|
|
77
|
+
memory.updatedAt,
|
|
78
|
+
memory.repoId,
|
|
79
|
+
memory.repoPath ?? null,
|
|
80
|
+
memory.repoName ?? null,
|
|
81
|
+
memory.branch ?? null,
|
|
82
|
+
memory.commitSha ?? null,
|
|
83
|
+
memory.task ?? null,
|
|
84
|
+
memory.intent,
|
|
85
|
+
memory.summary,
|
|
86
|
+
memory.decision,
|
|
87
|
+
memory.why,
|
|
88
|
+
memory.source,
|
|
89
|
+
memory.rawTranscriptPath ?? null,
|
|
90
|
+
memory.embeddingText
|
|
91
|
+
);
|
|
92
|
+
for (const f of memory.files) insertFile.run(memory.id, f);
|
|
93
|
+
for (const t of memory.tags) insertTag.run(memory.id, t);
|
|
94
|
+
for (const a of memory.alternativesRejected) insertAlt.run(memory.id, a);
|
|
95
|
+
for (const r of memory.risks) insertRisk.run(memory.id, r);
|
|
96
|
+
for (const fu of memory.followUps) insertFollowup.run(memory.id, fu);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
run();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type MemoryRow = {
|
|
103
|
+
id: string;
|
|
104
|
+
created_at: string;
|
|
105
|
+
updated_at: string;
|
|
106
|
+
repo_id: string;
|
|
107
|
+
repo_path: string | null;
|
|
108
|
+
repo_name: string | null;
|
|
109
|
+
branch: string | null;
|
|
110
|
+
commit_sha: string | null;
|
|
111
|
+
task: string | null;
|
|
112
|
+
intent: string;
|
|
113
|
+
summary: string;
|
|
114
|
+
decision: string;
|
|
115
|
+
why: string;
|
|
116
|
+
source: string;
|
|
117
|
+
raw_transcript_path: string | null;
|
|
118
|
+
embedding_text: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function hydrateMemory(db: Database.Database, row: MemoryRow): CodingMemory {
|
|
122
|
+
const files = db
|
|
123
|
+
.prepare<[string], { file_path: string }>(
|
|
124
|
+
"SELECT file_path FROM memory_files WHERE memory_id = ?"
|
|
125
|
+
)
|
|
126
|
+
.all(row.id)
|
|
127
|
+
.map((r) => r.file_path);
|
|
128
|
+
|
|
129
|
+
const tags = db
|
|
130
|
+
.prepare<[string], { tag: string }>("SELECT tag FROM memory_tags WHERE memory_id = ?")
|
|
131
|
+
.all(row.id)
|
|
132
|
+
.map((r) => r.tag);
|
|
133
|
+
|
|
134
|
+
const alternativesRejected = db
|
|
135
|
+
.prepare<[string], { value: string }>(
|
|
136
|
+
"SELECT value FROM memory_alternatives WHERE memory_id = ?"
|
|
137
|
+
)
|
|
138
|
+
.all(row.id)
|
|
139
|
+
.map((r) => r.value);
|
|
140
|
+
|
|
141
|
+
const risks = db
|
|
142
|
+
.prepare<[string], { value: string }>(
|
|
143
|
+
"SELECT value FROM memory_risks WHERE memory_id = ?"
|
|
144
|
+
)
|
|
145
|
+
.all(row.id)
|
|
146
|
+
.map((r) => r.value);
|
|
147
|
+
|
|
148
|
+
const followUps = db
|
|
149
|
+
.prepare<[string], { value: string }>(
|
|
150
|
+
"SELECT value FROM memory_followups WHERE memory_id = ?"
|
|
151
|
+
)
|
|
152
|
+
.all(row.id)
|
|
153
|
+
.map((r) => r.value);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
id: row.id,
|
|
157
|
+
createdAt: row.created_at,
|
|
158
|
+
updatedAt: row.updated_at,
|
|
159
|
+
repoId: row.repo_id,
|
|
160
|
+
repoPath: row.repo_path ?? undefined,
|
|
161
|
+
repoName: row.repo_name ?? undefined,
|
|
162
|
+
branch: row.branch ?? undefined,
|
|
163
|
+
commitSha: row.commit_sha ?? undefined,
|
|
164
|
+
task: row.task ?? undefined,
|
|
165
|
+
intent: row.intent,
|
|
166
|
+
summary: row.summary,
|
|
167
|
+
decision: row.decision,
|
|
168
|
+
why: row.why,
|
|
169
|
+
source: row.source as CodingMemory["source"],
|
|
170
|
+
rawTranscriptPath: row.raw_transcript_path ?? undefined,
|
|
171
|
+
embeddingText: row.embedding_text,
|
|
172
|
+
files,
|
|
173
|
+
tags,
|
|
174
|
+
alternativesRejected,
|
|
175
|
+
risks,
|
|
176
|
+
followUps,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function getMemoryById(db: Database.Database, id: string): CodingMemory | null {
|
|
181
|
+
const row = db
|
|
182
|
+
.prepare<[string], MemoryRow>("SELECT * FROM memories WHERE id = ?")
|
|
183
|
+
.get(id);
|
|
184
|
+
return row ? hydrateMemory(db, row) : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getMemoryByCommit(db: Database.Database, commitSha: string): CodingMemory | null {
|
|
188
|
+
const row = db
|
|
189
|
+
.prepare<[string], MemoryRow>("SELECT * FROM memories WHERE commit_sha = ? LIMIT 1")
|
|
190
|
+
.get(commitSha);
|
|
191
|
+
return row ? hydrateMemory(db, row) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function getMemoriesByCommit(db: Database.Database, commitSha: string): CodingMemory[] {
|
|
195
|
+
const rows = db
|
|
196
|
+
.prepare<[string], MemoryRow>("SELECT * FROM memories WHERE commit_sha = ?")
|
|
197
|
+
.all(commitSha);
|
|
198
|
+
return rows.map((r) => hydrateMemory(db, r));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function getMemoriesByFile(
|
|
202
|
+
db: Database.Database,
|
|
203
|
+
repoId: string | null,
|
|
204
|
+
filePaths: string | string[],
|
|
205
|
+
limit: number
|
|
206
|
+
): CodingMemory[] {
|
|
207
|
+
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
208
|
+
const placeholders = paths.map(() => "?").join(", ");
|
|
209
|
+
let rows: MemoryRow[];
|
|
210
|
+
if (repoId) {
|
|
211
|
+
rows = db
|
|
212
|
+
.prepare<unknown[], MemoryRow>(
|
|
213
|
+
`SELECT DISTINCT m.* FROM memories m
|
|
214
|
+
JOIN memory_files f ON f.memory_id = m.id
|
|
215
|
+
WHERE m.repo_id = ? AND f.file_path IN (${placeholders})
|
|
216
|
+
ORDER BY m.created_at DESC
|
|
217
|
+
LIMIT ?`
|
|
218
|
+
)
|
|
219
|
+
.all(repoId, ...paths, limit);
|
|
220
|
+
} else {
|
|
221
|
+
rows = db
|
|
222
|
+
.prepare<unknown[], MemoryRow>(
|
|
223
|
+
`SELECT DISTINCT m.* FROM memories m
|
|
224
|
+
JOIN memory_files f ON f.memory_id = m.id
|
|
225
|
+
WHERE f.file_path IN (${placeholders})
|
|
226
|
+
ORDER BY m.created_at DESC
|
|
227
|
+
LIMIT ?`
|
|
228
|
+
)
|
|
229
|
+
.all(...paths, limit);
|
|
230
|
+
}
|
|
231
|
+
return rows.map((r) => hydrateMemory(db, r));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getAllMemories(db: Database.Database): CodingMemory[] {
|
|
235
|
+
const rows = db
|
|
236
|
+
.prepare<[], MemoryRow>("SELECT * FROM memories ORDER BY created_at DESC")
|
|
237
|
+
.all();
|
|
238
|
+
return rows.map((r) => hydrateMemory(db, r));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function getMemoriesByRepoId(db: Database.Database, repoId: string): CodingMemory[] {
|
|
242
|
+
const rows = db
|
|
243
|
+
.prepare<[string], MemoryRow>(
|
|
244
|
+
"SELECT * FROM memories WHERE repo_id = ? ORDER BY created_at DESC"
|
|
245
|
+
)
|
|
246
|
+
.all(repoId);
|
|
247
|
+
return rows.map((r) => hydrateMemory(db, r));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function getMemoriesByRepoPath(db: Database.Database, repoPath: string): CodingMemory[] {
|
|
251
|
+
const rows = db
|
|
252
|
+
.prepare<[string], MemoryRow>(
|
|
253
|
+
"SELECT * FROM memories WHERE repo_path LIKE ? ORDER BY created_at DESC"
|
|
254
|
+
)
|
|
255
|
+
.all(`%${repoPath}%`);
|
|
256
|
+
return rows.map((r) => hydrateMemory(db, r));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function listMemories(
|
|
260
|
+
db: Database.Database,
|
|
261
|
+
options: { repoId?: string; limit: number }
|
|
262
|
+
): CodingMemory[] {
|
|
263
|
+
let rows: MemoryRow[];
|
|
264
|
+
if (options.repoId) {
|
|
265
|
+
rows = db
|
|
266
|
+
.prepare<[string, number], MemoryRow>(
|
|
267
|
+
"SELECT * FROM memories WHERE repo_id = ? ORDER BY created_at DESC LIMIT ?"
|
|
268
|
+
)
|
|
269
|
+
.all(options.repoId, options.limit);
|
|
270
|
+
} else {
|
|
271
|
+
rows = db
|
|
272
|
+
.prepare<[number], MemoryRow>(
|
|
273
|
+
"SELECT * FROM memories ORDER BY created_at DESC LIMIT ?"
|
|
274
|
+
)
|
|
275
|
+
.all(options.limit);
|
|
276
|
+
}
|
|
277
|
+
return rows.map((r) => hydrateMemory(db, r));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function deleteMemory(db: Database.Database, id: string): void {
|
|
281
|
+
db.prepare<[string]>("DELETE FROM memories WHERE id = ?").run(id);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export type MemoryStats = {
|
|
285
|
+
total: number;
|
|
286
|
+
repos: number;
|
|
287
|
+
oldest: string;
|
|
288
|
+
newest: string;
|
|
289
|
+
topFiles: { filePath: string; count: number }[];
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export function getStats(db: Database.Database): MemoryStats {
|
|
293
|
+
const row = db
|
|
294
|
+
.prepare<[], { total: number; repos: number; oldest: string; newest: string }>(
|
|
295
|
+
"SELECT COUNT(*) as total, COUNT(DISTINCT repo_id) as repos, MIN(created_at) as oldest, MAX(created_at) as newest FROM memories"
|
|
296
|
+
)
|
|
297
|
+
.get();
|
|
298
|
+
|
|
299
|
+
const topFiles = db
|
|
300
|
+
.prepare<[], { file_path: string; count: number }>(
|
|
301
|
+
"SELECT file_path, COUNT(*) as count FROM memory_files GROUP BY file_path ORDER BY count DESC LIMIT 10"
|
|
302
|
+
)
|
|
303
|
+
.all()
|
|
304
|
+
.map((r) => ({ filePath: r.file_path, count: r.count }));
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
total: row?.total ?? 0,
|
|
308
|
+
repos: row?.repos ?? 0,
|
|
309
|
+
oldest: row?.oldest ? new Date(row.oldest).toLocaleDateString() : "-",
|
|
310
|
+
newest: row?.newest ? new Date(row.newest).toLocaleDateString() : "-",
|
|
311
|
+
topFiles,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function updateMemory(
|
|
316
|
+
db: Database.Database,
|
|
317
|
+
id: string,
|
|
318
|
+
updates: {
|
|
319
|
+
intent?: string;
|
|
320
|
+
summary?: string;
|
|
321
|
+
decision?: string;
|
|
322
|
+
why?: string;
|
|
323
|
+
task?: string;
|
|
324
|
+
alternativesRejected?: string[];
|
|
325
|
+
risks?: string[];
|
|
326
|
+
followUps?: string[];
|
|
327
|
+
tags?: string[];
|
|
328
|
+
embeddingText?: string;
|
|
329
|
+
}
|
|
330
|
+
): void {
|
|
331
|
+
const now = new Date().toISOString();
|
|
332
|
+
|
|
333
|
+
db.prepare(
|
|
334
|
+
"UPDATE memories SET intent = ?, summary = ?, decision = ?, why = ?, task = ?, embedding_text = ?, updated_at = ? WHERE id = ?"
|
|
335
|
+
).run(
|
|
336
|
+
updates.intent ?? "",
|
|
337
|
+
updates.summary ?? "",
|
|
338
|
+
updates.decision ?? "",
|
|
339
|
+
updates.why ?? "",
|
|
340
|
+
updates.task ?? null,
|
|
341
|
+
updates.embeddingText ?? "",
|
|
342
|
+
now,
|
|
343
|
+
id
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (updates.tags !== undefined) {
|
|
347
|
+
db.prepare<[string]>("DELETE FROM memory_tags WHERE memory_id = ?").run(id);
|
|
348
|
+
const ins = db.prepare("INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)");
|
|
349
|
+
for (const t of updates.tags) ins.run(id, t);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (updates.alternativesRejected !== undefined) {
|
|
353
|
+
db.prepare<[string]>("DELETE FROM memory_alternatives WHERE memory_id = ?").run(id);
|
|
354
|
+
const ins = db.prepare("INSERT OR IGNORE INTO memory_alternatives (memory_id, value) VALUES (?, ?)");
|
|
355
|
+
for (const a of updates.alternativesRejected) ins.run(id, a);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (updates.risks !== undefined) {
|
|
359
|
+
db.prepare<[string]>("DELETE FROM memory_risks WHERE memory_id = ?").run(id);
|
|
360
|
+
const ins = db.prepare("INSERT OR IGNORE INTO memory_risks (memory_id, value) VALUES (?, ?)");
|
|
361
|
+
for (const r of updates.risks) ins.run(id, r);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (updates.followUps !== undefined) {
|
|
365
|
+
db.prepare<[string]>("DELETE FROM memory_followups WHERE memory_id = ?").run(id);
|
|
366
|
+
const ins = db.prepare("INSERT OR IGNORE INTO memory_followups (memory_id, value) VALUES (?, ?)");
|
|
367
|
+
for (const fu of updates.followUps) ins.run(id, fu);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import type { CodingMemory, ScoreBreakdown, SearchResult } from "./types.js";
|
|
3
|
+
import {
|
|
4
|
+
getMemoriesByRepoId,
|
|
5
|
+
getMemoriesByRepoPath,
|
|
6
|
+
getAllMemories,
|
|
7
|
+
} from "./saveMemory.js";
|
|
8
|
+
|
|
9
|
+
export const STALE_THRESHOLD_DAYS = 90;
|
|
10
|
+
|
|
11
|
+
export function isStale(memory: CodingMemory, thresholdDays = STALE_THRESHOLD_DAYS): boolean {
|
|
12
|
+
const ageMs = Date.now() - new Date(memory.createdAt).getTime();
|
|
13
|
+
return ageMs > thresholdDays * 24 * 60 * 60 * 1000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SearchOptions = {
|
|
17
|
+
query: string;
|
|
18
|
+
repoId?: string;
|
|
19
|
+
repoPath?: string;
|
|
20
|
+
files?: string[];
|
|
21
|
+
tags?: string[];
|
|
22
|
+
since?: string;
|
|
23
|
+
before?: string;
|
|
24
|
+
limit?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function scoreMemory(
|
|
28
|
+
memory: CodingMemory,
|
|
29
|
+
query: string,
|
|
30
|
+
contextRepoId: string | null,
|
|
31
|
+
contextFiles: string[]
|
|
32
|
+
): ScoreBreakdown {
|
|
33
|
+
const words = query
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.split(/\s+/)
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
|
|
38
|
+
if (words.length === 0) {
|
|
39
|
+
const sameRepo = contextRepoId && memory.repoId === contextRepoId ? 10 : 0;
|
|
40
|
+
const ageMs = Date.now() - new Date(memory.createdAt).getTime();
|
|
41
|
+
const recency = ageMs < 30 * 24 * 60 * 60 * 1000 ? 1 : 0;
|
|
42
|
+
return { total: sameRepo + recency, sameRepo, fileOverlap: 0, tagMatch: 0, decisionMatch: 0, whyMatch: 0, summaryMatch: 0, filePathMatch: 0, recency };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hasKeyword = (text: string): boolean =>
|
|
46
|
+
words.some((w) => text.toLowerCase().includes(w));
|
|
47
|
+
|
|
48
|
+
const sameRepo = contextRepoId && memory.repoId === contextRepoId ? 10 : 0;
|
|
49
|
+
const fileOverlap = contextFiles.some((f) => memory.files.includes(f)) ? 8 : 0;
|
|
50
|
+
const tagMatch = memory.tags.some(hasKeyword) ? 5 : 0;
|
|
51
|
+
const decisionMatch = hasKeyword(memory.decision) ? 4 : 0;
|
|
52
|
+
const whyMatch = hasKeyword(memory.why) ? 3 : 0;
|
|
53
|
+
const summaryMatch = hasKeyword(memory.summary) ? 2 : 0;
|
|
54
|
+
const filePathMatch = memory.files.some(hasKeyword) ? 2 : 0;
|
|
55
|
+
|
|
56
|
+
const ageMs = Date.now() - new Date(memory.createdAt).getTime();
|
|
57
|
+
const recency = ageMs < 30 * 24 * 60 * 60 * 1000 ? 1 : 0;
|
|
58
|
+
|
|
59
|
+
const total =
|
|
60
|
+
sameRepo +
|
|
61
|
+
fileOverlap +
|
|
62
|
+
tagMatch +
|
|
63
|
+
decisionMatch +
|
|
64
|
+
whyMatch +
|
|
65
|
+
summaryMatch +
|
|
66
|
+
filePathMatch +
|
|
67
|
+
recency;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
total,
|
|
71
|
+
sameRepo,
|
|
72
|
+
fileOverlap,
|
|
73
|
+
tagMatch,
|
|
74
|
+
decisionMatch,
|
|
75
|
+
whyMatch,
|
|
76
|
+
summaryMatch,
|
|
77
|
+
filePathMatch,
|
|
78
|
+
recency,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function explainRelevance(scores: ScoreBreakdown): string {
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
if (scores.sameRepo) parts.push(`same repo (+${scores.sameRepo})`);
|
|
85
|
+
if (scores.fileOverlap) parts.push(`file overlap (+${scores.fileOverlap})`);
|
|
86
|
+
if (scores.tagMatch) parts.push(`tag match (+${scores.tagMatch})`);
|
|
87
|
+
if (scores.decisionMatch) parts.push(`keyword in decision (+${scores.decisionMatch})`);
|
|
88
|
+
if (scores.whyMatch) parts.push(`keyword in why (+${scores.whyMatch})`);
|
|
89
|
+
if (scores.summaryMatch) parts.push(`keyword in summary (+${scores.summaryMatch})`);
|
|
90
|
+
if (scores.filePathMatch) parts.push(`keyword in file path (+${scores.filePathMatch})`);
|
|
91
|
+
if (scores.recency) parts.push(`recent memory (+${scores.recency})`);
|
|
92
|
+
return parts.length ? `Matched: ${parts.join(", ")}` : "No specific match factors";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function searchMemory(db: Database.Database, options: SearchOptions): SearchResult[] {
|
|
96
|
+
const { query, repoId, repoPath, files = [], tags = [], since, before, limit = 10 } = options;
|
|
97
|
+
|
|
98
|
+
let memories: CodingMemory[] = [];
|
|
99
|
+
|
|
100
|
+
if (repoId) {
|
|
101
|
+
memories = getMemoriesByRepoId(db, repoId);
|
|
102
|
+
if (memories.length === 0 && repoPath) {
|
|
103
|
+
memories = getMemoriesByRepoPath(db, repoPath);
|
|
104
|
+
}
|
|
105
|
+
} else if (repoPath) {
|
|
106
|
+
memories = getMemoriesByRepoPath(db, repoPath);
|
|
107
|
+
} else {
|
|
108
|
+
memories = getAllMemories(db);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Date filters
|
|
112
|
+
const sinceMs = since ? new Date(since).getTime() : null;
|
|
113
|
+
const beforeMs = before ? new Date(before).getTime() : null;
|
|
114
|
+
if (sinceMs !== null || beforeMs !== null) {
|
|
115
|
+
memories = memories.filter((m) => {
|
|
116
|
+
const t = new Date(m.createdAt).getTime();
|
|
117
|
+
if (sinceMs !== null && t < sinceMs) return false;
|
|
118
|
+
if (beforeMs !== null && t > beforeMs) return false;
|
|
119
|
+
return true;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Tag filter — memory must have ALL requested tags
|
|
124
|
+
if (tags.length > 0) {
|
|
125
|
+
const lowerTags = tags.map((t) => t.toLowerCase());
|
|
126
|
+
memories = memories.filter((m) =>
|
|
127
|
+
lowerTags.every((tag) => m.tags.map((t) => t.toLowerCase()).includes(tag))
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const emptyQuery = query.trim() === "";
|
|
132
|
+
|
|
133
|
+
const results: SearchResult[] = memories
|
|
134
|
+
.map((memory) => {
|
|
135
|
+
const score = scoreMemory(memory, query, repoId ?? null, files);
|
|
136
|
+
return { memory, score, relevanceReason: explainRelevance(score) };
|
|
137
|
+
})
|
|
138
|
+
.filter((r) => {
|
|
139
|
+
if (emptyQuery) return true;
|
|
140
|
+
const contentScore =
|
|
141
|
+
r.score.fileOverlap +
|
|
142
|
+
r.score.tagMatch +
|
|
143
|
+
r.score.decisionMatch +
|
|
144
|
+
r.score.whyMatch +
|
|
145
|
+
r.score.summaryMatch +
|
|
146
|
+
r.score.filePathMatch;
|
|
147
|
+
return contentScore > 0;
|
|
148
|
+
})
|
|
149
|
+
.sort((a, b) => b.score.total - a.score.total)
|
|
150
|
+
.slice(0, limit);
|
|
151
|
+
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type CodingMemory = {
|
|
2
|
+
id: string;
|
|
3
|
+
createdAt: string;
|
|
4
|
+
updatedAt: string;
|
|
5
|
+
|
|
6
|
+
repoId: string;
|
|
7
|
+
repoPath?: string;
|
|
8
|
+
repoName?: string;
|
|
9
|
+
branch?: string;
|
|
10
|
+
commitSha?: string;
|
|
11
|
+
|
|
12
|
+
files: string[];
|
|
13
|
+
tags: string[];
|
|
14
|
+
|
|
15
|
+
task?: string;
|
|
16
|
+
intent: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
decision: string;
|
|
19
|
+
why: string;
|
|
20
|
+
|
|
21
|
+
alternativesRejected: string[];
|
|
22
|
+
risks: string[];
|
|
23
|
+
followUps: string[];
|
|
24
|
+
|
|
25
|
+
source: "manual" | "claude-code" | "cli" | "hook";
|
|
26
|
+
rawTranscriptPath?: string;
|
|
27
|
+
|
|
28
|
+
embeddingText: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ScoreBreakdown = {
|
|
32
|
+
total: number;
|
|
33
|
+
sameRepo: number;
|
|
34
|
+
fileOverlap: number;
|
|
35
|
+
tagMatch: number;
|
|
36
|
+
decisionMatch: number;
|
|
37
|
+
whyMatch: number;
|
|
38
|
+
summaryMatch: number;
|
|
39
|
+
filePathMatch: number;
|
|
40
|
+
recency: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SearchResult = {
|
|
44
|
+
memory: CodingMemory;
|
|
45
|
+
score: ScoreBreakdown;
|
|
46
|
+
relevanceReason: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type RepoContext = {
|
|
50
|
+
repoRoot: string;
|
|
51
|
+
repoId: string;
|
|
52
|
+
repoPath: string;
|
|
53
|
+
repoName: string;
|
|
54
|
+
branch: string | null;
|
|
55
|
+
commitSha: string;
|
|
56
|
+
changedFiles: string[];
|
|
57
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CodingMemory, SearchResult } from "../memory/types.js";
|
|
2
|
+
import { isStale } from "../memory/searchMemory.js";
|
|
3
|
+
|
|
4
|
+
export function formatMemory(memory: CodingMemory, verbose = false): string {
|
|
5
|
+
const staleNote = isStale(memory) ? " [STALE — older than 90 days, verify still applies]" : "";
|
|
6
|
+
const lines: string[] = [memory.id + staleNote];
|
|
7
|
+
|
|
8
|
+
if (memory.commitSha) lines.push(`Commit: ${memory.commitSha.slice(0, 8)}`);
|
|
9
|
+
if (memory.repoName) lines.push(`Repo: ${memory.repoName}`);
|
|
10
|
+
if (memory.files.length) lines.push(`Files: ${memory.files.join(", ")}`);
|
|
11
|
+
|
|
12
|
+
lines.push("", "Intent:", ` ${memory.intent}`);
|
|
13
|
+
lines.push("", "Decision:", ` ${memory.decision}`);
|
|
14
|
+
lines.push("", "Why:", ` ${memory.why}`);
|
|
15
|
+
|
|
16
|
+
if (memory.risks.length) {
|
|
17
|
+
lines.push("", "Risks:");
|
|
18
|
+
for (const r of memory.risks) lines.push(` - ${r}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (verbose) {
|
|
22
|
+
if (memory.alternativesRejected.length) {
|
|
23
|
+
lines.push("", "Alternatives rejected:");
|
|
24
|
+
for (const a of memory.alternativesRejected) lines.push(` - ${a}`);
|
|
25
|
+
}
|
|
26
|
+
if (memory.followUps.length) {
|
|
27
|
+
lines.push("", "Follow-ups:");
|
|
28
|
+
for (const fu of memory.followUps) lines.push(` - ${fu}`);
|
|
29
|
+
}
|
|
30
|
+
if (memory.tags.length) {
|
|
31
|
+
lines.push("", `Tags: ${memory.tags.join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
if (memory.task) {
|
|
34
|
+
lines.push("", `Task: ${memory.task}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push("", `Summary: ${memory.summary}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatSearchResult(result: SearchResult): string {
|
|
43
|
+
return `${formatMemory(result.memory)}\n\n [${result.relevanceReason}]`;
|
|
44
|
+
}
|