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,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");
|