aidimag 1.0.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 (91) hide show
  1. package/LICENSE +102 -0
  2. package/README.md +113 -0
  3. package/dist/capture/bootstrap.d.ts +25 -0
  4. package/dist/capture/bootstrap.js +188 -0
  5. package/dist/capture/commit-miner.d.ts +59 -0
  6. package/dist/capture/commit-miner.js +381 -0
  7. package/dist/capture/harvest.d.ts +50 -0
  8. package/dist/capture/harvest.js +207 -0
  9. package/dist/capture/pr-miner.d.ts +57 -0
  10. package/dist/capture/pr-miner.js +185 -0
  11. package/dist/capture/session-briefing.d.ts +36 -0
  12. package/dist/capture/session-briefing.js +150 -0
  13. package/dist/capture/session-extraction.d.ts +23 -0
  14. package/dist/capture/session-extraction.js +54 -0
  15. package/dist/capture/triage.d.ts +30 -0
  16. package/dist/capture/triage.js +108 -0
  17. package/dist/cli/commands/capture.d.ts +5 -0
  18. package/dist/cli/commands/capture.js +357 -0
  19. package/dist/cli/commands/hosts.d.ts +5 -0
  20. package/dist/cli/commands/hosts.js +98 -0
  21. package/dist/cli/commands/knowledge.d.ts +5 -0
  22. package/dist/cli/commands/knowledge.js +121 -0
  23. package/dist/cli/commands/memory.d.ts +6 -0
  24. package/dist/cli/commands/memory.js +392 -0
  25. package/dist/cli/commands/sync.d.ts +5 -0
  26. package/dist/cli/commands/sync.js +307 -0
  27. package/dist/cli/commands/tickets.d.ts +6 -0
  28. package/dist/cli/commands/tickets.js +328 -0
  29. package/dist/cli/commands/verify.d.ts +5 -0
  30. package/dist/cli/commands/verify.js +136 -0
  31. package/dist/cli/index.d.ts +19 -0
  32. package/dist/cli/index.js +46 -0
  33. package/dist/cli/shared.d.ts +37 -0
  34. package/dist/cli/shared.js +175 -0
  35. package/dist/config.d.ts +55 -0
  36. package/dist/config.js +51 -0
  37. package/dist/context/generate.d.ts +24 -0
  38. package/dist/context/generate.js +115 -0
  39. package/dist/critique/critique.d.ts +40 -0
  40. package/dist/critique/critique.js +110 -0
  41. package/dist/db/schema.d.ts +28 -0
  42. package/dist/db/schema.js +269 -0
  43. package/dist/db/store.d.ts +182 -0
  44. package/dist/db/store.js +906 -0
  45. package/dist/debug.d.ts +14 -0
  46. package/dist/debug.js +25 -0
  47. package/dist/embeddings/provider.d.ts +16 -0
  48. package/dist/embeddings/provider.js +100 -0
  49. package/dist/embeddings/search.d.ts +22 -0
  50. package/dist/embeddings/search.js +95 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.js +12 -0
  53. package/dist/knowledge/chunk.d.ts +9 -0
  54. package/dist/knowledge/chunk.js +78 -0
  55. package/dist/knowledge/extract.d.ts +34 -0
  56. package/dist/knowledge/extract.js +113 -0
  57. package/dist/knowledge/ingest.d.ts +79 -0
  58. package/dist/knowledge/ingest.js +305 -0
  59. package/dist/knowledge/llm.d.ts +21 -0
  60. package/dist/knowledge/llm.js +110 -0
  61. package/dist/mcp/server.d.ts +11 -0
  62. package/dist/mcp/server.js +532 -0
  63. package/dist/security/evidence.d.ts +11 -0
  64. package/dist/security/evidence.js +15 -0
  65. package/dist/security/seal.d.ts +3 -0
  66. package/dist/security/seal.js +28 -0
  67. package/dist/security/sync-push.d.ts +2 -0
  68. package/dist/security/sync-push.js +37 -0
  69. package/dist/security/url.d.ts +6 -0
  70. package/dist/security/url.js +67 -0
  71. package/dist/sync/client.d.ts +84 -0
  72. package/dist/sync/client.js +391 -0
  73. package/dist/sync/server.d.ts +75 -0
  74. package/dist/sync/server.js +659 -0
  75. package/dist/tickets/provider.d.ts +80 -0
  76. package/dist/tickets/provider.js +375 -0
  77. package/dist/types.d.ts +133 -0
  78. package/dist/types.js +5 -0
  79. package/dist/ui/page.d.ts +5 -0
  80. package/dist/ui/page.js +841 -0
  81. package/dist/ui/server.d.ts +10 -0
  82. package/dist/ui/server.js +437 -0
  83. package/dist/verify/check.d.ts +38 -0
  84. package/dist/verify/check.js +121 -0
  85. package/dist/verify/engine.d.ts +39 -0
  86. package/dist/verify/engine.js +178 -0
  87. package/dist/verify/hooks.d.ts +24 -0
  88. package/dist/verify/hooks.js +125 -0
  89. package/dist/verify/runners.d.ts +26 -0
  90. package/dist/verify/runners.js +193 -0
  91. package/package.json +78 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Commit miner (Phase 2 capture pipeline).
3
+ *
4
+ * Walks git history since the last mined commit and heuristically extracts
5
+ * memory-worthy candidates from commit messages + touched files. Candidates
6
+ * land in the proposal queue (human-in-the-loop) — never directly in memory.
7
+ *
8
+ * Each proposal is anchored with COMMIT_REF evidence so Phase 3 verification
9
+ * can re-check it against history.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { extractTicketId, readTicketsConfig, DEFAULT_TICKET_PATTERN } from "../tickets/provider.js";
13
+ import { debugLog } from "../debug.js";
14
+ const MINER_CURSOR_KEY = "commit_miner_last_sha";
15
+ const COMMIT_SEP = "\x1e"; // record separator
16
+ const FIELD_SEP = "\x1f"; // unit separator
17
+ /**
18
+ * Heuristic signal patterns → memory kind.
19
+ * Order matters: first match wins.
20
+ */
21
+ const SIGNALS = [
22
+ {
23
+ kind: "FAILED_APPROACH",
24
+ patterns: [
25
+ /\brevert(s|ed|ing)?\b/i,
26
+ /\bback(\s|-)?out\b/i,
27
+ /\bdidn'?t work\b/i,
28
+ /\babandon(s|ed|ing)?\b/i,
29
+ ],
30
+ },
31
+ {
32
+ kind: "GOTCHA",
33
+ patterns: [
34
+ /\bworkaround\b/i,
35
+ /\bhack\b/i,
36
+ /\bgotcha\b/i,
37
+ /\bedge case\b/i,
38
+ /\brace condition\b/i,
39
+ /\bfoot(\s|-)?gun\b/i,
40
+ /\bsubtle\b/i,
41
+ /\bcareful\b/i,
42
+ /\bdo not\b.*\bbecause\b/i,
43
+ ],
44
+ },
45
+ {
46
+ kind: "DECISION",
47
+ patterns: [
48
+ /\bdecid(e|ed|ing)\b/i,
49
+ /\bswitch(ed|ing)? (from|to)\b/i,
50
+ /\bmigrat(e|ed|ing) (from|to)\b/i,
51
+ /\breplac(e|ed|ing) .+ with\b/i,
52
+ /\binstead of\b/i,
53
+ /\bchose\b/i,
54
+ /\badopt(s|ed|ing)?\b/i,
55
+ /\bADR\b/,
56
+ ],
57
+ },
58
+ {
59
+ kind: "CONVENTION",
60
+ patterns: [
61
+ /\bconvention\b/i,
62
+ /\balways\b.+\b(use|go|import|call)\b/i,
63
+ /\bnever\b.+\b(use|import|call)\b/i,
64
+ /\bstandardiz(e|ed|ing)\b/i,
65
+ /\benforce(s|d)?\b/i,
66
+ /\blint rule\b/i,
67
+ ],
68
+ },
69
+ {
70
+ kind: "INVARIANT",
71
+ patterns: [/\binvariant\b/i, /\bmust (always|never)\b/i, /\bguarantee(s|d)?\b/i],
72
+ },
73
+ ];
74
+ /** Why-markers: a commit explaining reasoning is more memory-worthy. */
75
+ const WHY_MARKERS = /\b(because|so that|otherwise|due to|the reason|to avoid|to prevent)\b/i;
76
+ function git(repoRoot, args) {
77
+ return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
78
+ }
79
+ /** False when the repo is initialized but has no commits yet (no HEAD). */
80
+ export function hasGitCommits(repoRoot) {
81
+ try {
82
+ execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
83
+ cwd: repoRoot,
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "ignore"],
86
+ });
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ export function readCommits(repoRoot, sinceSha, maxCommits = 500) {
94
+ if (!hasGitCommits(repoRoot))
95
+ return [];
96
+ const range = sinceSha ? `${sinceSha}..HEAD` : "HEAD";
97
+ let raw;
98
+ try {
99
+ // NOTE: merges are included on purpose — GitHub "merge pull request"
100
+ // commits carry the PR title in the body, and squash-merges carry the
101
+ // full PR description. Pure merge noise ("Merge branch 'x'") has no
102
+ // signal words, so classifyCommit filters it naturally.
103
+ raw = git(repoRoot, [
104
+ "log",
105
+ range,
106
+ `--max-count=${maxCommits}`,
107
+ `--pretty=format:${COMMIT_SEP}%H${FIELD_SEP}%s${FIELD_SEP}%b${FIELD_SEP}`,
108
+ "--name-only",
109
+ ]);
110
+ }
111
+ catch (err) {
112
+ // sinceSha may no longer exist (rebase/gc) — fall back to full history
113
+ if (sinceSha)
114
+ return readCommits(repoRoot, null, maxCommits);
115
+ throw err;
116
+ }
117
+ const commits = [];
118
+ for (const chunk of raw.split(COMMIT_SEP)) {
119
+ if (!chunk.trim())
120
+ continue;
121
+ const [sha, subject, body, fileBlock] = chunk.split(FIELD_SEP);
122
+ if (!sha)
123
+ continue;
124
+ const files = (fileBlock ?? "")
125
+ .split("\n")
126
+ .map((f) => f.trim())
127
+ .filter(Boolean);
128
+ commits.push({ sha: sha.trim(), subject: subject ?? "", body: body ?? "", files });
129
+ }
130
+ return commits;
131
+ }
132
+ export function classifyCommit(c) {
133
+ const text = `${c.subject}\n${c.body}`;
134
+ for (const { kind, patterns } of SIGNALS) {
135
+ for (const re of patterns) {
136
+ const m = text.match(re);
137
+ if (m)
138
+ return { kind, matched: m[0] };
139
+ }
140
+ }
141
+ // a long explanatory body with why-markers is a DECISION candidate even without keywords
142
+ if (c.body.length > 120 && WHY_MARKERS.test(c.body)) {
143
+ return { kind: "DECISION", matched: "explanatory body" };
144
+ }
145
+ return null;
146
+ }
147
+ function buildClaim(c, kind) {
148
+ let subject = c.subject.trim().replace(/\.+$/, "");
149
+ let bodyLines = c.body
150
+ .split("\n")
151
+ .map((l) => l.trim())
152
+ .filter((l) => l && !l.startsWith("Co-authored-by") && !l.startsWith("Signed-off-by"));
153
+ // merge commits: the subject is boilerplate ("Merge pull request #123 …");
154
+ // the PR title is the first body line — promote it.
155
+ if (/^Merge (pull request|branch)/i.test(subject) && bodyLines.length) {
156
+ subject = bodyLines[0].replace(/\.+$/, "");
157
+ bodyLines = bodyLines.slice(1);
158
+ }
159
+ const why = bodyLines.join(" ").slice(0, 300);
160
+ const prefix = kind === "FAILED_APPROACH"
161
+ ? "An approach was abandoned"
162
+ : kind === "GOTCHA"
163
+ ? "There is a gotcha"
164
+ : kind === "DECISION"
165
+ ? "A decision was made"
166
+ : kind === "CONVENTION"
167
+ ? "A convention applies"
168
+ : "An invariant holds";
169
+ return why ? `${prefix}: ${subject} — ${why}` : `${prefix}: ${subject}`;
170
+ }
171
+ /** Files that say nothing about the code — never useful as memory scope. */
172
+ const SCOPE_NOISE = /^(\.idea\/|\.vscode\/|\.aidimag\/|\.github\/workflows\/.*\.lock|\.gitignore$|\.DS_Store$|node_modules\/)/;
173
+ /** Reduce touched files to a few representative scope paths (common directories). */
174
+ export function scopeFromFiles(files, maxPaths = 4) {
175
+ files = files.filter((f) => !SCOPE_NOISE.test(f));
176
+ if (files.length === 0)
177
+ return [];
178
+ if (files.length <= maxPaths)
179
+ return files;
180
+ const dirs = new Map();
181
+ for (const f of files) {
182
+ const dir = f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : ".";
183
+ dirs.set(dir, (dirs.get(dir) ?? 0) + 1);
184
+ }
185
+ return [...dirs.entries()]
186
+ .sort((a, b) => b[1] - a[1])
187
+ .slice(0, maxPaths)
188
+ .map(([d]) => d);
189
+ }
190
+ export function mineCommits(store, repoRoot, opts = {}) {
191
+ if (!hasGitCommits(repoRoot)) {
192
+ return { scanned: 0, proposed: [], skippedDuplicates: 0, lastSha: null, noCommits: true };
193
+ }
194
+ const sinceSha = opts.full ? null : store.getMeta(MINER_CURSOR_KEY);
195
+ const commits = readCommits(repoRoot, sinceSha, opts.maxCommits ?? 500);
196
+ if (commits.length === 0 && sinceSha) {
197
+ return {
198
+ scanned: 0,
199
+ proposed: [],
200
+ skippedDuplicates: 0,
201
+ lastSha: sinceSha,
202
+ noNewCommits: true,
203
+ };
204
+ }
205
+ // T1 ticket extraction (offline): per-commit from the message; for
206
+ // incremental mining (the post-commit hook path) the current branch name is
207
+ // a trustworthy fallback — for full-history scans it would mislabel.
208
+ const ticketPattern = readTicketsConfig(repoRoot).pattern ?? DEFAULT_TICKET_PATTERN;
209
+ let branchTicket = null;
210
+ if (sinceSha) {
211
+ try {
212
+ const branch = git(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
213
+ branchTicket = extractTicketId(branch, ticketPattern);
214
+ }
215
+ catch {
216
+ // detached HEAD etc — message extraction still applies
217
+ }
218
+ }
219
+ const proposed = [];
220
+ let skippedDuplicates = 0;
221
+ for (const c of commits) {
222
+ const hit = classifyCommit(c);
223
+ if (!hit)
224
+ continue;
225
+ const ticketRef = extractTicketId(`${c.subject}\n${c.body}`, ticketPattern) ?? branchTicket ?? undefined;
226
+ const input = {
227
+ kind: hit.kind,
228
+ claim: buildClaim(c, hit.kind),
229
+ paths: scopeFromFiles(c.files),
230
+ evidence: [
231
+ { type: "COMMIT_REF", payload: c.sha },
232
+ ...(ticketRef ? [{ type: "TICKET_REF", payload: ticketRef }] : []),
233
+ ],
234
+ source: "commit-miner",
235
+ sourceRef: c.sha,
236
+ rationale: `Matched signal "${hit.matched}" in commit ${c.sha.slice(0, 8)}: ${c.subject}`,
237
+ ticketRef,
238
+ };
239
+ const p = store.propose(input);
240
+ if (p)
241
+ proposed.push(p);
242
+ else
243
+ skippedDuplicates++;
244
+ }
245
+ // advance cursor to current HEAD (newest commit is first in `git log` output)
246
+ const head = commits.length > 0 ? commits[0].sha : sinceSha;
247
+ if (head)
248
+ store.setMeta(MINER_CURSOR_KEY, head);
249
+ return { scanned: commits.length, proposed, skippedDuplicates, lastSha: head ?? null };
250
+ }
251
+ // ---------------------------------------------------------------- LLM mining (deep tier)
252
+ const LLM_DIFF_CHARS = 6_000;
253
+ const LLM_MAX_COMMITS = 40; // per run — LLM mining is the deep tier
254
+ export const COMMIT_EXTRACT_INSTRUCTIONS = `You are mining a git commit for durable, project-specific knowledge worth remembering across AI coding sessions: decisions (and rejected alternatives), conventions, gotchas, failed approaches, invariants, architecture facts.
255
+
256
+ Rules:
257
+ 1. Most commits contain NOTHING durable — routine features/fixes/refactors. Return zero claims for those. Do NOT invent.
258
+ 2. When there IS signal, SYNTHESIZE a falsifiable claim about the codebase — do not parrot the commit message. Bad: "A decision was made: use Redis". Good: "Rate limiting uses Redis (src/limits); the in-memory limiter was abandoned because multi-instance deploys need shared counters".
259
+ 3. Use the DIFF, not just the message — renamed modules, deleted approaches, and added config tell the real story.
260
+ 4. kinds: DECISION, CONVENTION, GOTCHA, FAILED_APPROACH, ARCHITECTURE, INVARIANT, GUARDRAIL (guardrail_level never|ask-first|always), SKILL, TODO_CONTEXT.
261
+ 5. Scope with the touched paths; add "static_check" (cheap shell command, exit 0 iff true) when an honest one exists.
262
+ 6. 0–2 claims per commit. Zero is the common case.
263
+
264
+ Respond with ONLY: {"claims":[{"kind":"DECISION","claim":"...","paths":["src/x"],"symbols":[],"guardrail_level":null,"rationale":"...","static_check":null}]}`;
265
+ function commitDiff(repoRoot, sha) {
266
+ try {
267
+ return git(repoRoot, ["show", sha, "--stat", "--patch", "--format="]).slice(0, LLM_DIFF_CHARS);
268
+ }
269
+ catch {
270
+ return "";
271
+ }
272
+ }
273
+ /**
274
+ * LLM-powered deep mining: reads message + diff, synthesizes claims with
275
+ * suggested STATIC_CHECKs. Requires a text provider (OpenAI/Ollama); the
276
+ * caller falls back to regex mining when none is available. Same cursor as
277
+ * regex mining — the two modes are alternatives over the same history.
278
+ */
279
+ export async function mineCommitsLlm(store, repoRoot, opts = {}) {
280
+ const { getTextProvider } = await import("../knowledge/llm.js");
281
+ const { parseClaims } = await import("../knowledge/extract.js");
282
+ const provider = await getTextProvider();
283
+ if (!provider) {
284
+ const r = mineCommits(store, repoRoot, opts);
285
+ return { ...r, provider: null };
286
+ }
287
+ if (!hasGitCommits(repoRoot)) {
288
+ return { scanned: 0, proposed: [], skippedDuplicates: 0, lastSha: null, noCommits: true, provider: null };
289
+ }
290
+ const sinceSha = opts.full ? null : store.getMeta(MINER_CURSOR_KEY);
291
+ const commits = readCommits(repoRoot, sinceSha, Math.min(opts.maxCommits ?? LLM_MAX_COMMITS, LLM_MAX_COMMITS));
292
+ if (commits.length === 0 && sinceSha) {
293
+ return {
294
+ scanned: 0,
295
+ proposed: [],
296
+ skippedDuplicates: 0,
297
+ lastSha: sinceSha,
298
+ noNewCommits: true,
299
+ provider: `${provider.name}/${provider.model}`,
300
+ };
301
+ }
302
+ const ticketPattern = readTicketsConfig(repoRoot).pattern ?? DEFAULT_TICKET_PATTERN;
303
+ const proposed = [];
304
+ let skippedDuplicates = 0;
305
+ for (const c of commits) {
306
+ // Pure merge noise never carries signal — skip the LLM call entirely.
307
+ if (/^Merge branch /i.test(c.subject) && !c.body.trim())
308
+ continue;
309
+ const user = `Commit ${c.sha.slice(0, 12)}\nSubject: ${c.subject}\nBody:\n${c.body || "(none)"}\n\n` +
310
+ `Diff (truncated):\n${commitDiff(repoRoot, c.sha)}`;
311
+ let claims;
312
+ try {
313
+ claims = parseClaims(await provider.generate(COMMIT_EXTRACT_INSTRUCTIONS, user));
314
+ }
315
+ catch (err) {
316
+ debugLog(`llm mining commit ${c.sha.slice(0, 8)} (skipped)`, err);
317
+ continue; // provider hiccup on one commit shouldn't kill the run
318
+ }
319
+ const ticketRef = extractTicketId(`${c.subject}\n${c.body}`, ticketPattern) ?? undefined;
320
+ for (const cl of claims.slice(0, 2)) {
321
+ const evidence = [{ type: "COMMIT_REF", payload: c.sha }];
322
+ if (cl.staticCheck)
323
+ evidence.push({ type: "STATIC_CHECK", payload: cl.staticCheck });
324
+ if (ticketRef)
325
+ evidence.push({ type: "TICKET_REF", payload: ticketRef });
326
+ const p = store.propose({
327
+ kind: cl.kind,
328
+ claim: cl.claim,
329
+ paths: cl.paths ?? scopeFromFiles(c.files),
330
+ symbols: cl.symbols,
331
+ guardrailLevel: cl.guardrailLevel,
332
+ evidence,
333
+ source: "commit-miner",
334
+ sourceRef: c.sha,
335
+ rationale: cl.rationale ?? `LLM-mined from commit ${c.sha.slice(0, 8)}: ${c.subject}`,
336
+ ticketRef,
337
+ });
338
+ if (p)
339
+ proposed.push(p);
340
+ else
341
+ skippedDuplicates++;
342
+ }
343
+ }
344
+ const head = commits.length > 0 ? commits[0].sha : sinceSha;
345
+ if (head)
346
+ store.setMeta(MINER_CURSOR_KEY, head);
347
+ return {
348
+ scanned: commits.length,
349
+ proposed,
350
+ skippedDuplicates,
351
+ lastSha: head ?? null,
352
+ provider: `${provider.name}/${provider.model}`,
353
+ };
354
+ }
355
+ /** Human-readable summary of a mine run (CLI + MCP). */
356
+ export function describeMineResult(r, opts = {}) {
357
+ if (r.noCommits)
358
+ return "No git commits yet — make an initial commit before mining history.";
359
+ if (r.noNewCommits) {
360
+ return (`No new commits since the last mine (cursor @ ${r.lastSha?.slice(0, 8) ?? "?"}). ` +
361
+ "Use full=true (or `dim mine --full`) to rescan all history.");
362
+ }
363
+ const llmNote = opts.llmRequested && !opts.llmProvider
364
+ ? " (no LLM provider — fell back to keyword mining; run Ollama or set OPENAI_API_KEY for llm=true)"
365
+ : opts.llmProvider
366
+ ? ` with ${opts.llmProvider}`
367
+ : "";
368
+ let msg = `Scanned ${r.scanned} commit(s)${llmNote}: ${r.proposed.length} proposal(s) queued` +
369
+ (r.skippedDuplicates ? `, ${r.skippedDuplicates} duplicate(s) skipped` : "") +
370
+ (r.lastSha ? ` (cursor @ ${r.lastSha.slice(0, 8)})` : "");
371
+ if (r.proposed.length === 0 && r.scanned > 0) {
372
+ msg +=
373
+ "\nNone matched memory-worthy signals — try descriptive commit messages" +
374
+ (opts.llmRequested ? "." : " or enable llm=true / `dim mine --llm`.");
375
+ }
376
+ else if (r.proposed.length > 0) {
377
+ msg += "\nReview with `dim review`.";
378
+ }
379
+ return msg;
380
+ }
381
+ //# sourceMappingURL=commit-miner.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Transcript harvester — out-of-band capture of the context humans type into
3
+ * AI chats. Claude Code persists every session as JSONL under
4
+ * ~/.claude/projects/<path-slug>/*.jsonl; the USER messages in there are the
5
+ * highest-signal capture source aidimag has: they're the facts a human already
6
+ * decided were worth teaching an AI ("we use X because Y", "never touch Z").
7
+ *
8
+ * `dim harvest` extracts durable, falsifiable claims from those messages with
9
+ * the configured LLM provider (OpenAI/Ollama, same fallback as knowledge
10
+ * ingestion) and queues them as proposals (source `harvest:claude-code`) —
11
+ * nothing becomes active memory without `dim review`.
12
+ *
13
+ * Privacy: opt-in by invocation, local-only (transcripts never leave the
14
+ * machine except to the LLM provider you configured), and secret-looking lines
15
+ * are redacted before extraction. `--install-hook` wires a Claude Code
16
+ * SessionEnd hook so harvesting runs automatically when a session closes.
17
+ */
18
+ import type { MemoryStore } from "../db/store.js";
19
+ export interface HarvestResult {
20
+ sessionsScanned: number;
21
+ messagesConsidered: number;
22
+ proposed: number;
23
+ duplicates: number;
24
+ provider: string | null;
25
+ transcriptDir: string | null;
26
+ }
27
+ /** Claude Code stores transcripts under a slug of the project's absolute path. */
28
+ export declare function claudeProjectDir(repoRoot: string): string | null;
29
+ /** Very conservative redaction: drop lines that look like secrets before they reach any LLM. */
30
+ export declare function redactSecrets(text: string): string;
31
+ /** Extract genuine human-typed messages from one Claude Code session JSONL. */
32
+ export declare function userMessagesFromTranscript(jsonl: string): string[];
33
+ export declare const HARVEST_EXTRACT_INSTRUCTIONS = "You are reviewing messages a DEVELOPER typed into an AI coding assistant while working on their project. These messages often contain durable project knowledge the developer was teaching the AI: decisions, conventions, gotchas, failed approaches, architecture facts, rules.\n\nExtract that durable knowledge as FALSIFIABLE claims. Rules:\n\n1. Only durable, project-specific facts the HUMAN stated \u2014 not the task of the day, not questions, not generic programming advice.\n2. Write each claim as a checkable statement about the codebase.\n3. kinds: DECISION, CONVENTION, GOTCHA, FAILED_APPROACH, ARCHITECTURE, INVARIANT, GUARDRAIL (set guardrail_level: never|ask-first|always), SKILL, TODO_CONTEXT.\n4. Scope with paths/symbols when the messages name them; else leave empty.\n5. In \"rationale\", QUOTE the fragment of the developer's message the claim came from.\n6. Extract 0\u20138 claims. Zero is fine \u2014 most sessions contain none. Do NOT invent.\n\nRespond with ONLY a JSON object of this exact shape:\n{\"claims\":[{\"kind\":\"CONVENTION\",\"claim\":\"...\",\"paths\":[\"src/x\"],\"symbols\":[],\"guardrail_level\":null,\"rationale\":\"user said: \\\"...\\\"\"}]}";
34
+ /**
35
+ * Harvest new/updated Claude Code sessions for this repo into the proposal
36
+ * queue. Cursor-tracked by file mtime; `all` rescans everything (the proposal
37
+ * dedupe index absorbs repeats).
38
+ */
39
+ export declare function harvestClaudeSessions(store: MemoryStore, repoRoot: string, opts?: {
40
+ all?: boolean;
41
+ }): Promise<HarvestResult>;
42
+ /**
43
+ * Wire `dim harvest -q` into the repo's Claude Code SessionEnd hook
44
+ * (.claude/settings.json) so every session is harvested when it closes.
45
+ * Additive: merges with existing settings, never clobbers other hooks.
46
+ */
47
+ export declare function installClaudeSessionEndHook(repoRoot: string): {
48
+ installed: boolean;
49
+ settingsPath: string;
50
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Transcript harvester — out-of-band capture of the context humans type into
3
+ * AI chats. Claude Code persists every session as JSONL under
4
+ * ~/.claude/projects/<path-slug>/*.jsonl; the USER messages in there are the
5
+ * highest-signal capture source aidimag has: they're the facts a human already
6
+ * decided were worth teaching an AI ("we use X because Y", "never touch Z").
7
+ *
8
+ * `dim harvest` extracts durable, falsifiable claims from those messages with
9
+ * the configured LLM provider (OpenAI/Ollama, same fallback as knowledge
10
+ * ingestion) and queues them as proposals (source `harvest:claude-code`) —
11
+ * nothing becomes active memory without `dim review`.
12
+ *
13
+ * Privacy: opt-in by invocation, local-only (transcripts never leave the
14
+ * machine except to the LLM provider you configured), and secret-looking lines
15
+ * are redacted before extraction. `--install-hook` wires a Claude Code
16
+ * SessionEnd hook so harvesting runs automatically when a session closes.
17
+ */
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import path from "node:path";
21
+ import { getTextProvider } from "../knowledge/llm.js";
22
+ import { parseClaims } from "../knowledge/extract.js";
23
+ import { debugLog } from "../debug.js";
24
+ const CURSOR_META_KEY = "harvest_claude_last_mtime";
25
+ /** Ignore short/noisy user turns ("yes", "continue", slash commands…). */
26
+ const MIN_MESSAGE_CHARS = 40;
27
+ /** Cap what we send to the LLM per session (chars). */
28
+ const MAX_SESSION_CHARS = 24_000;
29
+ /** Claude Code stores transcripts under a slug of the project's absolute path. */
30
+ export function claudeProjectDir(repoRoot) {
31
+ const slug = path.resolve(repoRoot).replace(/[^a-zA-Z0-9]/g, "-");
32
+ const dir = path.join(homedir(), ".claude", "projects", slug);
33
+ return existsSync(dir) ? dir : null;
34
+ }
35
+ /** Very conservative redaction: drop lines that look like secrets before they reach any LLM. */
36
+ export function redactSecrets(text) {
37
+ const SECRET_LINE = /(api[-_]?key|secret|token|password|passwd|authorization|bearer\s+[a-z0-9_-]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY|aws_access_key_id|sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{20,}|xox[baprs]-)/i;
38
+ return text
39
+ .split("\n")
40
+ .map((line) => (SECRET_LINE.test(line) ? "[REDACTED — possible secret]" : line))
41
+ .join("\n");
42
+ }
43
+ /** Extract genuine human-typed messages from one Claude Code session JSONL. */
44
+ export function userMessagesFromTranscript(jsonl) {
45
+ const out = [];
46
+ for (const line of jsonl.split("\n")) {
47
+ if (!line.trim())
48
+ continue;
49
+ let entry;
50
+ try {
51
+ entry = JSON.parse(line);
52
+ }
53
+ catch {
54
+ continue;
55
+ }
56
+ if (entry.type !== "user" || entry.isMeta)
57
+ continue;
58
+ const message = entry.message;
59
+ if (!message || message.role !== "user")
60
+ continue;
61
+ let text = "";
62
+ if (typeof message.content === "string") {
63
+ text = message.content;
64
+ }
65
+ else if (Array.isArray(message.content)) {
66
+ // tool_result blocks are machine output, not the human — skip them
67
+ text = message.content
68
+ .filter((c) => c.type === "text" && typeof c.text === "string")
69
+ .map((c) => c.text)
70
+ .join("\n");
71
+ }
72
+ text = text.trim();
73
+ // skip injected command/system scaffolding and trivial turns
74
+ if (!text || text.startsWith("<") || text.startsWith("/"))
75
+ continue;
76
+ if (text.length < MIN_MESSAGE_CHARS)
77
+ continue;
78
+ out.push(text);
79
+ }
80
+ return out;
81
+ }
82
+ export const HARVEST_EXTRACT_INSTRUCTIONS = `You are reviewing messages a DEVELOPER typed into an AI coding assistant while working on their project. These messages often contain durable project knowledge the developer was teaching the AI: decisions, conventions, gotchas, failed approaches, architecture facts, rules.
83
+
84
+ Extract that durable knowledge as FALSIFIABLE claims. Rules:
85
+
86
+ 1. Only durable, project-specific facts the HUMAN stated — not the task of the day, not questions, not generic programming advice.
87
+ 2. Write each claim as a checkable statement about the codebase.
88
+ 3. kinds: DECISION, CONVENTION, GOTCHA, FAILED_APPROACH, ARCHITECTURE, INVARIANT, GUARDRAIL (set guardrail_level: never|ask-first|always), SKILL, TODO_CONTEXT.
89
+ 4. Scope with paths/symbols when the messages name them; else leave empty.
90
+ 5. In "rationale", QUOTE the fragment of the developer's message the claim came from.
91
+ 6. Extract 0–8 claims. Zero is fine — most sessions contain none. Do NOT invent.
92
+
93
+ Respond with ONLY a JSON object of this exact shape:
94
+ {"claims":[{"kind":"CONVENTION","claim":"...","paths":["src/x"],"symbols":[],"guardrail_level":null,"rationale":"user said: \\"...\\""}]}`;
95
+ function pendingSessions(dir, sinceMtimeMs, all) {
96
+ return readdirSync(dir)
97
+ .filter((f) => f.endsWith(".jsonl"))
98
+ .map((f) => {
99
+ const abs = path.join(dir, f);
100
+ return { file: f, abs, mtimeMs: statSync(abs).mtimeMs };
101
+ })
102
+ .filter((s) => all || s.mtimeMs > sinceMtimeMs)
103
+ .sort((a, b) => a.mtimeMs - b.mtimeMs);
104
+ }
105
+ /**
106
+ * Harvest new/updated Claude Code sessions for this repo into the proposal
107
+ * queue. Cursor-tracked by file mtime; `all` rescans everything (the proposal
108
+ * dedupe index absorbs repeats).
109
+ */
110
+ export async function harvestClaudeSessions(store, repoRoot, opts = {}) {
111
+ const result = {
112
+ sessionsScanned: 0,
113
+ messagesConsidered: 0,
114
+ proposed: 0,
115
+ duplicates: 0,
116
+ provider: null,
117
+ transcriptDir: null,
118
+ };
119
+ const dir = claudeProjectDir(repoRoot);
120
+ if (!dir)
121
+ return result;
122
+ result.transcriptDir = dir;
123
+ const provider = await getTextProvider();
124
+ if (!provider)
125
+ return result;
126
+ result.provider = `${provider.name}/${provider.model}`;
127
+ const cursor = opts.all ? 0 : parseFloat(store.getMeta(CURSOR_META_KEY) ?? "0") || 0;
128
+ const sessions = pendingSessions(dir, cursor, Boolean(opts.all));
129
+ let maxMtime = cursor;
130
+ for (const s of sessions) {
131
+ result.sessionsScanned++;
132
+ maxMtime = Math.max(maxMtime, s.mtimeMs);
133
+ let messages;
134
+ try {
135
+ messages = userMessagesFromTranscript(readFileSync(s.abs, "utf8"));
136
+ }
137
+ catch (err) {
138
+ debugLog(`harvest transcript ${s.file} (skipped)`, err);
139
+ continue; // unreadable/partial file — retry next run (cursor still advances past it)
140
+ }
141
+ if (!messages.length)
142
+ continue;
143
+ result.messagesConsidered += messages.length;
144
+ const corpus = redactSecrets(messages.join("\n\n---\n\n")).slice(0, MAX_SESSION_CHARS);
145
+ let claims;
146
+ try {
147
+ const raw = await provider.generate(HARVEST_EXTRACT_INSTRUCTIONS, `Developer messages from one coding session on this project:\n\n----- BEGIN MESSAGES -----\n${corpus}\n----- END MESSAGES -----`);
148
+ claims = parseClaims(raw);
149
+ }
150
+ catch (err) {
151
+ debugLog(`harvest llm extraction ${s.file} (skipped)`, err);
152
+ continue; // provider hiccup — this session retries on the next --all run
153
+ }
154
+ const sessionId = s.file.replace(/\.jsonl$/, "");
155
+ for (const c of claims) {
156
+ const p = store.propose({
157
+ kind: c.kind,
158
+ claim: c.claim,
159
+ paths: c.paths,
160
+ symbols: c.symbols,
161
+ guardrailLevel: c.guardrailLevel,
162
+ rationale: c.rationale ?? "Stated by the user in a Claude Code session.",
163
+ evidence: [
164
+ { type: "HUMAN_ATTESTED", payload: `stated by user in Claude Code session ${sessionId.slice(0, 8)}` },
165
+ ],
166
+ source: "harvest:claude-code",
167
+ sourceRef: sessionId,
168
+ });
169
+ if (p)
170
+ result.proposed++;
171
+ else
172
+ result.duplicates++;
173
+ }
174
+ }
175
+ if (maxMtime > cursor)
176
+ store.setMeta(CURSOR_META_KEY, String(maxMtime));
177
+ return result;
178
+ }
179
+ // ---------------------------------------------------------------- hook install
180
+ const HOOK_COMMAND = "dim harvest -q";
181
+ /**
182
+ * Wire `dim harvest -q` into the repo's Claude Code SessionEnd hook
183
+ * (.claude/settings.json) so every session is harvested when it closes.
184
+ * Additive: merges with existing settings, never clobbers other hooks.
185
+ */
186
+ export function installClaudeSessionEndHook(repoRoot) {
187
+ const settingsPath = path.join(repoRoot, ".claude", "settings.json");
188
+ let settings = {};
189
+ if (existsSync(settingsPath)) {
190
+ try {
191
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
192
+ }
193
+ catch {
194
+ throw new Error(`${settingsPath} exists but is not valid JSON — fix it before installing the hook.`);
195
+ }
196
+ }
197
+ const hooks = (settings.hooks ??= {});
198
+ const sessionEnd = (hooks.SessionEnd ??= []);
199
+ const already = sessionEnd.some((m) => m.hooks?.some((h) => h.command?.includes("dim harvest")));
200
+ if (already)
201
+ return { installed: false, settingsPath };
202
+ sessionEnd.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] });
203
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
204
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
205
+ return { installed: true, settingsPath };
206
+ }
207
+ //# sourceMappingURL=harvest.js.map