autoctxd 0.4.1

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 (50) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/CONTRIBUTING.md +80 -0
  3. package/LICENSE +21 -0
  4. package/README.md +301 -0
  5. package/SECURITY.md +81 -0
  6. package/package.json +55 -0
  7. package/scripts/install-hooks.ts +80 -0
  8. package/scripts/install.ps1 +71 -0
  9. package/scripts/install.sh +67 -0
  10. package/scripts/uninstall-hooks.ts +57 -0
  11. package/src/ai/active-guard.ts +96 -0
  12. package/src/ai/adaptive-ranker.ts +48 -0
  13. package/src/ai/classifier.ts +256 -0
  14. package/src/ai/compressor.ts +129 -0
  15. package/src/ai/decision-chains.ts +100 -0
  16. package/src/ai/decision-extractor.ts +148 -0
  17. package/src/ai/pattern-detector.ts +147 -0
  18. package/src/ai/proactive.ts +78 -0
  19. package/src/cli/doctor.ts +171 -0
  20. package/src/cli/embeddings.ts +209 -0
  21. package/src/cli/index.ts +574 -0
  22. package/src/cli/reclassify.ts +134 -0
  23. package/src/context/builder.ts +97 -0
  24. package/src/context/formatter.ts +109 -0
  25. package/src/context/ranker.ts +84 -0
  26. package/src/db/sqlite/decisions.ts +56 -0
  27. package/src/db/sqlite/feedback.ts +92 -0
  28. package/src/db/sqlite/observations.ts +58 -0
  29. package/src/db/sqlite/schema.ts +366 -0
  30. package/src/db/sqlite/sessions.ts +50 -0
  31. package/src/db/sqlite/summaries.ts +69 -0
  32. package/src/db/vector/client.ts +134 -0
  33. package/src/db/vector/embeddings.ts +119 -0
  34. package/src/db/vector/providers/factory.ts +99 -0
  35. package/src/db/vector/providers/minilm.ts +90 -0
  36. package/src/db/vector/providers/ollama.ts +92 -0
  37. package/src/db/vector/providers/tfidf.ts +98 -0
  38. package/src/db/vector/providers/types.ts +39 -0
  39. package/src/db/vector/search.ts +131 -0
  40. package/src/hooks/post-tool-use.ts +205 -0
  41. package/src/hooks/pre-tool-use.ts +305 -0
  42. package/src/hooks/stop.ts +334 -0
  43. package/src/mcp/server.ts +293 -0
  44. package/src/server/dashboard.html +268 -0
  45. package/src/server/dashboard.ts +170 -0
  46. package/src/util/debug.ts +56 -0
  47. package/src/util/ignore.ts +171 -0
  48. package/src/util/metrics.ts +236 -0
  49. package/src/util/path.ts +57 -0
  50. package/tsconfig.json +14 -0
@@ -0,0 +1,574 @@
1
+ #!/usr/bin/env bun
2
+ // autoctxd CLI — Full command suite
3
+
4
+ import { getDb, closeDb } from "../db/sqlite/schema";
5
+ import { hybridSearch } from "../db/vector/search";
6
+ import { closeVectorDb } from "../db/vector/client";
7
+ import { runReclassifyCli } from "./reclassify";
8
+ import { runDoctor } from "./doctor";
9
+ import { runEmbeddingsCommand } from "./embeddings";
10
+ import { getGlobalMetrics } from "../util/metrics";
11
+ import { getFeedbackStats } from "../db/sqlite/feedback";
12
+ import { startDashboardServer } from "../server/dashboard";
13
+
14
+ const args = process.argv.slice(2);
15
+ const command = args[0];
16
+
17
+ async function main() {
18
+ try {
19
+ switch (command) {
20
+ case "stats":
21
+ stats();
22
+ break;
23
+ case "sessions":
24
+ sessions();
25
+ break;
26
+ case "decisions":
27
+ decisions(args[1]);
28
+ break;
29
+ case "search":
30
+ await search(args.slice(1).join(" "));
31
+ break;
32
+ case "show":
33
+ show(args[1], args[2]);
34
+ break;
35
+ case "patterns":
36
+ patterns(args[1]);
37
+ break;
38
+ case "digest":
39
+ digest(args[1]);
40
+ break;
41
+ case "export":
42
+ exportContext(args[1]);
43
+ break;
44
+ case "init":
45
+ init();
46
+ break;
47
+ case "reset":
48
+ reset();
49
+ break;
50
+ case "reclassify":
51
+ runReclassifyCli(args.slice(1));
52
+ break;
53
+ case "doctor":
54
+ process.exit(await runDoctor());
55
+ break;
56
+ case "metrics":
57
+ showMetrics();
58
+ break;
59
+ case "dashboard":
60
+ await startDashboardServer(parseInt(args[1] || "4589", 10));
61
+ return; // keep process alive
62
+ case "mcp":
63
+ await import("../mcp/server");
64
+ return; // server owns the event loop
65
+ case "feedback":
66
+ showFeedbackStats();
67
+ break;
68
+ case "embeddings":
69
+ process.exit(await runEmbeddingsCommand(args.slice(1)));
70
+ break;
71
+ case "cleanup-decisions":
72
+ cleanupDecisions(args.slice(1));
73
+ break;
74
+ default:
75
+ help();
76
+ }
77
+ } finally {
78
+ closeDb();
79
+ await closeVectorDb();
80
+ }
81
+ }
82
+
83
+ function help() {
84
+ console.log(`
85
+ autoctxd — Persistent memory for Claude Code
86
+
87
+ Commands:
88
+ stats Usage statistics
89
+ sessions List recent sessions
90
+ decisions [project] Show architectural decisions
91
+ search <query> Hybrid search (semantic + full-text)
92
+ show session <id> Show session details
93
+ patterns [project] Show detected work patterns
94
+ digest [project] Show/generate weekly digests
95
+ export [project] Export context to markdown
96
+ init Initialize database
97
+ reset Clear all data (careful!)
98
+ reclassify [--dry-run] [--all] [--recompress] [--project <name>]
99
+ Re-run classifier on existing observations.
100
+ (defaults to only 'other'; --all for every row)
101
+ --recompress also rewrites historical session
102
+ summaries with the current compressor.
103
+ --project <name> scopes recompression to one project.
104
+ doctor Verify install integrity
105
+ metrics Show token-savings metrics
106
+ dashboard [port] Launch local web dashboard (default port 4589)
107
+ mcp Run as MCP server (stdio) — for Claude Desktop/Cursor/Windsurf
108
+ feedback Show learning stats (feedback accumulated)
109
+ embeddings <sub> Manage embedding provider (list/status/switch/reembed)
110
+ cleanup-decisions [--dry-run]
111
+ Delete decisions left over from the pre-0.4 extractor
112
+ (npm install of generic words, monitor noise) and
113
+ collapse cross-session duplicates.
114
+
115
+ Examples:
116
+ autoctxd search "race condition async"
117
+ autoctxd decisions ./my-app
118
+ autoctxd export ./my-app > context.md
119
+ autoctxd stats
120
+ `);
121
+ }
122
+
123
+ function init() {
124
+ getDb();
125
+ console.log("Database initialized successfully.");
126
+ }
127
+
128
+ function reset() {
129
+ const db = getDb();
130
+ const tables = ["observations", "summaries", "decisions", "patterns",
131
+ "embeddings_cache", "token_metrics", "sessions"];
132
+ for (const table of tables) {
133
+ db.exec(`DELETE FROM ${table}`);
134
+ }
135
+ // Reset FTS
136
+ try {
137
+ db.exec("DELETE FROM observations_fts");
138
+ db.exec("DELETE FROM decisions_fts");
139
+ } catch {}
140
+ console.log("All data cleared.");
141
+ }
142
+
143
+ function cleanupDecisions(flags: string[]) {
144
+ const db = getDb();
145
+ const dryRun = flags.includes("--dry-run");
146
+
147
+ // Patterns that the pre-0.4 extractor produced from log noise. Anything
148
+ // here is verifiably not an architectural decision — it's a generic word
149
+ // the regex captured because "npm install <word>" appeared in a Monitor
150
+ // command, a comment, or a task description.
151
+ const noiseTitles = [
152
+ "Added npm dep: output",
153
+ "Added npm dep: progress",
154
+ "Added npm dep: tsx",
155
+ "Added npm dep: tests",
156
+ "Added npm dep: test",
157
+ "Added npm dep: dev",
158
+ "Added npm dep: prod",
159
+ "Added npm dep: build",
160
+ "Added npm dep: start",
161
+ "Added npm dep: all",
162
+ "Added npm dep: true",
163
+ "Added npm dep: false",
164
+ "Added npm dep: latest",
165
+ "Added npm dep: debug",
166
+ "Added bun dep: output",
167
+ "Added bun dep: progress",
168
+ "Added pnpm dep: output",
169
+ "Added pnpm dep: progress",
170
+ "Added yarn dep: output",
171
+ "Added yarn dep: progress",
172
+ "Added pip dep: output",
173
+ "Added pip dep: progress",
174
+ ];
175
+
176
+ const placeholders = noiseTitles.map(() => "?").join(",");
177
+
178
+ // Tool-prefixed rows that the pre-0.4 extractor allowed through whenever
179
+ // the classifier scored an observation as type=decision (a stray keyword
180
+ // would do it). New code rejects these at the source; this clears the
181
+ // historical leftovers so the dashboard stops showing them.
182
+ const prefixNoiseClause = `(
183
+ decision_text LIKE 'Bash:%'
184
+ OR decision_text LIKE 'PowerShell:%'
185
+ OR decision_text LIKE 'Edit:%'
186
+ OR decision_text LIKE 'Edited %'
187
+ OR decision_text LIKE 'Monitor:%'
188
+ OR decision_text LIKE 'ToolSearch:%'
189
+ OR decision_text LIKE 'TodoWrite%'
190
+ OR decision_text LIKE 'TaskStop:%'
191
+ OR decision_text LIKE 'TaskOutput:%'
192
+ )`;
193
+
194
+ const before = (db.prepare("SELECT COUNT(*) as c FROM decisions").get() as any).c;
195
+
196
+ const noiseCount = (db.prepare(
197
+ `SELECT COUNT(*) as c FROM decisions WHERE title IN (${placeholders})`
198
+ ).get(...noiseTitles) as any).c;
199
+
200
+ const prefixCount = (db.prepare(
201
+ `SELECT COUNT(*) as c FROM decisions WHERE ${prefixNoiseClause}`
202
+ ).get() as any).c;
203
+
204
+ const dupCount = (db.prepare(`
205
+ SELECT COUNT(*) as c FROM decisions
206
+ WHERE id NOT IN (
207
+ SELECT MIN(id) FROM decisions
208
+ GROUP BY COALESCE(project_path, ''), title
209
+ )
210
+ `).get() as any).c;
211
+
212
+ console.log("\nautoctxd cleanup-decisions");
213
+ console.log("─".repeat(40));
214
+ console.log(` decisions in DB: ${before}`);
215
+ console.log(` generic-word noise: ${noiseCount}`);
216
+ console.log(` tool-prefixed noise: ${prefixCount}`);
217
+ console.log(` cross-session duplicates: ${dupCount}`);
218
+
219
+ if (dryRun) {
220
+ console.log("\n (--dry-run — no changes written)");
221
+ return;
222
+ }
223
+
224
+ db.exec("BEGIN");
225
+ try {
226
+ db.prepare(`DELETE FROM decisions WHERE title IN (${placeholders})`).run(...noiseTitles);
227
+ db.exec(`DELETE FROM decisions WHERE ${prefixNoiseClause}`);
228
+ db.exec(`
229
+ DELETE FROM decisions WHERE id NOT IN (
230
+ SELECT MIN(id) FROM decisions
231
+ GROUP BY COALESCE(project_path, ''), title
232
+ )
233
+ `);
234
+ db.exec("COMMIT");
235
+ } catch (e) {
236
+ db.exec("ROLLBACK");
237
+ throw e;
238
+ }
239
+
240
+ const after = (db.prepare("SELECT COUNT(*) as c FROM decisions").get() as any).c;
241
+ console.log(`\n deleted: ${before - after}`);
242
+ console.log(` remaining: ${after}`);
243
+ }
244
+
245
+ function stats() {
246
+ const db = getDb();
247
+ const sessionCount = (db.prepare("SELECT COUNT(*) as c FROM sessions").get() as any).c;
248
+ const obsCount = (db.prepare("SELECT COUNT(*) as c FROM observations").get() as any).c;
249
+ const decisionCount = (db.prepare("SELECT COUNT(*) as c FROM decisions").get() as any).c;
250
+ const patternCount = (db.prepare("SELECT COUNT(*) as c FROM patterns").get() as any).c;
251
+ const summaryCount = (db.prepare("SELECT COUNT(*) as c FROM summaries").get() as any).c;
252
+ const cacheCount = (db.prepare("SELECT COUNT(*) as c FROM embeddings_cache").get() as any).c;
253
+
254
+ const topTypes = db.prepare(`
255
+ SELECT type, COUNT(*) as c FROM observations GROUP BY type ORDER BY c DESC LIMIT 8
256
+ `).all() as Array<{ type: string; c: number }>;
257
+
258
+ const topProjects = db.prepare(`
259
+ SELECT project_path, COUNT(*) as c FROM sessions
260
+ WHERE project_path IS NOT NULL
261
+ GROUP BY project_path ORDER BY c DESC LIMIT 5
262
+ `).all() as Array<{ project_path: string; c: number }>;
263
+
264
+ console.log(`
265
+ ╔══════════════════════════════════════╗
266
+ ║ autoctxd Statistics ║
267
+ ╚══════════════════════════════════════╝
268
+
269
+ Sessions: ${sessionCount}
270
+ Observations: ${obsCount}
271
+ Summaries: ${summaryCount}
272
+ Decisions: ${decisionCount}
273
+ Patterns: ${patternCount}
274
+ Cached embeds: ${cacheCount}
275
+ `);
276
+
277
+ if (topTypes.length > 0) {
278
+ console.log("Observation types:");
279
+ for (const t of topTypes) {
280
+ const bar = "█".repeat(Math.min(20, Math.ceil(t.c / Math.max(1, topTypes[0].c) * 20)));
281
+ console.log(` ${t.type.padEnd(15)} ${bar} ${t.c}`);
282
+ }
283
+ console.log("");
284
+ }
285
+
286
+ if (topProjects.length > 0) {
287
+ console.log("Top projects:");
288
+ for (const p of topProjects) {
289
+ console.log(` ${p.project_path} (${p.c} sessions)`);
290
+ }
291
+ }
292
+ }
293
+
294
+ function sessions() {
295
+ const db = getDb();
296
+ const rows = db.prepare(`
297
+ SELECT session_id, project_path, git_branch, started_at, ended_at, total_observations
298
+ FROM sessions ORDER BY started_at DESC LIMIT 20
299
+ `).all() as any[];
300
+
301
+ console.log("\nRecent Sessions:");
302
+ console.log("─".repeat(90));
303
+ for (const r of rows) {
304
+ const status = r.ended_at ? "done" : "active";
305
+ const branch = r.git_branch ? ` [${r.git_branch}]` : "";
306
+ const path = r.project_path ? truncPath(r.project_path, 30) : "?";
307
+ console.log(` [${status.padEnd(6)}] ${r.session_id.slice(0, 12)}... | ${path}${branch} | ${r.started_at} | ${r.total_observations || 0} obs`);
308
+ }
309
+ }
310
+
311
+ function decisions(project?: string) {
312
+ const db = getDb();
313
+ const query = project
314
+ ? db.prepare("SELECT * FROM decisions WHERE project_path LIKE ? ORDER BY created_at DESC").all(`%${project}%`)
315
+ : db.prepare("SELECT * FROM decisions ORDER BY created_at DESC LIMIT 20").all();
316
+
317
+ console.log("\nArchitectural Decisions:");
318
+ console.log("─".repeat(80));
319
+ for (const d of query as any[]) {
320
+ console.log(` [${(d.created_at || "").slice(0, 10)}] ${d.title}`);
321
+ console.log(` ${d.decision_text.slice(0, 120)}`);
322
+ if (d.alternatives) console.log(` Rejected: ${d.alternatives}`);
323
+ if (d.rationale) console.log(` Reason: ${d.rationale}`);
324
+ if (d.files_affected) console.log(` Files: ${d.files_affected}`);
325
+ console.log("");
326
+ }
327
+
328
+ if ((query as any[]).length === 0) {
329
+ console.log(" No decisions recorded yet.");
330
+ }
331
+ }
332
+
333
+ async function search(query: string) {
334
+ if (!query) {
335
+ console.log("Usage: autoctxd search <query>");
336
+ return;
337
+ }
338
+
339
+ console.log(`\nSearching for "${query}"...`);
340
+ console.log("─".repeat(80));
341
+
342
+ const results = await hybridSearch(query, { limit: 15 });
343
+
344
+ for (const r of results) {
345
+ const icon = r.source === "vector" ? "~" : r.source === "fts_decisions" ? "!" : ">";
346
+ console.log(` [${icon}] ${r.text.slice(0, 120)}`);
347
+ if (r.metadata.project_path) console.log(` Project: ${r.metadata.project_path}`);
348
+ if (r.metadata.created_at) console.log(` Date: ${r.metadata.created_at}`);
349
+ if (r.metadata.type) console.log(` Type: ${r.metadata.type} | Score: ${r.metadata.importance}`);
350
+ console.log("");
351
+ }
352
+
353
+ if (results.length === 0) {
354
+ console.log(" No results found.");
355
+ } else {
356
+ console.log(` Legend: [~] semantic match [!] decision match [>] text match`);
357
+ }
358
+ }
359
+
360
+ function show(type: string, id: string) {
361
+ if (type !== "session" || !id) {
362
+ console.log("Usage: autoctxd show session <id>");
363
+ return;
364
+ }
365
+
366
+ const db = getDb();
367
+ const session = db.prepare("SELECT * FROM sessions WHERE session_id LIKE ?").get(`${id}%`) as any;
368
+ if (!session) {
369
+ console.log("Session not found.");
370
+ return;
371
+ }
372
+
373
+ console.log(`\n╔══════════════════════════════════════╗`);
374
+ console.log(`║ Session: ${session.session_id.slice(0, 26)} ║`);
375
+ console.log(`╚══════════════════════════════════════╝`);
376
+ console.log(`Project: ${session.project_path}`);
377
+ console.log(`Branch: ${session.git_branch || "n/a"}`);
378
+ console.log(`Started: ${session.started_at}`);
379
+ console.log(`Ended: ${session.ended_at || "still active"}`);
380
+ console.log(`Total obs: ${session.total_observations || 0}`);
381
+
382
+ const obs = db.prepare(`
383
+ SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp ASC
384
+ `).all(session.session_id) as any[];
385
+
386
+ if (obs.length > 0) {
387
+ console.log("\nTimeline:");
388
+ console.log("─".repeat(80));
389
+ for (const o of obs) {
390
+ const time = o.timestamp.split(" ")[1] || o.timestamp;
391
+ const imp = "●".repeat(Math.min(5, Math.ceil(o.importance_score / 2)));
392
+ console.log(` ${time} [${o.type.padEnd(12)}] ${imp} ${o.summary.slice(0, 80)}`);
393
+ if (o.file_paths) {
394
+ console.log(`${"".padEnd(12)}files: ${o.file_paths}`);
395
+ }
396
+ }
397
+ }
398
+
399
+ const summaries = db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(session.session_id) as any[];
400
+ if (summaries.length > 0) {
401
+ console.log("\nSession Summary:");
402
+ console.log("─".repeat(80));
403
+ console.log(summaries[0].text);
404
+ }
405
+ }
406
+
407
+ function patterns(project?: string) {
408
+ const db = getDb();
409
+ const query = project
410
+ ? db.prepare("SELECT * FROM patterns WHERE project_path LIKE ? ORDER BY frequency DESC").all(`%${project}%`)
411
+ : db.prepare("SELECT * FROM patterns ORDER BY frequency DESC LIMIT 20").all();
412
+
413
+ console.log("\nDetected Patterns:");
414
+ console.log("─".repeat(80));
415
+ for (const p of query as any[]) {
416
+ const freq = "█".repeat(Math.min(10, p.frequency));
417
+ console.log(` [${p.pattern_type}] ${p.description}`);
418
+ console.log(` Frequency: ${freq} (${p.frequency}x) | Last: ${p.last_seen}`);
419
+ if (p.examples) console.log(` Examples: ${p.examples.slice(0, 100)}`);
420
+ console.log("");
421
+ }
422
+
423
+ if ((query as any[]).length === 0) {
424
+ console.log(" No patterns detected yet. Patterns emerge after multiple sessions.");
425
+ }
426
+ }
427
+
428
+ function digest(project?: string) {
429
+ const db = getDb();
430
+ const query = project
431
+ ? db.prepare("SELECT * FROM summaries WHERE level = 2 AND project_path LIKE ? ORDER BY created_at DESC LIMIT 5").all(`%${project}%`)
432
+ : db.prepare("SELECT * FROM summaries WHERE level = 2 ORDER BY created_at DESC LIMIT 5").all();
433
+
434
+ console.log("\nWeekly Digests:");
435
+ console.log("─".repeat(80));
436
+ for (const d of query as any[]) {
437
+ console.log(` [${d.created_at}] Project: ${d.project_path || "all"}`);
438
+ console.log(d.text.split("\n").map((l: string) => ` ${l}`).join("\n"));
439
+ console.log("");
440
+ }
441
+
442
+ if ((query as any[]).length === 0) {
443
+ console.log(" No digests yet. Digests are auto-generated weekly when sessions accumulate.");
444
+ }
445
+ }
446
+
447
+ function exportContext(project?: string) {
448
+ const db = getDb();
449
+ const projectFilter = project || "%";
450
+ const isFilter = project ? true : false;
451
+
452
+ const lines: string[] = [];
453
+ lines.push("# Claude-CTX Context Export");
454
+ lines.push(`Generated: ${new Date().toISOString()}`);
455
+ if (project) lines.push(`Project filter: ${project}`);
456
+ lines.push("");
457
+
458
+ // Decisions
459
+ const decs = (isFilter
460
+ ? db.prepare("SELECT * FROM decisions WHERE project_path LIKE ? ORDER BY created_at DESC").all(`%${projectFilter}%`)
461
+ : db.prepare("SELECT * FROM decisions ORDER BY created_at DESC LIMIT 20").all()
462
+ ) as any[];
463
+
464
+ if (decs.length > 0) {
465
+ lines.push("## Architectural Decisions");
466
+ lines.push("");
467
+ for (const d of decs) {
468
+ lines.push(`### ${d.title}`);
469
+ lines.push(`- **Date:** ${d.created_at}`);
470
+ lines.push(`- **Decision:** ${d.decision_text}`);
471
+ if (d.alternatives) lines.push(`- **Rejected:** ${d.alternatives}`);
472
+ if (d.rationale) lines.push(`- **Reason:** ${d.rationale}`);
473
+ if (d.files_affected) lines.push(`- **Files:** ${d.files_affected}`);
474
+ lines.push("");
475
+ }
476
+ }
477
+
478
+ // Recent summaries
479
+ const sums = (isFilter
480
+ ? db.prepare("SELECT * FROM summaries WHERE project_path LIKE ? AND level = 1 ORDER BY created_at DESC LIMIT 10").all(`%${projectFilter}%`)
481
+ : db.prepare("SELECT * FROM summaries WHERE level = 1 ORDER BY created_at DESC LIMIT 10").all()
482
+ ) as any[];
483
+
484
+ if (sums.length > 0) {
485
+ lines.push("## Recent Session Summaries");
486
+ lines.push("");
487
+ for (const s of sums) {
488
+ lines.push(`### Session ${s.session_id || "unknown"} (${s.created_at})`);
489
+ lines.push("```");
490
+ lines.push(s.text);
491
+ lines.push("```");
492
+ lines.push("");
493
+ }
494
+ }
495
+
496
+ // Patterns
497
+ const pats = (isFilter
498
+ ? db.prepare("SELECT * FROM patterns WHERE project_path LIKE ? ORDER BY frequency DESC").all(`%${projectFilter}%`)
499
+ : db.prepare("SELECT * FROM patterns ORDER BY frequency DESC LIMIT 10").all()
500
+ ) as any[];
501
+
502
+ if (pats.length > 0) {
503
+ lines.push("## Detected Patterns");
504
+ lines.push("");
505
+ for (const p of pats as any[]) {
506
+ lines.push(`- **[${p.pattern_type}]** ${p.description} (seen ${p.frequency}x)`);
507
+ }
508
+ lines.push("");
509
+ }
510
+
511
+ // Stats
512
+ const sessionCount = (db.prepare("SELECT COUNT(*) as c FROM sessions").get() as any).c;
513
+ const obsCount = (db.prepare("SELECT COUNT(*) as c FROM observations").get() as any).c;
514
+
515
+ lines.push("## Statistics");
516
+ lines.push(`- Total sessions: ${sessionCount}`);
517
+ lines.push(`- Total observations: ${obsCount}`);
518
+ lines.push(`- Decisions: ${decs.length}`);
519
+ lines.push(`- Patterns: ${pats.length}`);
520
+ lines.push("");
521
+
522
+ console.log(lines.join("\n"));
523
+ }
524
+
525
+ function showFeedbackStats() {
526
+ const s = getFeedbackStats();
527
+ console.log(`
528
+ ╔══════════════════════════════════════╗
529
+ ║ autoctxd Learning Stats ║
530
+ ╚══════════════════════════════════════╝
531
+
532
+ Total feedback entries: ${s.total}
533
+ Useful: ${s.useful}
534
+ Irrelevant: ${s.irrelevant}
535
+ Wrong: ${s.wrong}
536
+ `);
537
+ if (s.total === 0) {
538
+ console.log(` No feedback yet. autoctxd learns as you (or Claude) call
539
+ the record_feedback MCP tool to rate injected items.
540
+ `);
541
+ return;
542
+ }
543
+ console.log("By target type:");
544
+ for (const [type, counts] of Object.entries(s.byType)) {
545
+ console.log(` ${type.padEnd(14)} useful:${counts.useful} irrelevant:${counts.irrelevant} wrong:${counts.wrong}`);
546
+ }
547
+ }
548
+
549
+ function showMetrics() {
550
+ const m = getGlobalMetrics();
551
+ console.log(`
552
+ ╔══════════════════════════════════════╗
553
+ ║ autoctxd Token Metrics ║
554
+ ╚══════════════════════════════════════╝
555
+
556
+ Sessions with injected context: ${m.sessionsWithContext}
557
+ Total tokens injected: ${m.totalInjected.toLocaleString()}
558
+ Estimated tokens saved: ~${m.totalSaved.toLocaleString()}
559
+ Avg tokens per session: ${m.avgInjectedPerSession}
560
+
561
+ ROI: for every injected token, you avoid re-explaining ~${
562
+ m.totalInjected > 0 ? (m.totalSaved / m.totalInjected).toFixed(2) : "0"
563
+ } tokens of context.
564
+ `);
565
+ }
566
+
567
+ function truncPath(path: string, maxLen: number): string {
568
+ if (path.length <= maxLen) return path;
569
+ const parts = path.split(/[/\\]/);
570
+ if (parts.length <= 2) return "..." + path.slice(-(maxLen - 3));
571
+ return "..." + parts.slice(-2).join("/");
572
+ }
573
+
574
+ main();
@@ -0,0 +1,134 @@
1
+ // Re-runs classifier on existing observations to clean up historical "other" entries.
2
+
3
+ import { getDb } from "../db/sqlite/schema";
4
+ import { classifyObservation } from "../ai/classifier";
5
+ import { compressSession } from "../ai/compressor";
6
+ import { insertSummary } from "../db/sqlite/summaries";
7
+ import { getObservationsBySession } from "../db/sqlite/observations";
8
+
9
+ interface StoredObs {
10
+ id: number;
11
+ tool_name: string | null;
12
+ summary: string;
13
+ file_paths: string | null;
14
+ type: string;
15
+ importance_score: number;
16
+ }
17
+
18
+ export function reclassifyAll(options: { onlyOther?: boolean; dryRun?: boolean } = {}): {
19
+ scanned: number;
20
+ updated: number;
21
+ changes: Map<string, number>;
22
+ } {
23
+ const { onlyOther = false, dryRun = false } = options;
24
+ const db = getDb();
25
+
26
+ const rows = (onlyOther
27
+ ? db.prepare("SELECT id, tool_name, summary, file_paths, type, importance_score FROM observations WHERE type = 'other'").all()
28
+ : db.prepare("SELECT id, tool_name, summary, file_paths, type, importance_score FROM observations").all()
29
+ ) as StoredObs[];
30
+
31
+ const update = db.prepare("UPDATE observations SET type = ?, importance_score = ? WHERE id = ?");
32
+ const changes = new Map<string, number>();
33
+ let updated = 0;
34
+
35
+ for (const row of rows) {
36
+ const filePaths = row.file_paths ? row.file_paths.split(",").map(s => s.trim()).filter(Boolean) : [];
37
+ const result = classifyObservation(row.tool_name || "", row.summary, filePaths);
38
+
39
+ if (result.type !== row.type || result.importance !== row.importance_score) {
40
+ const key = `${row.type} → ${result.type}`;
41
+ changes.set(key, (changes.get(key) || 0) + 1);
42
+
43
+ if (!dryRun) {
44
+ update.run(result.type, result.importance, row.id);
45
+ }
46
+ updated++;
47
+ }
48
+ }
49
+
50
+ return { scanned: rows.length, updated, changes };
51
+ }
52
+
53
+ export interface RecompressResult {
54
+ sessionsScanned: number;
55
+ summariesRewritten: number;
56
+ }
57
+
58
+ export function recompressAll(options: { dryRun?: boolean; project?: string } = {}): RecompressResult {
59
+ const { dryRun = false, project } = options;
60
+ const db = getDb();
61
+
62
+ // Pick sessions that already have a Level-1 summary — those are the rows that
63
+ // show up in "RECENT SESSIONS" in the injected context block.
64
+ const rows = (project
65
+ ? db.prepare(`
66
+ SELECT DISTINCT s.session_id, s.project_path
67
+ FROM summaries s
68
+ WHERE s.level = 1 AND s.session_id IS NOT NULL
69
+ AND s.project_path LIKE ?
70
+ `).all(`%${project}%`)
71
+ : db.prepare(`
72
+ SELECT DISTINCT session_id, project_path FROM summaries
73
+ WHERE level = 1 AND session_id IS NOT NULL
74
+ `).all()
75
+ ) as Array<{ session_id: string; project_path: string | null }>;
76
+
77
+ let rewritten = 0;
78
+ for (const row of rows) {
79
+ const observations = getObservationsBySession(row.session_id);
80
+ if (observations.length === 0) continue;
81
+
82
+ const summary = compressSession(observations, row.project_path || undefined);
83
+ if (!dryRun) {
84
+ // insertSummary upserts on (session_id, level=1) — see summaries.ts
85
+ insertSummary({
86
+ session_id: row.session_id,
87
+ level: 1,
88
+ text: summary.text,
89
+ project_path: row.project_path || undefined,
90
+ });
91
+ }
92
+ rewritten++;
93
+ }
94
+
95
+ return { sessionsScanned: rows.length, summariesRewritten: rewritten };
96
+ }
97
+
98
+ export function runReclassifyCli(args: string[]): void {
99
+ const dryRun = args.includes("--dry-run");
100
+ const recompress = args.includes("--recompress");
101
+ const onlyOther = args.includes("--only-other") || !args.includes("--all");
102
+ const projectIdx = args.indexOf("--project");
103
+ const project = projectIdx >= 0 ? args[projectIdx + 1] : undefined;
104
+
105
+ console.log(`\nReclassifying observations...`);
106
+ console.log(` Mode: ${onlyOther ? "only 'other' type" : "all observations"}${dryRun ? " (DRY RUN)" : ""}`);
107
+ console.log("─".repeat(60));
108
+
109
+ const { scanned, updated, changes } = reclassifyAll({ onlyOther, dryRun });
110
+
111
+ console.log(`\n Scanned: ${scanned}`);
112
+ console.log(` Updated: ${updated}${dryRun ? " (not applied)" : ""}`);
113
+
114
+ if (changes.size > 0) {
115
+ console.log(`\n Type transitions:`);
116
+ const sorted = [...changes.entries()].sort((a, b) => b[1] - a[1]);
117
+ for (const [transition, count] of sorted) {
118
+ console.log(` ${transition} (${count}x)`);
119
+ }
120
+ } else {
121
+ console.log(`\n No changes needed — classifier agrees with stored types.`);
122
+ }
123
+
124
+ if (recompress) {
125
+ console.log(`\nRecompressing historical session summaries${project ? ` for project matching "${project}"` : ""}...`);
126
+ console.log("─".repeat(60));
127
+ const r = recompressAll({ dryRun, project });
128
+ console.log(` Sessions scanned: ${r.sessionsScanned}`);
129
+ console.log(` Summaries rewritten: ${r.summariesRewritten}${dryRun ? " (not applied)" : ""}`);
130
+ if (r.summariesRewritten === 0 && r.sessionsScanned === 0) {
131
+ console.log(` Nothing to rewrite — no level-1 summaries found.`);
132
+ }
133
+ }
134
+ }