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.
- package/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/SECURITY.md +81 -0
- package/package.json +55 -0
- package/scripts/install-hooks.ts +80 -0
- package/scripts/install.ps1 +71 -0
- package/scripts/install.sh +67 -0
- package/scripts/uninstall-hooks.ts +57 -0
- package/src/ai/active-guard.ts +96 -0
- package/src/ai/adaptive-ranker.ts +48 -0
- package/src/ai/classifier.ts +256 -0
- package/src/ai/compressor.ts +129 -0
- package/src/ai/decision-chains.ts +100 -0
- package/src/ai/decision-extractor.ts +148 -0
- package/src/ai/pattern-detector.ts +147 -0
- package/src/ai/proactive.ts +78 -0
- package/src/cli/doctor.ts +171 -0
- package/src/cli/embeddings.ts +209 -0
- package/src/cli/index.ts +574 -0
- package/src/cli/reclassify.ts +134 -0
- package/src/context/builder.ts +97 -0
- package/src/context/formatter.ts +109 -0
- package/src/context/ranker.ts +84 -0
- package/src/db/sqlite/decisions.ts +56 -0
- package/src/db/sqlite/feedback.ts +92 -0
- package/src/db/sqlite/observations.ts +58 -0
- package/src/db/sqlite/schema.ts +366 -0
- package/src/db/sqlite/sessions.ts +50 -0
- package/src/db/sqlite/summaries.ts +69 -0
- package/src/db/vector/client.ts +134 -0
- package/src/db/vector/embeddings.ts +119 -0
- package/src/db/vector/providers/factory.ts +99 -0
- package/src/db/vector/providers/minilm.ts +90 -0
- package/src/db/vector/providers/ollama.ts +92 -0
- package/src/db/vector/providers/tfidf.ts +98 -0
- package/src/db/vector/providers/types.ts +39 -0
- package/src/db/vector/search.ts +131 -0
- package/src/hooks/post-tool-use.ts +205 -0
- package/src/hooks/pre-tool-use.ts +305 -0
- package/src/hooks/stop.ts +334 -0
- package/src/mcp/server.ts +293 -0
- package/src/server/dashboard.html +268 -0
- package/src/server/dashboard.ts +170 -0
- package/src/util/debug.ts +56 -0
- package/src/util/ignore.ts +171 -0
- package/src/util/metrics.ts +236 -0
- package/src/util/path.ts +57 -0
- 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
|
+
}
|
package/src/util/path.ts
ADDED
|
@@ -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
|
+
}
|