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,334 @@
1
+ #!/usr/bin/env bun
2
+ // Stop hook: session-end logic - compress observations, generate embeddings, detect patterns
3
+
4
+ import { getDb, closeDb } from "../db/sqlite/schema";
5
+ import { getObservationsBySession, countObservationsBySession } from "../db/sqlite/observations";
6
+ import { endSession } from "../db/sqlite/sessions";
7
+ import { insertSummary } from "../db/sqlite/summaries";
8
+ import { insertDecision } from "../db/sqlite/decisions";
9
+ import { compressSession } from "../ai/compressor";
10
+ import { extractDecisionsFromObservations } from "../ai/decision-extractor";
11
+ import { generateEmbedding } from "../db/vector/embeddings";
12
+ import { addVector, closeVectorDb } from "../db/vector/client";
13
+ import { detectPatterns } from "../ai/pattern-detector";
14
+ import { debug } from "../util/debug";
15
+ import { recordSavings } from "../util/metrics";
16
+ import { detectDecisionChains } from "../ai/decision-chains";
17
+
18
+ interface HookInput {
19
+ session_id: string;
20
+ transcript_path: string;
21
+ cwd: string;
22
+ hook_event_name: string;
23
+ }
24
+
25
+ async function main() {
26
+ let input: HookInput;
27
+ try {
28
+ const raw = await Bun.stdin.text();
29
+ input = JSON.parse(raw);
30
+ } catch {
31
+ process.exit(0);
32
+ }
33
+
34
+ if (input.hook_event_name !== "Stop") {
35
+ process.exit(0);
36
+ }
37
+
38
+ try {
39
+ const sessionId = input.session_id;
40
+
41
+ // Check if this session has observations
42
+ const obsCount = countObservationsBySession(sessionId);
43
+ if (obsCount === 0) {
44
+ process.exit(0);
45
+ }
46
+
47
+ // Get all observations for this session
48
+ const observations = getObservationsBySession(sessionId);
49
+
50
+ // Compress into session summary (Level 1)
51
+ const summary = compressSession(observations, input.cwd);
52
+
53
+ // Save summary to SQLite
54
+ insertSummary({
55
+ session_id: sessionId,
56
+ level: 1,
57
+ text: summary.text,
58
+ project_path: input.cwd,
59
+ });
60
+
61
+ // Generate embedding and save to LanceDB
62
+ try {
63
+ const embedding = await generateEmbedding(summary.text);
64
+ await addVector({
65
+ id: `summary-${sessionId}`,
66
+ session_id: sessionId,
67
+ project_path: input.cwd,
68
+ text: summary.text,
69
+ level: 1,
70
+ created_at: new Date().toISOString(),
71
+ vector: Array.from(embedding),
72
+ });
73
+ } catch (e) {
74
+ // Vector storage is non-critical
75
+ }
76
+
77
+ // Extract and save decisions
78
+ const decisions = extractDecisionsFromObservations(observations, input.cwd);
79
+ for (const decision of decisions) {
80
+ insertDecision(decision);
81
+
82
+ // Also embed decisions for semantic search
83
+ try {
84
+ const decEmbedding = await generateEmbedding(`${decision.title} ${decision.decision_text}`);
85
+ await addVector({
86
+ id: `decision-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
87
+ session_id: sessionId,
88
+ project_path: input.cwd,
89
+ text: `DECISION: ${decision.title} — ${decision.decision_text}`,
90
+ level: 9, // High priority level for decisions
91
+ created_at: new Date().toISOString(),
92
+ vector: Array.from(decEmbedding),
93
+ });
94
+ } catch {
95
+ // Non-critical
96
+ }
97
+ }
98
+
99
+ // Detect and save patterns
100
+ try {
101
+ detectPatterns(observations, input.cwd);
102
+ } catch {
103
+ // Non-critical
104
+ }
105
+
106
+ // Update session end
107
+ endSession(sessionId, obsCount);
108
+
109
+ // Token savings estimate
110
+ recordSavings(sessionId, observations);
111
+
112
+ // Detect cross-session decision chains (e.g. A→B then B→C)
113
+ try {
114
+ detectDecisionChains(input.cwd);
115
+ } catch {
116
+ // Non-critical
117
+ }
118
+
119
+ // Check if we need a weekly digest
120
+ try {
121
+ await maybeGenerateWeeklyDigest(input.cwd);
122
+ } catch {
123
+ // Non-critical
124
+ }
125
+
126
+ // Check if we need a monthly digest (Level 3)
127
+ try {
128
+ await maybeGenerateMonthlyDigest(input.cwd);
129
+ } catch {
130
+ // Non-critical
131
+ }
132
+
133
+ debug("stop", "session ended", {
134
+ session: sessionId.slice(0, 8),
135
+ observations: obsCount,
136
+ decisions: decisions.length,
137
+ });
138
+
139
+ } catch (e) {
140
+ const errorLog = Bun.file(`${import.meta.dir}/../../data/error.log`);
141
+ await Bun.write(errorLog, `${new Date().toISOString()} Stop error: ${e}\n`);
142
+ } finally {
143
+ closeDb();
144
+ await closeVectorDb();
145
+ }
146
+
147
+ process.exit(0);
148
+ }
149
+
150
+ async function maybeGenerateWeeklyDigest(projectPath: string) {
151
+ const db = getDb();
152
+
153
+ // Check last weekly digest
154
+ const lastDigest = db.prepare(`
155
+ SELECT created_at FROM summaries
156
+ WHERE project_path = ? AND level = 2
157
+ ORDER BY created_at DESC LIMIT 1
158
+ `).get(projectPath) as any;
159
+
160
+ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
161
+
162
+ if (lastDigest && lastDigest.created_at > oneWeekAgo) {
163
+ return; // Too recent
164
+ }
165
+
166
+ // Get all session summaries since last digest (or last 7 days)
167
+ const since = lastDigest?.created_at || oneWeekAgo;
168
+ const sessionSummaries = db.prepare(`
169
+ SELECT text FROM summaries
170
+ WHERE project_path = ? AND level = 1 AND created_at >= ?
171
+ ORDER BY created_at ASC
172
+ `).all(projectPath, since) as Array<{ text: string }>;
173
+
174
+ if (sessionSummaries.length < 2) {
175
+ return; // Not enough data
176
+ }
177
+
178
+ // Generate weekly digest (heuristic: combine and compress)
179
+ const combined = sessionSummaries.map(s => s.text).join("\n---\n");
180
+ const digestLines: string[] = [
181
+ `Weekly Digest | ${new Date().toISOString().slice(0, 10)}`,
182
+ `Sessions: ${sessionSummaries.length}`,
183
+ "",
184
+ ];
185
+
186
+ // Extract key themes by counting common words across summaries
187
+ const wordCounts = new Map<string, number>();
188
+ for (const s of sessionSummaries) {
189
+ const words = s.text.toLowerCase().split(/\s+/).filter(w => w.length > 3);
190
+ const unique = new Set(words);
191
+ for (const w of unique) {
192
+ wordCounts.set(w, (wordCounts.get(w) || 0) + 1);
193
+ }
194
+ }
195
+
196
+ const themes = [...wordCounts.entries()]
197
+ .filter(([, c]) => c >= 2)
198
+ .sort((a, b) => b[1] - a[1])
199
+ .slice(0, 10)
200
+ .map(([w]) => w);
201
+
202
+ if (themes.length > 0) {
203
+ digestLines.push(`Key themes: ${themes.join(", ")}`);
204
+ }
205
+
206
+ // Take first 2 lines from each session summary
207
+ digestLines.push("");
208
+ digestLines.push("Session highlights:");
209
+ for (const s of sessionSummaries.slice(-5)) {
210
+ const firstLines = s.text.split("\n").filter(l => l.trim()).slice(0, 2);
211
+ for (const line of firstLines) {
212
+ digestLines.push(` ${line.trim()}`);
213
+ }
214
+ }
215
+
216
+ const digestText = digestLines.join("\n");
217
+
218
+ insertSummary({
219
+ level: 2,
220
+ text: digestText,
221
+ project_path: projectPath,
222
+ });
223
+
224
+ // Embed weekly digest
225
+ try {
226
+ const embedding = await generateEmbedding(digestText);
227
+ await addVector({
228
+ id: `digest-${Date.now()}`,
229
+ session_id: "",
230
+ project_path: projectPath,
231
+ text: digestText,
232
+ level: 2,
233
+ created_at: new Date().toISOString(),
234
+ vector: Array.from(embedding),
235
+ });
236
+ } catch {
237
+ // Non-critical
238
+ }
239
+ }
240
+
241
+ async function maybeGenerateMonthlyDigest(projectPath: string) {
242
+ const db = getDb();
243
+
244
+ const lastDigest = db.prepare(`
245
+ SELECT created_at FROM summaries
246
+ WHERE project_path = ? AND level = 3
247
+ ORDER BY created_at DESC LIMIT 1
248
+ `).get(projectPath) as any;
249
+
250
+ const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
251
+
252
+ if (lastDigest && lastDigest.created_at > oneMonthAgo) {
253
+ return;
254
+ }
255
+
256
+ const since = lastDigest?.created_at || oneMonthAgo;
257
+
258
+ // Aggregate from weekly digests (Level 2) if available, else from session summaries
259
+ const weeklies = db.prepare(`
260
+ SELECT text FROM summaries
261
+ WHERE project_path = ? AND level = 2 AND created_at >= ?
262
+ ORDER BY created_at ASC
263
+ `).all(projectPath, since) as Array<{ text: string }>;
264
+
265
+ if (weeklies.length < 2) return; // Need at least 2 weekly digests
266
+
267
+ const lines: string[] = [
268
+ `Monthly Digest | ${new Date().toISOString().slice(0, 10)}`,
269
+ `Weekly digests aggregated: ${weeklies.length}`,
270
+ "",
271
+ ];
272
+
273
+ // Extract recurring themes from weekly digests
274
+ const themeCounts = new Map<string, number>();
275
+ for (const w of weeklies) {
276
+ const m = w.text.match(/Key themes:\s*(.+)/);
277
+ if (m) {
278
+ for (const theme of m[1].split(/,\s*/)) {
279
+ const t = theme.trim().toLowerCase();
280
+ if (t) themeCounts.set(t, (themeCounts.get(t) || 0) + 1);
281
+ }
282
+ }
283
+ }
284
+
285
+ const persistentThemes = [...themeCounts.entries()]
286
+ .filter(([, c]) => c >= 2)
287
+ .sort((a, b) => b[1] - a[1])
288
+ .slice(0, 8)
289
+ .map(([t]) => t);
290
+
291
+ if (persistentThemes.length > 0) {
292
+ lines.push(`Persistent themes across the month: ${persistentThemes.join(", ")}`);
293
+ }
294
+
295
+ // Top decisions of the month
296
+ const topDecs = db.prepare(`
297
+ SELECT title FROM decisions
298
+ WHERE project_path = ? AND created_at >= ?
299
+ ORDER BY created_at DESC LIMIT 10
300
+ `).all(projectPath, since) as Array<{ title: string }>;
301
+
302
+ if (topDecs.length > 0) {
303
+ lines.push("");
304
+ lines.push("Key decisions this month:");
305
+ for (const d of topDecs) {
306
+ lines.push(` • ${d.title}`);
307
+ }
308
+ }
309
+
310
+ const digestText = lines.join("\n");
311
+
312
+ insertSummary({
313
+ level: 3,
314
+ text: digestText,
315
+ project_path: projectPath,
316
+ });
317
+
318
+ try {
319
+ const embedding = await generateEmbedding(digestText);
320
+ await addVector({
321
+ id: `monthly-${Date.now()}`,
322
+ session_id: "",
323
+ project_path: projectPath,
324
+ text: digestText,
325
+ level: 3,
326
+ created_at: new Date().toISOString(),
327
+ vector: Array.from(embedding),
328
+ });
329
+ } catch {
330
+ // Non-critical
331
+ }
332
+ }
333
+
334
+ main();
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env bun
2
+ // autoctxd MCP server — Model Context Protocol server that lets Claude (and
3
+ // any other MCP-compatible client: Cursor, Windsurf, Cline) query memory on
4
+ // demand during reasoning, not just at session start.
5
+ //
6
+ // Exposes 7 tools that Claude can call during a conversation:
7
+ // - recall_decisions Look up architectural decisions for a project
8
+ // - recall_unfinished Get blocked/pending items from past sessions
9
+ // - search_memory Hybrid semantic + FTS search
10
+ // - get_project_history Recent session summaries for a project
11
+ // - check_intent Active Guard: does this action contradict a past decision?
12
+ // - record_feedback Mark a past inject as useful/irrelevant/wrong
13
+ // - record_decision Let Claude explicitly log a decision it just made
14
+
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import {
18
+ CallToolRequestSchema,
19
+ ListToolsRequestSchema,
20
+ } from "@modelcontextprotocol/sdk/types.js";
21
+ import { getDb, closeDb } from "../db/sqlite/schema";
22
+ import { getDecisionsByProject, insertDecision } from "../db/sqlite/decisions";
23
+ import { getRecentSummaries } from "../db/sqlite/summaries";
24
+ import { getUnfinishedItems } from "../ai/proactive";
25
+ import { hybridSearch } from "../db/vector/search";
26
+ import { closeVectorDb } from "../db/vector/client";
27
+ import { checkIntent } from "../ai/active-guard";
28
+ import { recordFeedback, type Verdict } from "../db/sqlite/feedback";
29
+ import { debug } from "../util/debug";
30
+
31
+ const server = new Server(
32
+ {
33
+ name: "autoctxd",
34
+ version: "0.2.0",
35
+ },
36
+ {
37
+ capabilities: {
38
+ tools: {},
39
+ },
40
+ }
41
+ );
42
+
43
+ // Log every tool call to mcp_access_log so we can analyze what Claude queries
44
+ function logAccess(tool: string, args: any, resultCount: number) {
45
+ try {
46
+ const db = getDb();
47
+ db.prepare(`
48
+ INSERT INTO mcp_access_log (tool_name, args, result_count, project_path)
49
+ VALUES (?, ?, ?, ?)
50
+ `).run(tool, JSON.stringify(args || {}).slice(0, 1000), resultCount, args?.project_path || null);
51
+ } catch {}
52
+ }
53
+
54
+ function textContent(text: string) {
55
+ return { content: [{ type: "text" as const, text }] };
56
+ }
57
+
58
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
59
+ tools: [
60
+ {
61
+ name: "recall_decisions",
62
+ description:
63
+ "Retrieve architectural decisions the user has previously made in a project. Use this BEFORE suggesting technologies, patterns, or major changes — the user may have already decided against alternatives. Returns title, decision text, alternatives rejected, and rationale when available.",
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: {
67
+ project_path: { type: "string", description: "Absolute path of the project. If omitted, returns decisions across all projects." },
68
+ limit: { type: "number", description: "Max decisions to return (default 10)" },
69
+ },
70
+ },
71
+ },
72
+ {
73
+ name: "recall_unfinished",
74
+ description:
75
+ "Get items the user was blocked on or left unfinished in past sessions on this project. Surface these at the start of work so the user doesn't need to re-explain what they were stuck on.",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ project_path: { type: "string", description: "Absolute path of the project" },
80
+ limit: { type: "number", description: "Max items (default 5)" },
81
+ },
82
+ required: ["project_path"],
83
+ },
84
+ },
85
+ {
86
+ name: "search_memory",
87
+ description:
88
+ "Hybrid semantic + full-text search across the user's entire coding memory (all projects unless filtered). Use when the user asks 'have we done X before?' or when you need to recall past work.",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ query: { type: "string", description: "Natural language query" },
93
+ project_path: { type: "string", description: "Optional filter to a single project" },
94
+ limit: { type: "number", description: "Max results (default 8)" },
95
+ },
96
+ required: ["query"],
97
+ },
98
+ },
99
+ {
100
+ name: "get_project_history",
101
+ description:
102
+ "Get recent session summaries for a project. Each summary covers a past work session: what was done, key files touched, decisions made. Use to understand the arc of recent work.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ project_path: { type: "string" },
107
+ limit: { type: "number", description: "Default 3" },
108
+ },
109
+ required: ["project_path"],
110
+ },
111
+ },
112
+ {
113
+ name: "check_intent",
114
+ description:
115
+ "ACTIVE GUARD. Before you execute a non-trivial action (installing a package, migrating a library, rewriting a subsystem), call this with your intent. Returns warnings if the action contradicts a past decision (e.g. installing a library you previously rejected).",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ project_path: { type: "string" },
120
+ intent: { type: "string", description: "Natural description of what you're about to do, e.g. 'installing prisma for the ORM' or 'switching auth to JWT'" },
121
+ },
122
+ required: ["project_path", "intent"],
123
+ },
124
+ },
125
+ {
126
+ name: "record_feedback",
127
+ description:
128
+ "When the user indicates that something surfaced from memory was helpful or unhelpful, record it. This makes autoctxd learn to suppress irrelevant items and amplify useful ones for this user specifically.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ target_type: { type: "string", enum: ["decision", "observation", "summary", "pattern", "unfinished"] },
133
+ target_id: { type: "string", description: "ID of the item being rated" },
134
+ target_text: { type: "string", description: "Short description of what was rated (for human review)" },
135
+ verdict: { type: "string", enum: ["useful", "irrelevant", "wrong"] },
136
+ reason: { type: "string", description: "Optional explanation from the user" },
137
+ project_path: { type: "string" },
138
+ },
139
+ required: ["target_type", "target_id", "verdict"],
140
+ },
141
+ },
142
+ {
143
+ name: "record_decision",
144
+ description:
145
+ "Explicitly persist a decision the user just made during this conversation. Use when the user says something like 'let's go with X' or 'we're not going to use Y' — decisions recorded this way will never be forgotten and will guide future sessions.",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ project_path: { type: "string" },
150
+ title: { type: "string", description: "Short title like 'Chose Postgres for main DB'" },
151
+ decision_text: { type: "string", description: "Full explanation of the decision" },
152
+ alternatives: { type: "string", description: "Comma-separated list of rejected alternatives" },
153
+ rationale: { type: "string", description: "Why this choice was made" },
154
+ },
155
+ required: ["project_path", "title", "decision_text"],
156
+ },
157
+ },
158
+ ],
159
+ }));
160
+
161
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
162
+ const name = request.params.name;
163
+ const args = (request.params.arguments || {}) as any;
164
+ debug("mcp", `tool called: ${name}`, args);
165
+
166
+ try {
167
+ if (name === "recall_decisions") {
168
+ const limit = Math.min(50, args.limit || 10);
169
+ let results: any[];
170
+ if (args.project_path) {
171
+ results = getDecisionsByProject(args.project_path).slice(0, limit);
172
+ } else {
173
+ const db = getDb();
174
+ results = db.prepare("SELECT * FROM decisions ORDER BY created_at DESC LIMIT ?").all(limit) as any[];
175
+ }
176
+ logAccess(name, args, results.length);
177
+ if (results.length === 0) {
178
+ return textContent("No decisions recorded for this project yet.");
179
+ }
180
+ const formatted = results.map((d, i) => {
181
+ const parts = [`${i + 1}. ${d.title} [id=${d.id}]`];
182
+ parts.push(` Decision: ${d.decision_text}`);
183
+ if (d.alternatives) parts.push(` Rejected: ${d.alternatives}`);
184
+ if (d.rationale) parts.push(` Reason: ${d.rationale}`);
185
+ if (d.files_affected) parts.push(` Files: ${d.files_affected}`);
186
+ parts.push(` When: ${d.created_at}`);
187
+ return parts.join("\n");
188
+ }).join("\n\n");
189
+ return textContent(formatted);
190
+ }
191
+
192
+ if (name === "recall_unfinished") {
193
+ const items = getUnfinishedItems(args.project_path, args.limit || 5);
194
+ logAccess(name, args, items.length);
195
+ if (items.length === 0) {
196
+ return textContent("No unfinished/blocked items in the past 30 days for this project.");
197
+ }
198
+ const formatted = items.map((u, i) => {
199
+ const age = u.age_days <= 0 ? "today" : u.age_days === 1 ? "yesterday" : `${u.age_days} days ago`;
200
+ return `${i + 1}. [${age}] ${u.summary}${u.file_paths ? `\n Files: ${u.file_paths}` : ""}`;
201
+ }).join("\n\n");
202
+ return textContent(formatted);
203
+ }
204
+
205
+ if (name === "search_memory") {
206
+ const results = await hybridSearch(args.query, {
207
+ limit: args.limit || 8,
208
+ projectPath: args.project_path,
209
+ });
210
+ logAccess(name, args, results.length);
211
+ if (results.length === 0) {
212
+ return textContent(`No matches for: ${args.query}`);
213
+ }
214
+ const formatted = results.map((r, i) => {
215
+ const when = r.metadata?.timestamp || r.metadata?.created_at || "";
216
+ return `${i + 1}. [${r.source}] ${r.text}${when ? `\n (${when})` : ""}`;
217
+ }).join("\n\n");
218
+ return textContent(formatted);
219
+ }
220
+
221
+ if (name === "get_project_history") {
222
+ const summaries = getRecentSummaries(args.project_path, 1, args.limit || 3);
223
+ logAccess(name, args, summaries.length);
224
+ if (summaries.length === 0) {
225
+ return textContent("No past sessions recorded for this project yet.");
226
+ }
227
+ const formatted = summaries.map((s, i) => `### Session ${i + 1} (${s.created_at})\n${s.text}`).join("\n\n---\n\n");
228
+ return textContent(formatted);
229
+ }
230
+
231
+ if (name === "check_intent") {
232
+ const warnings = checkIntent(args.project_path, args.intent);
233
+ logAccess(name, args, warnings.length);
234
+ if (warnings.length === 0) {
235
+ return textContent(`No conflicts detected. "${args.intent}" appears consistent with past decisions.`);
236
+ }
237
+ const formatted = [
238
+ `⚠ INTENT CONFLICTS WITH ${warnings.length} PAST DECISION(S):`,
239
+ "",
240
+ ...warnings.map((w, i) => {
241
+ return `${i + 1}. ${w.decision.title} (confidence ${(w.confidence * 100).toFixed(0)}%)
242
+ Reason: ${w.reason}
243
+ Original decision: ${w.decision.decision_text}${w.decision.rationale ? `\n Original rationale: ${w.decision.rationale}` : ""}`;
244
+ }),
245
+ "",
246
+ "Consider: re-confirm with the user before proceeding, or explicitly override the past decision with record_decision.",
247
+ ].join("\n");
248
+ return textContent(formatted);
249
+ }
250
+
251
+ if (name === "record_feedback") {
252
+ const id = recordFeedback({
253
+ target_type: args.target_type,
254
+ target_id: String(args.target_id),
255
+ target_text: args.target_text,
256
+ verdict: args.verdict as Verdict,
257
+ reason: args.reason,
258
+ project_path: args.project_path,
259
+ });
260
+ logAccess(name, args, 1);
261
+ return textContent(`Feedback recorded (id=${id}). Future rankings will adapt.`);
262
+ }
263
+
264
+ if (name === "record_decision") {
265
+ insertDecision({
266
+ project_path: args.project_path,
267
+ title: args.title,
268
+ decision_text: args.decision_text,
269
+ alternatives: args.alternatives,
270
+ rationale: args.rationale,
271
+ });
272
+ logAccess(name, args, 1);
273
+ return textContent(`Decision recorded: "${args.title}". It will surface in all future sessions on this project.`);
274
+ }
275
+
276
+ return textContent(`Unknown tool: ${name}`);
277
+ } catch (e) {
278
+ debug("mcp", `tool error: ${name}`, String(e));
279
+ return textContent(`Error: ${e}`);
280
+ }
281
+ });
282
+
283
+ // Cleanup on exit
284
+ const cleanup = async () => {
285
+ try { closeDb(); } catch {}
286
+ try { await closeVectorDb(); } catch {}
287
+ };
288
+ process.on("SIGINT", async () => { await cleanup(); process.exit(0); });
289
+ process.on("SIGTERM", async () => { await cleanup(); process.exit(0); });
290
+
291
+ const transport = new StdioServerTransport();
292
+ await server.connect(transport);
293
+ debug("mcp", "autoctxd MCP server started");