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,97 @@
1
+ // Assembles the context block from multiple data sources
2
+
3
+ import { getDb } from "../db/sqlite/schema";
4
+ import { getRecentSessions } from "../db/sqlite/sessions";
5
+ import { getRecentSummaries } from "../db/sqlite/summaries";
6
+ import { getDecisionsByProject } from "../db/sqlite/decisions";
7
+ import { generateEmbedding } from "../db/vector/embeddings";
8
+ import { searchSimilar } from "../db/vector/client";
9
+ import { formatContextBlock } from "./formatter";
10
+ import { normalizePath } from "../util/path";
11
+
12
+ export async function buildContext(projectPath: string, gitBranch?: string): Promise<string> {
13
+ const db = getDb();
14
+
15
+ // Gather data from all sources
16
+ const recentSessions = getRecentSessions(projectPath, 3);
17
+ const recentSummaries = getRecentSummaries(projectPath, 1, 3);
18
+ const decisions = getDecisionsByProject(projectPath);
19
+
20
+ // Semantic search for similar past work
21
+ let semanticSummaries: Array<{ session_id: string; text: string; level: number; project_path: string; created_at: string }> = [];
22
+ try {
23
+ const contextText = `project ${projectPath} ${gitBranch || ""} development session`;
24
+ const embedding = await generateEmbedding(contextText);
25
+ semanticSummaries = await searchSimilar(Array.from(embedding), 5, projectPath);
26
+ } catch {
27
+ // Semantic search unavailable
28
+ }
29
+
30
+ // Merge semantic results with recent summaries
31
+ const allSummaries = [...recentSummaries];
32
+ for (const sr of semanticSummaries) {
33
+ if (!allSummaries.some(s => s.session_id === sr.session_id)) {
34
+ allSummaries.push({
35
+ session_id: sr.session_id,
36
+ level: sr.level,
37
+ text: sr.text,
38
+ project_path: sr.project_path,
39
+ created_at: sr.created_at,
40
+ });
41
+ }
42
+ }
43
+
44
+ // Hot files
45
+ const hotFiles = getHotFiles(db, projectPath);
46
+
47
+ // Patterns — pick the highest-frequency row per pattern_type so two rows
48
+ // for the same kind of insight (e.g. "Heavily uses Bash") never both appear.
49
+ const patterns = db.prepare(`
50
+ SELECT p.pattern_type, p.description
51
+ FROM patterns p
52
+ JOIN (
53
+ SELECT pattern_type, MAX(frequency) AS max_freq
54
+ FROM patterns WHERE project_path = ?
55
+ GROUP BY pattern_type
56
+ ) m ON m.pattern_type = p.pattern_type AND m.max_freq = p.frequency
57
+ WHERE p.project_path = ?
58
+ ORDER BY p.frequency DESC, p.last_seen DESC
59
+ LIMIT 5
60
+ `).all(projectPath, projectPath) as Array<{ pattern_type: string; description: string }>;
61
+
62
+ return formatContextBlock({
63
+ projectPath,
64
+ gitBranch,
65
+ recentSessions,
66
+ recentSummaries: allSummaries.slice(0, 5),
67
+ decisions: decisions.slice(0, 5),
68
+ hotFiles,
69
+ patterns,
70
+ });
71
+ }
72
+
73
+ function getHotFiles(db: any, projectPath: string): string[] {
74
+ try {
75
+ const rows = db.prepare(`
76
+ SELECT file_paths FROM observations o
77
+ JOIN sessions s ON o.session_id = s.session_id
78
+ WHERE s.project_path = ? AND o.file_paths IS NOT NULL AND o.file_paths != ''
79
+ ORDER BY o.timestamp DESC LIMIT 100
80
+ `).all(projectPath) as Array<{ file_paths: string }>;
81
+
82
+ const counts = new Map<string, number>();
83
+ for (const row of rows) {
84
+ for (const fp of row.file_paths.split(",")) {
85
+ const norm = normalizePath(fp);
86
+ if (norm) counts.set(norm, (counts.get(norm) || 0) + 1);
87
+ }
88
+ }
89
+
90
+ return [...counts.entries()]
91
+ .filter(([, count]) => count >= 3)
92
+ .sort((a, b) => b[1] - a[1])
93
+ .map(([f]) => f);
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
@@ -0,0 +1,109 @@
1
+ // Formats the context block that gets injected into Claude's session
2
+
3
+ import type { Decision } from "../db/sqlite/decisions";
4
+ import type { Summary } from "../db/sqlite/summaries";
5
+ import type { Session } from "../db/sqlite/sessions";
6
+
7
+ interface ContextData {
8
+ projectPath: string;
9
+ gitBranch?: string;
10
+ recentSessions: Session[];
11
+ recentSummaries: Summary[];
12
+ decisions: Decision[];
13
+ hotFiles?: string[];
14
+ patterns?: Array<{ pattern_type: string; description: string }>;
15
+ unfinished?: Array<{ summary: string; age_days: number; file_paths?: string }>;
16
+ }
17
+
18
+ export function formatContextBlock(data: ContextData): string {
19
+ const lines: string[] = [];
20
+
21
+ const branch = data.gitBranch ? ` | Branch: ${data.gitBranch}` : "";
22
+ lines.push(`╔══════════════════════════════════════════╗`);
23
+ lines.push(`║ CLAUDE-CTX: SESSION CONTEXT ║`);
24
+ lines.push(`║ Project: ${truncPath(data.projectPath, 28)}${branch} ║`);
25
+ lines.push(`╚══════════════════════════════════════════╝`);
26
+ lines.push("");
27
+
28
+ // Proactive: unfinished items bubble to the top
29
+ if (data.unfinished && data.unfinished.length > 0) {
30
+ lines.push("▸ UNFINISHED FROM PAST SESSIONS");
31
+ for (const u of data.unfinished.slice(0, 3)) {
32
+ const age = u.age_days === 0 ? "today" : u.age_days === 1 ? "yesterday" : `${u.age_days}d ago`;
33
+ lines.push(` ⚠ [${age}] ${truncate(u.summary, 100)}`);
34
+ if (u.file_paths) {
35
+ const shortPaths = u.file_paths.split(",").slice(0, 2).map(p => p.trim().split(/[/\\]/).pop()).join(", ");
36
+ if (shortPaths) lines.push(` Files: ${shortPaths}`);
37
+ }
38
+ }
39
+ lines.push("");
40
+ }
41
+
42
+ // Decisions (always relevant, never compressed)
43
+ if (data.decisions.length > 0) {
44
+ lines.push("▸ ARCHITECTURAL DECISIONS");
45
+ for (const d of data.decisions.slice(0, 5)) {
46
+ const date = d.created_at ? formatDate(d.created_at) : "";
47
+ const alt = d.alternatives ? ` — rejected ${truncate(d.alternatives, 40)}` : "";
48
+ lines.push(` • ${d.title} [${date}]${alt}`);
49
+ if (d.rationale) {
50
+ lines.push(` Reason: ${truncate(d.rationale, 60)}`);
51
+ }
52
+ }
53
+ lines.push("");
54
+ }
55
+
56
+ // Recent session summaries — compact format
57
+ if (data.recentSummaries.length > 0) {
58
+ lines.push("▸ RECENT SESSIONS");
59
+ for (const s of data.recentSummaries.slice(0, 3)) {
60
+ const date = s.created_at ? formatDate(s.created_at) : "";
61
+ const summaryLines = s.text.split("\n").filter(l => l.trim()).slice(0, 6);
62
+ lines.push(` [${date}] ${summaryLines[0] || "Session"}`);
63
+ for (const line of summaryLines.slice(1)) {
64
+ lines.push(` ${line.trim()}`);
65
+ }
66
+ }
67
+ lines.push("");
68
+ }
69
+
70
+ // Hot files
71
+ if (data.hotFiles && data.hotFiles.length > 0) {
72
+ lines.push(`▸ HOT FILES (frequently modified)`);
73
+ lines.push(` ${data.hotFiles.slice(0, 6).join(", ")}`);
74
+ lines.push("");
75
+ }
76
+
77
+ // Patterns
78
+ if (data.patterns && data.patterns.length > 0) {
79
+ lines.push("▸ YOUR PATTERNS IN THIS PROJECT");
80
+ for (const p of data.patterns.slice(0, 4)) {
81
+ lines.push(` • ${p.description}`);
82
+ }
83
+ lines.push("");
84
+ }
85
+
86
+ return lines.join("\n");
87
+ }
88
+
89
+ function truncPath(path: string, maxLen: number): string {
90
+ if (path.length <= maxLen) return path;
91
+ const parts = path.split(/[/\\]/);
92
+ if (parts.length <= 2) return path.slice(-maxLen);
93
+ return "..." + parts.slice(-2).join("/");
94
+ }
95
+
96
+ function truncate(text: string, maxLen: number): string {
97
+ if (text.length <= maxLen) return text;
98
+ return text.slice(0, maxLen - 3) + "...";
99
+ }
100
+
101
+ function formatDate(dateStr: string): string {
102
+ try {
103
+ const d = new Date(dateStr);
104
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
105
+ return `${months[d.getMonth()]} ${d.getDate()}`;
106
+ } catch {
107
+ return dateStr.slice(0, 10);
108
+ }
109
+ }
@@ -0,0 +1,84 @@
1
+ // Ranks context items by relevance to prioritize what gets injected
2
+
3
+ import type { Decision } from "../db/sqlite/decisions";
4
+ import type { Summary } from "../db/sqlite/summaries";
5
+
6
+ interface RankedItem {
7
+ type: "decision" | "summary" | "pattern";
8
+ text: string;
9
+ score: number;
10
+ metadata: Record<string, any>;
11
+ }
12
+
13
+ export function rankContextItems(
14
+ decisions: Decision[],
15
+ summaries: Summary[],
16
+ patterns: Array<{ pattern_type: string; description: string; frequency?: number }>,
17
+ tokenBudget = 800
18
+ ): RankedItem[] {
19
+ const items: RankedItem[] = [];
20
+
21
+ // Decisions always get high priority (never compressed)
22
+ for (const d of decisions) {
23
+ const recency = getRecencyScore(d.created_at);
24
+ items.push({
25
+ type: "decision",
26
+ text: `${d.title}: ${d.decision_text}`,
27
+ score: 10 + recency, // Base 10 for decisions
28
+ metadata: d,
29
+ });
30
+ }
31
+
32
+ // Summaries scored by recency and level
33
+ for (const s of summaries) {
34
+ const recency = getRecencyScore(s.created_at);
35
+ const levelBonus = s.level === 2 ? 2 : s.level === 3 ? 3 : 0;
36
+ items.push({
37
+ type: "summary",
38
+ text: s.text,
39
+ score: 5 + recency + levelBonus,
40
+ metadata: s,
41
+ });
42
+ }
43
+
44
+ // Patterns scored by frequency
45
+ for (const p of patterns) {
46
+ items.push({
47
+ type: "pattern",
48
+ text: p.description,
49
+ score: 3 + Math.min(5, (p.frequency || 1) / 2),
50
+ metadata: p,
51
+ });
52
+ }
53
+
54
+ // Sort by score descending
55
+ items.sort((a, b) => b.score - a.score);
56
+
57
+ // Trim to fit token budget (rough estimate: 4 chars per token)
58
+ let totalChars = 0;
59
+ const result: RankedItem[] = [];
60
+ for (const item of items) {
61
+ const itemChars = item.text.length + 50; // 50 for formatting overhead
62
+ if (totalChars + itemChars > tokenBudget * 4) break;
63
+ totalChars += itemChars;
64
+ result.push(item);
65
+ }
66
+
67
+ return result;
68
+ }
69
+
70
+ function getRecencyScore(dateStr?: string): number {
71
+ if (!dateStr) return 0;
72
+ try {
73
+ const ageMs = Date.now() - new Date(dateStr).getTime();
74
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
75
+ if (ageDays < 1) return 5;
76
+ if (ageDays < 3) return 4;
77
+ if (ageDays < 7) return 3;
78
+ if (ageDays < 14) return 2;
79
+ if (ageDays < 30) return 1;
80
+ return 0;
81
+ } catch {
82
+ return 0;
83
+ }
84
+ }
@@ -0,0 +1,56 @@
1
+ import { getDb } from "./schema";
2
+
3
+ export interface Decision {
4
+ id?: number;
5
+ project_path?: string;
6
+ title: string;
7
+ decision_text: string;
8
+ alternatives?: string;
9
+ rationale?: string;
10
+ files_affected?: string;
11
+ embedding_id?: string;
12
+ created_at?: string;
13
+ last_referenced?: string;
14
+ }
15
+
16
+ export function insertDecision(decision: Decision): void {
17
+ const db = getDb();
18
+ // INSERT OR IGNORE pairs with the unique index on (project_path, title)
19
+ // declared in schema.ts: re-extracting the same decision across sessions
20
+ // becomes a no-op instead of duplicating the row.
21
+ db.prepare(`
22
+ INSERT OR IGNORE INTO decisions (project_path, title, decision_text, alternatives, rationale, files_affected, embedding_id)
23
+ VALUES (?, ?, ?, ?, ?, ?, ?)
24
+ `).run(
25
+ decision.project_path || null,
26
+ decision.title,
27
+ decision.decision_text,
28
+ decision.alternatives || null,
29
+ decision.rationale || null,
30
+ decision.files_affected || null,
31
+ decision.embedding_id || null
32
+ );
33
+ }
34
+
35
+ export function getDecisionsByProject(projectPath: string): Decision[] {
36
+ const db = getDb();
37
+ return db.prepare(`
38
+ SELECT * FROM decisions WHERE project_path = ? ORDER BY created_at DESC
39
+ `).all(projectPath) as Decision[];
40
+ }
41
+
42
+ export function searchDecisions(query: string, limit = 5): Decision[] {
43
+ const db = getDb();
44
+ return db.prepare(`
45
+ SELECT d.* FROM decisions d
46
+ JOIN decisions_fts fts ON d.id = fts.rowid
47
+ WHERE decisions_fts MATCH ?
48
+ ORDER BY rank
49
+ LIMIT ?
50
+ `).all(query, limit) as Decision[];
51
+ }
52
+
53
+ export function touchDecision(id: number) {
54
+ const db = getDb();
55
+ db.prepare(`UPDATE decisions SET last_referenced = datetime('now') WHERE id = ?`).run(id);
56
+ }
@@ -0,0 +1,92 @@
1
+ // Stores user feedback on what autoctxd surfaced. Used by the adaptive ranker
2
+ // to suppress irrelevant items and amplify useful ones for this specific user.
3
+
4
+ import { getDb } from "./schema";
5
+
6
+ export type Verdict = "useful" | "irrelevant" | "wrong";
7
+
8
+ export interface Feedback {
9
+ id?: number;
10
+ target_type: "decision" | "observation" | "summary" | "pattern" | "unfinished";
11
+ target_id: string;
12
+ target_text?: string;
13
+ verdict: Verdict;
14
+ reason?: string;
15
+ project_path?: string;
16
+ session_id?: string;
17
+ created_at?: string;
18
+ }
19
+
20
+ export function recordFeedback(fb: Feedback): number {
21
+ const db = getDb();
22
+ const info = db.prepare(`
23
+ INSERT INTO feedback (target_type, target_id, target_text, verdict, reason, project_path, session_id)
24
+ VALUES (?, ?, ?, ?, ?, ?, ?)
25
+ `).run(
26
+ fb.target_type,
27
+ fb.target_id,
28
+ fb.target_text || null,
29
+ fb.verdict,
30
+ fb.reason || null,
31
+ fb.project_path || null,
32
+ fb.session_id || null
33
+ );
34
+ return Number(info.lastInsertRowid);
35
+ }
36
+
37
+ export function getFeedbackFor(targetType: string, targetId: string): Feedback[] {
38
+ const db = getDb();
39
+ return db.prepare(`
40
+ SELECT * FROM feedback WHERE target_type = ? AND target_id = ? ORDER BY created_at DESC
41
+ `).all(targetType, targetId) as Feedback[];
42
+ }
43
+
44
+ // Returns a score delta per target: negative if irrelevant/wrong, positive if useful
45
+ export function getScoreDeltas(projectPath?: string): Map<string, number> {
46
+ const db = getDb();
47
+ const rows = (projectPath
48
+ ? db.prepare(`
49
+ SELECT target_type, target_id, verdict, COUNT(*) as c FROM feedback
50
+ WHERE project_path = ? GROUP BY target_type, target_id, verdict
51
+ `).all(projectPath)
52
+ : db.prepare(`
53
+ SELECT target_type, target_id, verdict, COUNT(*) as c FROM feedback
54
+ GROUP BY target_type, target_id, verdict
55
+ `).all()
56
+ ) as Array<{ target_type: string; target_id: string; verdict: Verdict; c: number }>;
57
+
58
+ const deltas = new Map<string, number>();
59
+ for (const r of rows) {
60
+ const key = `${r.target_type}:${r.target_id}`;
61
+ const sign = r.verdict === "useful" ? 1 : r.verdict === "irrelevant" ? -2 : -3;
62
+ deltas.set(key, (deltas.get(key) || 0) + sign * r.c);
63
+ }
64
+ return deltas;
65
+ }
66
+
67
+ // Aggregated stats for dashboard/telemetry
68
+ export function getFeedbackStats(): {
69
+ total: number;
70
+ useful: number;
71
+ irrelevant: number;
72
+ wrong: number;
73
+ byType: Record<string, { useful: number; irrelevant: number; wrong: number }>;
74
+ } {
75
+ const db = getDb();
76
+ const total = (db.prepare("SELECT COUNT(*) as c FROM feedback").get() as any).c;
77
+ const useful = (db.prepare("SELECT COUNT(*) as c FROM feedback WHERE verdict = 'useful'").get() as any).c;
78
+ const irrelevant = (db.prepare("SELECT COUNT(*) as c FROM feedback WHERE verdict = 'irrelevant'").get() as any).c;
79
+ const wrong = (db.prepare("SELECT COUNT(*) as c FROM feedback WHERE verdict = 'wrong'").get() as any).c;
80
+
81
+ const byTypeRows = db.prepare(`
82
+ SELECT target_type, verdict, COUNT(*) as c FROM feedback GROUP BY target_type, verdict
83
+ `).all() as Array<{ target_type: string; verdict: Verdict; c: number }>;
84
+
85
+ const byType: Record<string, { useful: number; irrelevant: number; wrong: number }> = {};
86
+ for (const r of byTypeRows) {
87
+ byType[r.target_type] ||= { useful: 0, irrelevant: 0, wrong: 0 };
88
+ byType[r.target_type][r.verdict] = r.c;
89
+ }
90
+
91
+ return { total, useful, irrelevant, wrong, byType };
92
+ }
@@ -0,0 +1,58 @@
1
+ import { getDb } from "./schema";
2
+
3
+ export interface Observation {
4
+ id?: number;
5
+ session_id: string;
6
+ type: string;
7
+ tool_name?: string;
8
+ summary: string;
9
+ file_paths?: string;
10
+ timestamp?: string;
11
+ importance_score?: number;
12
+ }
13
+
14
+ export function insertObservation(obs: Observation): void {
15
+ const db = getDb();
16
+ db.prepare(`
17
+ INSERT INTO observations (session_id, type, tool_name, summary, file_paths, importance_score)
18
+ VALUES (?, ?, ?, ?, ?, ?)
19
+ `).run(
20
+ obs.session_id,
21
+ obs.type,
22
+ obs.tool_name || null,
23
+ obs.summary,
24
+ obs.file_paths || null,
25
+ obs.importance_score ?? 5
26
+ );
27
+ }
28
+
29
+ export function getObservationsBySession(sessionId: string): Observation[] {
30
+ const db = getDb();
31
+ return db.prepare(`
32
+ SELECT * FROM observations WHERE session_id = ? ORDER BY importance_score DESC, timestamp DESC
33
+ `).all(sessionId) as Observation[];
34
+ }
35
+
36
+ export function getTopObservations(sessionId: string, limit = 5): Observation[] {
37
+ const db = getDb();
38
+ return db.prepare(`
39
+ SELECT * FROM observations WHERE session_id = ? ORDER BY importance_score DESC LIMIT ?
40
+ `).all(sessionId, limit) as Observation[];
41
+ }
42
+
43
+ export function searchObservations(query: string, limit = 10): Observation[] {
44
+ const db = getDb();
45
+ return db.prepare(`
46
+ SELECT o.* FROM observations o
47
+ JOIN observations_fts fts ON o.id = fts.rowid
48
+ WHERE observations_fts MATCH ?
49
+ ORDER BY rank
50
+ LIMIT ?
51
+ `).all(query, limit) as Observation[];
52
+ }
53
+
54
+ export function countObservationsBySession(sessionId: string): number {
55
+ const db = getDb();
56
+ const row = db.prepare(`SELECT COUNT(*) as count FROM observations WHERE session_id = ?`).get(sessionId) as any;
57
+ return row?.count ?? 0;
58
+ }