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,236 @@
1
+ // Token accounting: tracks real metrics about context value
2
+ // - exploration calls before first edit
3
+ // - time to first substantive action
4
+ // - context hit rate (explored file was in injected context)
5
+
6
+ import { getDb } from "../db/sqlite/schema";
7
+
8
+ // Rough GPT-style estimate: ~4 chars per token for English code/prose
9
+ export function estimateTokens(text: string): number {
10
+ return Math.ceil(text.length / 4);
11
+ }
12
+
13
+ /**
14
+ * Record the context injection at session start.
15
+ * Stores token count + list of files/topics mentioned in the injected context.
16
+ */
17
+ export function recordInjection(
18
+ sessionId: string,
19
+ contextBlock: string,
20
+ projectPath: string,
21
+ injectedFiles?: string[],
22
+ ): void {
23
+ const db = getDb();
24
+ const tokens = estimateTokens(contextBlock);
25
+ const filesJson = injectedFiles && injectedFiles.length > 0
26
+ ? JSON.stringify(injectedFiles.map(f => normalizePath(f)))
27
+ : null;
28
+ try {
29
+ db.prepare(`
30
+ INSERT INTO token_metrics (session_id, tokens_injected, injected_files)
31
+ VALUES (?, ?, ?)
32
+ `).run(sessionId, tokens, filesJson);
33
+ db.prepare(`
34
+ UPDATE sessions SET tokens_injected = ? WHERE session_id = ?
35
+ `).run(tokens, sessionId);
36
+ } catch {
37
+ // Metrics must never block the hook
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Record an exploration call (Read/Glob/Grep).
43
+ * Checks if the explored file was already covered by injected context (hit vs miss).
44
+ * Only counts calls before the first edit.
45
+ */
46
+ export function recordExplorationCall(sessionId: string, exploredPath: string): void {
47
+ const db = getDb();
48
+ try {
49
+ const row = db.prepare(
50
+ "SELECT injected_files, first_edit_recorded FROM token_metrics WHERE session_id = ?"
51
+ ).get(sessionId) as { injected_files: string | null; first_edit_recorded: number } | undefined;
52
+
53
+ if (!row) return;
54
+
55
+ // Only count exploration before first edit
56
+ if (row.first_edit_recorded) return;
57
+
58
+ const normalized = normalizePath(exploredPath);
59
+ let isHit = false;
60
+
61
+ if (row.injected_files) {
62
+ try {
63
+ const files: string[] = JSON.parse(row.injected_files);
64
+ isHit = files.some(f => normalized.includes(f) || f.includes(normalized));
65
+ } catch {
66
+ // Bad JSON, treat as miss
67
+ }
68
+ }
69
+
70
+ if (isHit) {
71
+ db.prepare(`
72
+ UPDATE token_metrics
73
+ SET context_hit_count = context_hit_count + 1,
74
+ exploration_calls_before_edit = exploration_calls_before_edit + 1
75
+ WHERE session_id = ?
76
+ `).run(sessionId);
77
+ } else {
78
+ db.prepare(`
79
+ UPDATE token_metrics
80
+ SET context_miss_count = context_miss_count + 1,
81
+ exploration_calls_before_edit = exploration_calls_before_edit + 1
82
+ WHERE session_id = ?
83
+ `).run(sessionId);
84
+ }
85
+ } catch {
86
+ // Silent
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Record the first substantive action (Edit/Write/Bash).
92
+ * Calculates time_to_first_edit_sec from session start.
93
+ */
94
+ export function recordFirstEdit(sessionId: string): void {
95
+ const db = getDb();
96
+ try {
97
+ const row = db.prepare(
98
+ "SELECT first_edit_recorded FROM token_metrics WHERE session_id = ?"
99
+ ).get(sessionId) as { first_edit_recorded: number } | undefined;
100
+
101
+ if (!row || row.first_edit_recorded) return;
102
+
103
+ // Get session start time
104
+ const session = db.prepare(
105
+ "SELECT started_at FROM sessions WHERE session_id = ?"
106
+ ).get(sessionId) as { started_at: string } | undefined;
107
+
108
+ if (!session) return;
109
+
110
+ const startedAt = new Date(session.started_at + "Z").getTime();
111
+ const now = Date.now();
112
+ const elapsedSec = Math.round((now - startedAt) / 1000 * 10) / 10;
113
+
114
+ db.prepare(`
115
+ UPDATE token_metrics
116
+ SET time_to_first_edit_sec = ?, first_edit_recorded = 1
117
+ WHERE session_id = ?
118
+ `).run(elapsedSec, sessionId);
119
+ } catch {
120
+ // Silent
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Calculate estimated tokens saved based on real data.
126
+ * Called at session end (Stop hook).
127
+ *
128
+ * Heuristics (conservative):
129
+ * - Each context hit = ~80 tokens saved (a Read call that was informed by context)
130
+ * - Baseline: sessions without context average N exploration calls;
131
+ * each call below baseline = ~50 tokens saved
132
+ */
133
+ export function recordSavings(sessionId: string, observations: Array<{ summary: string }>): void {
134
+ const db = getDb();
135
+ try {
136
+ const row = db.prepare(`
137
+ SELECT tokens_injected, context_hit_count, context_miss_count,
138
+ exploration_calls_before_edit
139
+ FROM token_metrics WHERE session_id = ?
140
+ `).get(sessionId) as {
141
+ tokens_injected: number;
142
+ context_hit_count: number;
143
+ context_miss_count: number;
144
+ exploration_calls_before_edit: number;
145
+ } | undefined;
146
+
147
+ if (!row || row.tokens_injected === 0) return;
148
+
149
+ // Calculate baseline: avg exploration calls in sessions WITHOUT context
150
+ const baseline = db.prepare(`
151
+ SELECT AVG(tm.exploration_calls_before_edit) as avg_calls
152
+ FROM token_metrics tm
153
+ WHERE tm.tokens_injected = 0 AND tm.exploration_calls_before_edit > 0
154
+ `).get() as { avg_calls: number | null } | undefined;
155
+
156
+ const TOKENS_PER_HIT = 80;
157
+ const TOKENS_PER_AVOIDED_CALL = 50;
158
+
159
+ let saved = 0;
160
+
161
+ // Value from context hits
162
+ saved += row.context_hit_count * TOKENS_PER_HIT;
163
+
164
+ // Value from avoided exploration (if we have baseline data)
165
+ if (baseline?.avg_calls && baseline.avg_calls > row.exploration_calls_before_edit) {
166
+ const avoidedCalls = Math.round(baseline.avg_calls - row.exploration_calls_before_edit);
167
+ saved += avoidedCalls * TOKENS_PER_AVOIDED_CALL;
168
+ }
169
+
170
+ // Calculate relevance score: hit rate as 0-1
171
+ const totalExploration = row.context_hit_count + row.context_miss_count;
172
+ const relevanceScore = totalExploration > 0
173
+ ? row.context_hit_count / totalExploration
174
+ : 0;
175
+
176
+ db.prepare(`
177
+ UPDATE token_metrics
178
+ SET estimated_tokens_saved = ?, context_relevance_score = ?
179
+ WHERE session_id = ?
180
+ `).run(saved, Math.round(relevanceScore * 100) / 100, sessionId);
181
+ } catch {
182
+ // Silent
183
+ }
184
+ }
185
+
186
+ export interface GlobalMetrics {
187
+ totalInjected: number;
188
+ totalSaved: number;
189
+ sessionsWithContext: number;
190
+ avgInjectedPerSession: number;
191
+ avgExplorationBeforeEdit: number;
192
+ avgTimeToFirstEditSec: number | null;
193
+ contextHitRate: number;
194
+ totalHits: number;
195
+ totalMisses: number;
196
+ }
197
+
198
+ export function getGlobalMetrics(): GlobalMetrics {
199
+ const db = getDb();
200
+ const row = db.prepare(`
201
+ SELECT
202
+ COALESCE(SUM(tokens_injected), 0) as totalInjected,
203
+ COALESCE(SUM(estimated_tokens_saved), 0) as totalSaved,
204
+ COUNT(*) as sessionsWithContext,
205
+ COALESCE(AVG(exploration_calls_before_edit), 0) as avgExploration,
206
+ AVG(CASE WHEN time_to_first_edit_sec IS NOT NULL THEN time_to_first_edit_sec END) as avgTimeToEdit,
207
+ COALESCE(SUM(context_hit_count), 0) as totalHits,
208
+ COALESCE(SUM(context_miss_count), 0) as totalMisses
209
+ FROM token_metrics WHERE tokens_injected > 0
210
+ `).get() as any;
211
+
212
+ const totalExploration = row.totalHits + row.totalMisses;
213
+
214
+ return {
215
+ totalInjected: row.totalInjected,
216
+ totalSaved: row.totalSaved,
217
+ sessionsWithContext: row.sessionsWithContext,
218
+ avgInjectedPerSession: row.sessionsWithContext > 0
219
+ ? Math.round(row.totalInjected / row.sessionsWithContext)
220
+ : 0,
221
+ avgExplorationBeforeEdit: Math.round(row.avgExploration * 10) / 10,
222
+ avgTimeToFirstEditSec: row.avgTimeToEdit !== null
223
+ ? Math.round(row.avgTimeToEdit * 10) / 10
224
+ : null,
225
+ contextHitRate: totalExploration > 0
226
+ ? Math.round((row.totalHits / totalExploration) * 100)
227
+ : 0,
228
+ totalHits: row.totalHits,
229
+ totalMisses: row.totalMisses,
230
+ };
231
+ }
232
+
233
+ /** Normalize file path for comparison — extract basename for fuzzy matching */
234
+ function normalizePath(filePath: string): string {
235
+ return filePath.replace(/\\/g, "/").split("/").pop() || filePath;
236
+ }
@@ -0,0 +1,57 @@
1
+ // Canonicalize file paths so the same file produces the same string across
2
+ // Windows ("C:\\Users\\..."), Git Bash/MSYS ("/c/Users/..."), Cygwin
3
+ // ("/cygdrive/c/Users/..."), and forward-slash POSIX. Without this, the same
4
+ // file gets counted as multiple entries in hot files / pattern detection.
5
+ //
6
+ // Output format: lowercase drive + forward slashes ("c:/Users/Eliel/...").
7
+
8
+ const WINDOWS_ABS = /^([a-zA-Z]):[\\/]/;
9
+ const MSYS_ABS = /^\/([a-zA-Z])\/(.*)$/;
10
+ const CYGWIN_ABS = /^\/cygdrive\/([a-zA-Z])\/(.*)$/;
11
+
12
+ export function normalizePath(input: string): string {
13
+ if (!input) return input;
14
+ let p = input.trim();
15
+ if (!p) return p;
16
+
17
+ // Strip surrounding quotes
18
+ if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
19
+ p = p.slice(1, -1);
20
+ }
21
+
22
+ // /cygdrive/c/Users/... -> c:/Users/...
23
+ const cyg = p.match(CYGWIN_ABS);
24
+ if (cyg) {
25
+ return `${cyg[1].toLowerCase()}:/${cyg[2].replace(/\\/g, "/")}`;
26
+ }
27
+
28
+ // /c/Users/... -> c:/Users/... (only when first segment is a single letter)
29
+ const msys = p.match(MSYS_ABS);
30
+ if (msys) {
31
+ return `${msys[1].toLowerCase()}:/${msys[2].replace(/\\/g, "/")}`;
32
+ }
33
+
34
+ // C:\Users\... or C:/Users/... -> c:/Users/...
35
+ const win = p.match(WINDOWS_ABS);
36
+ if (win) {
37
+ const drive = win[1].toLowerCase();
38
+ const rest = p.slice(2).replace(/\\/g, "/").replace(/^\/+/, "");
39
+ return `${drive}:/${rest}`;
40
+ }
41
+
42
+ // POSIX absolute or relative — just normalize separators
43
+ return p.replace(/\\/g, "/");
44
+ }
45
+
46
+ export function normalizePaths(paths: string[]): string[] {
47
+ const seen = new Set<string>();
48
+ const out: string[] = [];
49
+ for (const p of paths) {
50
+ const n = normalizePath(p);
51
+ if (n && !seen.has(n)) {
52
+ seen.add(n);
53
+ out.push(n);
54
+ }
55
+ }
56
+ return out;
57
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "types": ["bun"]
12
+ },
13
+ "include": ["src/**/*.ts"]
14
+ }