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,268 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>autoctxd dashboard</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ body { font-family: -apple-system, "SF Pro Text", Inter, system-ui, sans-serif; }
10
+ .mono { font-family: "SF Mono", Menlo, Consolas, monospace; }
11
+ .scroll-thin::-webkit-scrollbar { width: 6px; height: 6px; }
12
+ .scroll-thin::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
13
+ .type-pill { padding: 2px 8px; font-size: 11px; border-radius: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; }
14
+ .type-bug_fix { background: #7f1d1d; color: #fecaca; }
15
+ .type-decision { background: #78350f; color: #fde68a; }
16
+ .type-new_feature { background: #14532d; color: #bbf7d0; }
17
+ .type-refactor { background: #1e3a8a; color: #bfdbfe; }
18
+ .type-test { background: #3f3f46; color: #e4e4e7; }
19
+ .type-config { background: #581c87; color: #e9d5ff; }
20
+ .type-blocked { background: #450a0a; color: #fca5a5; }
21
+ .type-deploy { background: #064e3b; color: #a7f3d0; }
22
+ .type-research { background: #155e75; color: #a5f3fc; }
23
+ .type-other { background: #27272a; color: #a1a1aa; }
24
+ </style>
25
+ </head>
26
+ <body class="bg-slate-950 text-slate-100 min-h-screen">
27
+ <div class="max-w-7xl mx-auto p-6">
28
+ <header class="flex items-center justify-between mb-6">
29
+ <div>
30
+ <h1 class="text-2xl font-bold tracking-tight">autoctxd</h1>
31
+ <p class="text-slate-400 text-sm">your Claude Code memory, local and yours</p>
32
+ </div>
33
+ <div class="flex gap-2">
34
+ <input id="search" type="text" placeholder="Search memory..."
35
+ class="bg-slate-900 border border-slate-700 rounded px-3 py-2 w-80 text-sm focus:outline-none focus:border-sky-500" />
36
+ <select id="projectFilter" class="bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm">
37
+ <option value="">All projects</option>
38
+ </select>
39
+ </div>
40
+ </header>
41
+
42
+ <section id="metrics" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3 mb-6"></section>
43
+
44
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
45
+ <div class="lg:col-span-2 space-y-5">
46
+ <div id="searchResults" class="hidden bg-slate-900 border border-slate-800 rounded-lg p-4">
47
+ <h2 class="font-semibold text-slate-200 mb-3">Search Results</h2>
48
+ <div id="searchList" class="space-y-2 max-h-96 overflow-auto scroll-thin"></div>
49
+ </div>
50
+
51
+ <div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
52
+ <h2 class="font-semibold text-slate-200 mb-3">Activity Timeline</h2>
53
+ <div id="timeline" class="space-y-1 max-h-[420px] overflow-auto scroll-thin text-sm"></div>
54
+ </div>
55
+
56
+ <div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
57
+ <h2 class="font-semibold text-slate-200 mb-3">Architectural Decisions</h2>
58
+ <div id="decisions" class="space-y-3 max-h-96 overflow-auto scroll-thin"></div>
59
+ </div>
60
+
61
+ <div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
62
+ <h2 class="font-semibold text-slate-200 mb-3">Decision Chains</h2>
63
+ <div id="chains" class="space-y-2"></div>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="space-y-5">
68
+ <div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
69
+ <h2 class="font-semibold text-slate-200 mb-3">Recent Sessions</h2>
70
+ <div id="sessions" class="space-y-2 max-h-80 overflow-auto scroll-thin text-sm"></div>
71
+ </div>
72
+ <div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
73
+ <h2 class="font-semibold text-slate-200 mb-3">Observation Types</h2>
74
+ <div id="types" class="space-y-2"></div>
75
+ </div>
76
+ <div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
77
+ <h2 class="font-semibold text-slate-200 mb-3">Patterns</h2>
78
+ <div id="patterns" class="space-y-2 text-sm max-h-72 overflow-auto scroll-thin"></div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+
84
+ <script>
85
+ const $ = (s) => document.querySelector(s);
86
+ let currentProject = "";
87
+
88
+ async function api(path, params = {}) {
89
+ const qs = new URLSearchParams({ ...params });
90
+ if (currentProject) qs.set("project", currentProject);
91
+ const r = await fetch(`/api/${path}?${qs}`);
92
+ return r.json();
93
+ }
94
+
95
+ function metricCard(label, value, subtle = false) {
96
+ return `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3">
97
+ <div class="text-xs text-slate-500 uppercase tracking-wider">${label}</div>
98
+ <div class="text-xl font-bold ${subtle ? "text-slate-300" : "text-sky-400"} mt-1 mono">${value}</div>
99
+ </div>`;
100
+ }
101
+
102
+ async function loadStats() {
103
+ const data = await api("stats");
104
+ const c = data.counts;
105
+ const m = data.metrics;
106
+ const hitRate = m.contextHitRate != null ? `${m.contextHitRate}%` : "—";
107
+ const avgTime = m.avgTimeToFirstEditSec != null ? `${m.avgTimeToFirstEditSec}s` : "—";
108
+ const avgExplore = m.avgExplorationBeforeEdit != null ? m.avgExplorationBeforeEdit : "—";
109
+ $("#metrics").innerHTML = [
110
+ metricCard("Sessions", c.sessions),
111
+ metricCard("Observations", c.observations),
112
+ metricCard("Decisions", c.decisions),
113
+ metricCard("Projects", c.projects),
114
+ metricCard("Context Hit Rate", hitRate),
115
+ metricCard("Avg Explore Calls", avgExplore, true),
116
+ metricCard("Avg Time to Edit", avgTime, true),
117
+ metricCard("Tokens Saved ≈", m.totalSaved.toLocaleString(), true),
118
+ ].join("");
119
+
120
+ $("#types").innerHTML = data.types.map(t => {
121
+ const pct = Math.round((t.count / Math.max(1, c.observations)) * 100);
122
+ return `<div>
123
+ <div class="flex justify-between text-xs mb-1">
124
+ <span class="type-pill type-${t.type}">${t.type}</span>
125
+ <span class="mono text-slate-400">${t.count} (${pct}%)</span>
126
+ </div>
127
+ <div class="h-1 bg-slate-800 rounded-full">
128
+ <div class="h-full bg-sky-500 rounded-full" style="width:${pct}%"></div>
129
+ </div>
130
+ </div>`;
131
+ }).join("");
132
+ }
133
+
134
+ async function loadSessions() {
135
+ const rows = await api("sessions", { limit: 30 });
136
+
137
+ const projects = new Set(rows.map(r => r.project_path).filter(Boolean));
138
+ const select = $("#projectFilter");
139
+ const existing = new Set(Array.from(select.options).map(o => o.value));
140
+ for (const p of projects) {
141
+ if (!existing.has(p)) {
142
+ const opt = document.createElement("option");
143
+ opt.value = p; opt.textContent = p.split(/[/\\]/).slice(-2).join("/");
144
+ select.appendChild(opt);
145
+ }
146
+ }
147
+
148
+ $("#sessions").innerHTML = rows.map(r => {
149
+ const status = r.ended_at ? "done" : "active";
150
+ const when = (r.started_at || "").replace("T", " ").slice(0, 16);
151
+ const project = (r.project_path || "?").split(/[/\\]/).slice(-1)[0];
152
+ return `<div class="border-b border-slate-800 py-2 hover:bg-slate-800/30">
153
+ <div class="flex justify-between">
154
+ <span class="mono text-xs text-slate-400">${r.session_id.slice(0, 10)}</span>
155
+ <span class="text-xs ${status === "active" ? "text-emerald-400" : "text-slate-500"}">${status}</span>
156
+ </div>
157
+ <div class="text-slate-300">${project}</div>
158
+ <div class="text-xs text-slate-500 mono flex justify-between">
159
+ <span>${when}</span>
160
+ <span>${r.total_observations || 0} obs</span>
161
+ </div>
162
+ </div>`;
163
+ }).join("") || `<div class="text-slate-500 text-sm">No sessions yet.</div>`;
164
+ }
165
+
166
+ async function loadDecisions() {
167
+ const rows = await api("decisions", { limit: 30 });
168
+ $("#decisions").innerHTML = rows.map(r => `
169
+ <div class="border-l-2 border-amber-500 pl-3">
170
+ <div class="flex items-center gap-2">
171
+ <span class="font-medium text-slate-200">${escape(r.title)}</span>
172
+ <span class="text-xs text-slate-500 mono">${(r.created_at || "").slice(0, 10)}</span>
173
+ </div>
174
+ <div class="text-sm text-slate-400">${escape(r.decision_text).slice(0, 220)}</div>
175
+ ${r.rationale ? `<div class="text-xs text-slate-500 mt-1"><b>Reason:</b> ${escape(r.rationale)}</div>` : ""}
176
+ ${r.alternatives ? `<div class="text-xs text-slate-500"><b>Rejected:</b> ${escape(r.alternatives)}</div>` : ""}
177
+ </div>
178
+ `).join("") || `<div class="text-slate-500 text-sm">No decisions captured yet.</div>`;
179
+ }
180
+
181
+ async function loadPatterns() {
182
+ const rows = await api("patterns");
183
+ $("#patterns").innerHTML = rows.map(r => `
184
+ <div class="border-b border-slate-800 pb-2">
185
+ <div class="text-xs text-slate-500 uppercase">${r.pattern_type.replace(/_/g, " ")}</div>
186
+ <div class="text-slate-300">${escape(r.description)}</div>
187
+ <div class="text-xs text-slate-500 mono">seen ${r.frequency}x</div>
188
+ </div>
189
+ `).join("") || `<div class="text-slate-500 text-sm">No patterns detected yet — they emerge after 3+ sessions.</div>`;
190
+ }
191
+
192
+ async function loadChains() {
193
+ const rows = await api("chains");
194
+ $("#chains").innerHTML = rows.map(r => {
195
+ const parts = r.description.split(" → ");
196
+ return `<div class="flex flex-wrap items-center gap-2 text-sm">
197
+ ${parts.map((p, i) => `
198
+ <span class="bg-slate-800 px-2 py-1 rounded mono text-sky-300">${escape(p)}</span>
199
+ ${i < parts.length - 1 ? `<span class="text-slate-500">→</span>` : ""}
200
+ `).join("")}
201
+ </div>`;
202
+ }).join("") || `<div class="text-slate-500 text-sm">No decision chains yet. They appear when you revisit the same area of the stack.</div>`;
203
+ }
204
+
205
+ async function loadTimeline() {
206
+ const rows = await api("timeline");
207
+ $("#timeline").innerHTML = rows.slice(0, 80).map(r => {
208
+ const t = (r.timestamp || "").slice(5, 16).replace("T", " ");
209
+ const stars = "●".repeat(Math.min(5, Math.ceil((r.importance_score || 0) / 2)));
210
+ return `<div class="flex gap-3 items-start py-1 border-b border-slate-900 hover:bg-slate-800/30 px-1">
211
+ <span class="mono text-xs text-slate-500 shrink-0 w-24">${t}</span>
212
+ <span class="type-pill type-${r.type} shrink-0">${r.type}</span>
213
+ <span class="text-amber-500 shrink-0 text-xs">${stars}</span>
214
+ <span class="text-slate-300 truncate">${escape(r.summary)}</span>
215
+ </div>`;
216
+ }).join("") || `<div class="text-slate-500 text-sm p-4">No activity yet.</div>`;
217
+ }
218
+
219
+ async function runSearch(q) {
220
+ if (!q || q.length < 2) {
221
+ $("#searchResults").classList.add("hidden");
222
+ return;
223
+ }
224
+ const results = await api("search", { q });
225
+ $("#searchResults").classList.remove("hidden");
226
+ $("#searchList").innerHTML = results.map(r => {
227
+ const icon = r.source === "vector" ? "~" : r.source === "fts_decisions" ? "!" : ">";
228
+ return `<div class="p-2 rounded hover:bg-slate-800 border-l-2 border-sky-500">
229
+ <div class="flex justify-between text-xs text-slate-500">
230
+ <span>${r.source} ${icon}</span>
231
+ <span class="mono">score ${r.score.toFixed(2)}</span>
232
+ </div>
233
+ <div class="text-slate-300">${escape(r.text).slice(0, 260)}</div>
234
+ </div>`;
235
+ }).join("") || `<div class="text-slate-500">No matches.</div>`;
236
+ }
237
+
238
+ function escape(s) {
239
+ return String(s ?? "").replace(/[&<>"']/g, c => ({
240
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
241
+ }[c]));
242
+ }
243
+
244
+ let searchTimer;
245
+ $("#search").addEventListener("input", e => {
246
+ clearTimeout(searchTimer);
247
+ searchTimer = setTimeout(() => runSearch(e.target.value), 200);
248
+ });
249
+
250
+ $("#projectFilter").addEventListener("change", e => {
251
+ currentProject = e.target.value;
252
+ reload();
253
+ });
254
+
255
+ async function reload() {
256
+ await Promise.all([
257
+ loadStats(),
258
+ loadSessions(),
259
+ loadDecisions(),
260
+ loadPatterns(),
261
+ loadChains(),
262
+ loadTimeline(),
263
+ ]);
264
+ }
265
+ reload();
266
+ </script>
267
+ </body>
268
+ </html>
@@ -0,0 +1,170 @@
1
+ // Local web dashboard — single-origin Bun server + vanilla HTML app.
2
+ // Read-only view over SQLite/LanceDB data. Never leaves localhost.
3
+
4
+ import { getDb } from "../db/sqlite/schema";
5
+ import { getGlobalMetrics } from "../util/metrics";
6
+ import { hybridSearch } from "../db/vector/search";
7
+ import { readFileSync } from "fs";
8
+ import { join } from "path";
9
+
10
+ const HTML_PATH = join(import.meta.dir, "dashboard.html");
11
+
12
+ export async function startDashboardServer(port = 4589): Promise<void> {
13
+ const server = Bun.serve({
14
+ port,
15
+ async fetch(req) {
16
+ const url = new URL(req.url);
17
+
18
+ // CORS for localhost dev convenience
19
+ const cors = {
20
+ "Access-Control-Allow-Origin": "*",
21
+ "Access-Control-Allow-Headers": "*",
22
+ };
23
+
24
+ if (req.method === "OPTIONS") {
25
+ return new Response(null, { headers: cors });
26
+ }
27
+
28
+ try {
29
+ if (url.pathname === "/" || url.pathname === "/index.html") {
30
+ const html = readFileSync(HTML_PATH, "utf8");
31
+ return new Response(html, { headers: { "content-type": "text/html" } });
32
+ }
33
+
34
+ if (url.pathname === "/api/stats") {
35
+ return json(handleStats(), cors);
36
+ }
37
+ if (url.pathname === "/api/sessions") {
38
+ return json(handleSessions(url), cors);
39
+ }
40
+ if (url.pathname === "/api/decisions") {
41
+ return json(handleDecisions(url), cors);
42
+ }
43
+ if (url.pathname === "/api/patterns") {
44
+ return json(handlePatterns(url), cors);
45
+ }
46
+ if (url.pathname === "/api/session") {
47
+ return json(handleSession(url), cors);
48
+ }
49
+ if (url.pathname === "/api/search") {
50
+ return json(await handleSearch(url), cors);
51
+ }
52
+ if (url.pathname === "/api/timeline") {
53
+ return json(handleTimeline(url), cors);
54
+ }
55
+ if (url.pathname === "/api/chains") {
56
+ return json(handleChains(url), cors);
57
+ }
58
+ } catch (e) {
59
+ return json({ error: String(e) }, cors, 500);
60
+ }
61
+
62
+ return new Response("Not found", { status: 404, headers: cors });
63
+ },
64
+ });
65
+
66
+ console.log(`\n autoctxd dashboard running at http://localhost:${server.port}`);
67
+ console.log(` Press Ctrl+C to stop.\n`);
68
+ }
69
+
70
+ function json(body: unknown, headers: Record<string, string> = {}, status = 200): Response {
71
+ return new Response(JSON.stringify(body), {
72
+ status,
73
+ headers: { "content-type": "application/json", ...headers },
74
+ });
75
+ }
76
+
77
+ function handleStats() {
78
+ const db = getDb();
79
+ const metrics = getGlobalMetrics();
80
+ const counts = {
81
+ sessions: (db.prepare("SELECT COUNT(*) as c FROM sessions").get() as any).c,
82
+ observations: (db.prepare("SELECT COUNT(*) as c FROM observations").get() as any).c,
83
+ summaries: (db.prepare("SELECT COUNT(*) as c FROM summaries").get() as any).c,
84
+ decisions: (db.prepare("SELECT COUNT(*) as c FROM decisions").get() as any).c,
85
+ patterns: (db.prepare("SELECT COUNT(*) as c FROM patterns").get() as any).c,
86
+ projects: (db.prepare("SELECT COUNT(DISTINCT project_path) as c FROM sessions WHERE project_path IS NOT NULL").get() as any).c,
87
+ };
88
+
89
+ const types = db.prepare(`
90
+ SELECT type, COUNT(*) as count FROM observations GROUP BY type ORDER BY count DESC
91
+ `).all();
92
+
93
+ const activityByDay = db.prepare(`
94
+ SELECT DATE(timestamp) as day, COUNT(*) as count
95
+ FROM observations
96
+ WHERE timestamp >= DATE('now', '-30 days')
97
+ GROUP BY day ORDER BY day ASC
98
+ `).all();
99
+
100
+ return { counts, metrics, types, activityByDay };
101
+ }
102
+
103
+ function handleSessions(url: URL) {
104
+ const db = getDb();
105
+ const limit = Math.min(200, parseInt(url.searchParams.get("limit") || "50", 10));
106
+ const project = url.searchParams.get("project");
107
+ const rows = project
108
+ ? db.prepare(`SELECT * FROM sessions WHERE project_path = ? ORDER BY started_at DESC LIMIT ?`).all(project, limit)
109
+ : db.prepare(`SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?`).all(limit);
110
+ return rows;
111
+ }
112
+
113
+ function handleSession(url: URL) {
114
+ const db = getDb();
115
+ const id = url.searchParams.get("id");
116
+ if (!id) return { error: "missing id" };
117
+ const session = db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(id);
118
+ const obs = db.prepare("SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp ASC").all(id);
119
+ const summaries = db.prepare("SELECT * FROM summaries WHERE session_id = ?").all(id);
120
+ return { session, observations: obs, summaries };
121
+ }
122
+
123
+ function handleDecisions(url: URL) {
124
+ const db = getDb();
125
+ const project = url.searchParams.get("project");
126
+ const limit = Math.min(200, parseInt(url.searchParams.get("limit") || "50", 10));
127
+ return project
128
+ ? db.prepare(`SELECT * FROM decisions WHERE project_path = ? ORDER BY created_at DESC LIMIT ?`).all(project, limit)
129
+ : db.prepare(`SELECT * FROM decisions ORDER BY created_at DESC LIMIT ?`).all(limit);
130
+ }
131
+
132
+ function handlePatterns(url: URL) {
133
+ const db = getDb();
134
+ const project = url.searchParams.get("project");
135
+ return project
136
+ ? db.prepare(`SELECT * FROM patterns WHERE project_path = ? ORDER BY frequency DESC`).all(project)
137
+ : db.prepare(`SELECT * FROM patterns ORDER BY frequency DESC`).all();
138
+ }
139
+
140
+ function handleChains(url: URL) {
141
+ const db = getDb();
142
+ const project = url.searchParams.get("project");
143
+ return project
144
+ ? db.prepare(`SELECT * FROM patterns WHERE pattern_type = 'decision_chain' AND project_path = ? ORDER BY frequency DESC`).all(project)
145
+ : db.prepare(`SELECT * FROM patterns WHERE pattern_type = 'decision_chain' ORDER BY frequency DESC`).all();
146
+ }
147
+
148
+ function handleTimeline(url: URL) {
149
+ const db = getDb();
150
+ const project = url.searchParams.get("project");
151
+ const query = project
152
+ ? db.prepare(`
153
+ SELECT o.timestamp, o.type, o.summary, o.importance_score, o.session_id, s.project_path
154
+ FROM observations o JOIN sessions s ON o.session_id = s.session_id
155
+ WHERE s.project_path = ? ORDER BY o.timestamp DESC LIMIT 200
156
+ `).all(project)
157
+ : db.prepare(`
158
+ SELECT o.timestamp, o.type, o.summary, o.importance_score, o.session_id, s.project_path
159
+ FROM observations o JOIN sessions s ON o.session_id = s.session_id
160
+ ORDER BY o.timestamp DESC LIMIT 200
161
+ `).all();
162
+ return query;
163
+ }
164
+
165
+ async function handleSearch(url: URL) {
166
+ const query = url.searchParams.get("q") || "";
167
+ if (!query) return [];
168
+ const project = url.searchParams.get("project") || undefined;
169
+ return await hybridSearch(query, { limit: 20, projectPath: project });
170
+ }
@@ -0,0 +1,56 @@
1
+ // Debug logger - writes to data/debug.log when AUTOCTXD_DEBUG=1
2
+
3
+ import { appendFileSync, mkdirSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ const DATA_DIR = join(import.meta.dir, "..", "..", "data");
7
+ const DEBUG_LOG = join(DATA_DIR, "debug.log");
8
+
9
+ export const isDebug = process.env.AUTOCTXD_DEBUG === "1" || process.env.AUTOCTXD_DEBUG === "true";
10
+
11
+ export function debug(scope: string, message: string, data?: unknown): void {
12
+ if (!isDebug) return;
13
+ try {
14
+ mkdirSync(DATA_DIR, { recursive: true });
15
+ const ts = new Date().toISOString();
16
+ const extra = data !== undefined ? ` :: ${safeStringify(data)}` : "";
17
+ appendFileSync(DEBUG_LOG, `[${ts}] [${scope}] ${message}${extra}\n`);
18
+ } catch {
19
+ // never crash Claude from debug
20
+ }
21
+ }
22
+
23
+ export function timed<T>(scope: string, label: string, fn: () => T): T {
24
+ if (!isDebug) return fn();
25
+ const start = performance.now();
26
+ try {
27
+ const result = fn();
28
+ debug(scope, `${label} took ${(performance.now() - start).toFixed(1)}ms`);
29
+ return result;
30
+ } catch (e) {
31
+ debug(scope, `${label} FAILED after ${(performance.now() - start).toFixed(1)}ms: ${e}`);
32
+ throw e;
33
+ }
34
+ }
35
+
36
+ export async function timedAsync<T>(scope: string, label: string, fn: () => Promise<T>): Promise<T> {
37
+ if (!isDebug) return fn();
38
+ const start = performance.now();
39
+ try {
40
+ const result = await fn();
41
+ debug(scope, `${label} took ${(performance.now() - start).toFixed(1)}ms`);
42
+ return result;
43
+ } catch (e) {
44
+ debug(scope, `${label} FAILED after ${(performance.now() - start).toFixed(1)}ms: ${e}`);
45
+ throw e;
46
+ }
47
+ }
48
+
49
+ function safeStringify(data: unknown): string {
50
+ try {
51
+ const s = typeof data === "string" ? data : JSON.stringify(data);
52
+ return s.length > 500 ? s.slice(0, 500) + "..." : s;
53
+ } catch {
54
+ return String(data);
55
+ }
56
+ }
@@ -0,0 +1,171 @@
1
+ // .autoctxd-ignore — opt-out mechanism for sensitive projects.
2
+ //
3
+ // If a project root contains a .autoctxd-ignore file, observations whose
4
+ // project path or any captured file path matches a pattern in that file are
5
+ // dropped before being written to the database.
6
+ //
7
+ // Pattern syntax is a strict subset of .gitignore — enough to be useful, simple
8
+ // enough to keep zero-dependency:
9
+ // - blank lines and lines starting with # are ignored
10
+ // - leading "/" anchors to the project root
11
+ // - trailing "/" matches directories (and everything inside them)
12
+ // - "*" matches any run of characters except "/"
13
+ // - "**" matches any run of characters including "/"
14
+ // - "?" matches a single character except "/"
15
+ // - patterns without a "/" match any segment of the path (basename match)
16
+ //
17
+ // Negation ("!pattern") is intentionally not supported — keep the model simple.
18
+
19
+ import { existsSync, readFileSync, statSync } from "node:fs";
20
+ import { join, relative, sep } from "node:path";
21
+
22
+ interface CompiledRule {
23
+ pattern: string;
24
+ regex: RegExp;
25
+ dirOnly: boolean;
26
+ anchored: boolean;
27
+ }
28
+
29
+ interface CachedIgnore {
30
+ mtimeMs: number;
31
+ rules: CompiledRule[];
32
+ }
33
+
34
+ const cache = new Map<string, CachedIgnore | null>();
35
+
36
+ function patternToRegex(pattern: string): RegExp {
37
+ let re = "";
38
+ for (let i = 0; i < pattern.length; i++) {
39
+ const c = pattern[i];
40
+ if (c === "*") {
41
+ if (pattern[i + 1] === "*") {
42
+ re += ".*";
43
+ i++;
44
+ } else {
45
+ re += "[^/]*";
46
+ }
47
+ } else if (c === "?") {
48
+ re += "[^/]";
49
+ } else if (/[.+^$(){}|[\]\\]/.test(c)) {
50
+ re += "\\" + c;
51
+ } else {
52
+ re += c;
53
+ }
54
+ }
55
+ return new RegExp("^" + re + "$");
56
+ }
57
+
58
+ function compile(raw: string): CompiledRule[] {
59
+ const rules: CompiledRule[] = [];
60
+ for (let line of raw.split(/\r?\n/)) {
61
+ line = line.trim();
62
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
63
+
64
+ const anchored = line.startsWith("/");
65
+ if (anchored) line = line.slice(1);
66
+
67
+ const dirOnly = line.endsWith("/");
68
+ if (dirOnly) line = line.slice(0, -1);
69
+
70
+ rules.push({
71
+ pattern: line,
72
+ regex: patternToRegex(line),
73
+ dirOnly,
74
+ anchored,
75
+ });
76
+ }
77
+ return rules;
78
+ }
79
+
80
+ function loadRules(projectRoot: string): CompiledRule[] | null {
81
+ const file = join(projectRoot, ".autoctxd-ignore");
82
+ let mtimeMs = 0;
83
+ try {
84
+ mtimeMs = statSync(file).mtimeMs;
85
+ } catch {
86
+ cache.set(projectRoot, null);
87
+ return null;
88
+ }
89
+
90
+ const cached = cache.get(projectRoot);
91
+ if (cached && cached.mtimeMs === mtimeMs) return cached.rules;
92
+
93
+ try {
94
+ const rules = compile(readFileSync(file, "utf8"));
95
+ cache.set(projectRoot, { mtimeMs, rules });
96
+ return rules;
97
+ } catch {
98
+ cache.set(projectRoot, null);
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function matchOne(rule: CompiledRule, relPath: string, isDir: boolean): boolean {
104
+ if (rule.dirOnly && !isDir) {
105
+ // dir-only rules also match files inside that directory
106
+ const segments = relPath.split("/");
107
+ for (let i = 1; i <= segments.length; i++) {
108
+ const prefix = segments.slice(0, i).join("/");
109
+ if (rule.regex.test(prefix)) return true;
110
+ if (!rule.anchored && rule.regex.test(segments[i - 1])) return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ if (rule.anchored) {
116
+ return rule.regex.test(relPath);
117
+ }
118
+
119
+ // unanchored: match against full path or any single segment (basename-style)
120
+ if (rule.regex.test(relPath)) return true;
121
+ for (const seg of relPath.split("/")) {
122
+ if (rule.regex.test(seg)) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ function toRelative(projectRoot: string, target: string): string {
128
+ // Normalize to forward slashes so patterns are platform-agnostic.
129
+ const rel = relative(projectRoot, target).split(sep).join("/");
130
+ // If target is outside projectRoot, relative() gives "../..." — treat as not ignorable.
131
+ if (rel.startsWith("..")) return "";
132
+ return rel;
133
+ }
134
+
135
+ function isDirectorySafe(p: string): boolean {
136
+ try {
137
+ return statSync(p).isDirectory();
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /** Returns true if the project root itself is fully ignored (e.g. user added "*" or "/" to opt out the whole project). */
144
+ export function isProjectIgnored(projectRoot: string): boolean {
145
+ const rules = loadRules(projectRoot);
146
+ if (!rules) return false;
147
+ return rules.some((r) => r.pattern === "*" || r.pattern === "**" || r.pattern === "");
148
+ }
149
+
150
+ /** Returns true if any of the given paths matches an ignore rule for the project. */
151
+ export function shouldIgnorePaths(projectRoot: string, paths: string[]): boolean {
152
+ if (!projectRoot || paths.length === 0) return false;
153
+ const rules = loadRules(projectRoot);
154
+ if (!rules || rules.length === 0) return false;
155
+ if (isProjectIgnored(projectRoot)) return true;
156
+
157
+ for (const p of paths) {
158
+ if (!p) continue;
159
+ const rel = toRelative(projectRoot, p) || p; // fall back to raw path for basename match
160
+ const isDir = isDirectorySafe(p);
161
+ for (const rule of rules) {
162
+ if (matchOne(rule, rel, isDir)) return true;
163
+ }
164
+ }
165
+ return false;
166
+ }
167
+
168
+ /** For tests: clear the in-memory cache. */
169
+ export function _resetIgnoreCache(): void {
170
+ cache.clear();
171
+ }