@gswangg/duncan-cc 0.1.0
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/README.md +110 -0
- package/SPEC.md +195 -0
- package/package.json +39 -0
- package/src/content-replacements.ts +185 -0
- package/src/discovery.ts +340 -0
- package/src/mcp-server.ts +356 -0
- package/src/normalize.ts +702 -0
- package/src/parser.ts +257 -0
- package/src/pipeline.ts +274 -0
- package/src/query.ts +626 -0
- package/src/system-prompt.ts +408 -0
- package/src/tree.ts +371 -0
- package/tests/_skip-if-no-corpus.ts +12 -0
- package/tests/compaction.test.ts +205 -0
- package/tests/content-replacements.test.ts +214 -0
- package/tests/discovery.test.ts +129 -0
- package/tests/normalize.test.ts +192 -0
- package/tests/parity.test.ts +226 -0
- package/tests/parser-tree.test.ts +268 -0
- package/tests/pipeline.test.ts +174 -0
- package/tests/self-exclusion.test.ts +272 -0
- package/tests/system-prompt.test.ts +238 -0
- package/tsconfig.json +14 -0
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Discovery
|
|
3
|
+
*
|
|
4
|
+
* Finds CC session files on disk, supports routing modes:
|
|
5
|
+
* - project: all sessions in the same project dir (same cwd)
|
|
6
|
+
* - global: all sessions across all projects
|
|
7
|
+
* - specific: a named session file
|
|
8
|
+
*
|
|
9
|
+
* Also discovers subagent transcripts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdirSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
13
|
+
import { join, basename, dirname } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Path resolution — mirrors CC's path layout
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/** Get CC's projects directory */
|
|
21
|
+
export function getProjectsDir(): string {
|
|
22
|
+
return join(homedir(), ".claude", "projects");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Get project dir for a given cwd (hashed) */
|
|
26
|
+
export function getProjectDir(cwd: string): string | null {
|
|
27
|
+
const projectsDir = getProjectsDir();
|
|
28
|
+
if (!existsSync(projectsDir)) return null;
|
|
29
|
+
|
|
30
|
+
// CC hashes the cwd into the directory name
|
|
31
|
+
// The format is: the cwd with / replaced by - and leading - stripped
|
|
32
|
+
// e.g., /Users/foo/bar → -Users-foo-bar
|
|
33
|
+
const hashed = cwd.replace(/\//g, "-");
|
|
34
|
+
|
|
35
|
+
const fullPath = join(projectsDir, hashed);
|
|
36
|
+
if (existsSync(fullPath)) return fullPath;
|
|
37
|
+
|
|
38
|
+
// Try to find by scanning (the hash might differ slightly)
|
|
39
|
+
try {
|
|
40
|
+
const dirs = readdirSync(projectsDir, { withFileTypes: true });
|
|
41
|
+
for (const d of dirs) {
|
|
42
|
+
if (!d.isDirectory()) continue;
|
|
43
|
+
// Check if the directory name ends with the last component(s) of cwd
|
|
44
|
+
if (d.name === hashed || d.name.endsWith(hashed)) {
|
|
45
|
+
return join(projectsDir, d.name);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Session file discovery
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
export interface SessionFileInfo {
|
|
58
|
+
path: string;
|
|
59
|
+
sessionId: string;
|
|
60
|
+
mtime: Date;
|
|
61
|
+
size: number;
|
|
62
|
+
projectDir: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** List all session files in a project directory */
|
|
66
|
+
export function listSessionFiles(projectDir: string): SessionFileInfo[] {
|
|
67
|
+
if (!existsSync(projectDir)) return [];
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const entries = readdirSync(projectDir, { withFileTypes: true });
|
|
71
|
+
const sessions: SessionFileInfo[] = [];
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
75
|
+
const fullPath = join(projectDir, entry.name);
|
|
76
|
+
try {
|
|
77
|
+
const stat = statSync(fullPath);
|
|
78
|
+
sessions.push({
|
|
79
|
+
path: fullPath,
|
|
80
|
+
sessionId: entry.name.replace(".jsonl", ""),
|
|
81
|
+
mtime: stat.mtime,
|
|
82
|
+
size: stat.size,
|
|
83
|
+
projectDir,
|
|
84
|
+
});
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sort by mtime, newest first
|
|
89
|
+
sessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
90
|
+
return sessions;
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** List all session files across all projects */
|
|
97
|
+
export function listAllSessionFiles(): SessionFileInfo[] {
|
|
98
|
+
const projectsDir = getProjectsDir();
|
|
99
|
+
if (!existsSync(projectsDir)) return [];
|
|
100
|
+
|
|
101
|
+
const allSessions: SessionFileInfo[] = [];
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const projectDirs = readdirSync(projectsDir, { withFileTypes: true });
|
|
105
|
+
for (const d of projectDirs) {
|
|
106
|
+
if (!d.isDirectory()) continue;
|
|
107
|
+
const sessions = listSessionFiles(join(projectsDir, d.name));
|
|
108
|
+
allSessions.push(...sessions);
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
|
|
112
|
+
// Sort by mtime, newest first
|
|
113
|
+
allSessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
114
|
+
return allSessions;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** List subagent transcript files for a session */
|
|
118
|
+
export function listSubagentFiles(sessionFile: string): SessionFileInfo[] {
|
|
119
|
+
const sessionId = basename(sessionFile, ".jsonl");
|
|
120
|
+
const sessionDir = join(dirname(sessionFile), sessionId, "subagents");
|
|
121
|
+
|
|
122
|
+
if (!existsSync(sessionDir)) return [];
|
|
123
|
+
|
|
124
|
+
const files: SessionFileInfo[] = [];
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// Subagents can be in subdirectories
|
|
128
|
+
function scanDir(dir: string) {
|
|
129
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const fullPath = join(dir, entry.name);
|
|
132
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
133
|
+
try {
|
|
134
|
+
const stat = statSync(fullPath);
|
|
135
|
+
files.push({
|
|
136
|
+
path: fullPath,
|
|
137
|
+
sessionId: entry.name.replace(".jsonl", ""),
|
|
138
|
+
mtime: stat.mtime,
|
|
139
|
+
size: stat.size,
|
|
140
|
+
projectDir: dirname(sessionFile),
|
|
141
|
+
});
|
|
142
|
+
} catch {}
|
|
143
|
+
} else if (entry.isDirectory()) {
|
|
144
|
+
scanDir(fullPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
scanDir(sessionDir);
|
|
149
|
+
} catch {}
|
|
150
|
+
|
|
151
|
+
return files;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// Routing
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
158
|
+
export type RoutingMode = "project" | "global" | "session" | "self" | "ancestors";
|
|
159
|
+
|
|
160
|
+
export interface RoutingParams {
|
|
161
|
+
mode: RoutingMode;
|
|
162
|
+
/** For "project" mode: the cwd to find sessions for */
|
|
163
|
+
cwd?: string;
|
|
164
|
+
/** For "project" mode: explicit project dir path */
|
|
165
|
+
projectDir?: string;
|
|
166
|
+
/** For "session" mode: specific session file path or ID */
|
|
167
|
+
sessionId?: string;
|
|
168
|
+
/** Include subagent transcripts */
|
|
169
|
+
includeSubagents?: boolean;
|
|
170
|
+
/** Max sessions to return */
|
|
171
|
+
limit?: number;
|
|
172
|
+
/** Offset for pagination */
|
|
173
|
+
offset?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface RoutingResult {
|
|
177
|
+
sessions: SessionFileInfo[];
|
|
178
|
+
totalCount: number;
|
|
179
|
+
hasMore: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Resolve session files based on routing parameters */
|
|
183
|
+
export function resolveSessionFiles(params: RoutingParams): RoutingResult {
|
|
184
|
+
const limit = params.limit ?? 50;
|
|
185
|
+
const offset = params.offset ?? 0;
|
|
186
|
+
let allSessions: SessionFileInfo[];
|
|
187
|
+
|
|
188
|
+
switch (params.mode) {
|
|
189
|
+
case "project": {
|
|
190
|
+
const projectDir = params.projectDir ?? (params.cwd ? getProjectDir(params.cwd) : null);
|
|
191
|
+
if (!projectDir) {
|
|
192
|
+
return { sessions: [], totalCount: 0, hasMore: false };
|
|
193
|
+
}
|
|
194
|
+
allSessions = listSessionFiles(projectDir);
|
|
195
|
+
|
|
196
|
+
// Optionally include subagents
|
|
197
|
+
if (params.includeSubagents) {
|
|
198
|
+
const withSubagents: SessionFileInfo[] = [];
|
|
199
|
+
for (const s of allSessions) {
|
|
200
|
+
withSubagents.push(s);
|
|
201
|
+
withSubagents.push(...listSubagentFiles(s.path));
|
|
202
|
+
}
|
|
203
|
+
allSessions = withSubagents;
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case "global": {
|
|
209
|
+
allSessions = listAllSessionFiles();
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "session": {
|
|
214
|
+
if (!params.sessionId) {
|
|
215
|
+
return { sessions: [], totalCount: 0, hasMore: false };
|
|
216
|
+
}
|
|
217
|
+
// Try as full path first
|
|
218
|
+
if (existsSync(params.sessionId)) {
|
|
219
|
+
const stat = statSync(params.sessionId);
|
|
220
|
+
allSessions = [{
|
|
221
|
+
path: params.sessionId,
|
|
222
|
+
sessionId: basename(params.sessionId, ".jsonl"),
|
|
223
|
+
mtime: stat.mtime,
|
|
224
|
+
size: stat.size,
|
|
225
|
+
projectDir: dirname(params.sessionId),
|
|
226
|
+
}];
|
|
227
|
+
} else {
|
|
228
|
+
// Try to find by session ID across all projects
|
|
229
|
+
const all = listAllSessionFiles();
|
|
230
|
+
allSessions = all.filter((s) => s.sessionId === params.sessionId);
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
default:
|
|
236
|
+
return { sessions: [], totalCount: 0, hasMore: false };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const totalCount = allSessions.length;
|
|
240
|
+
const sessions = allSessions.slice(offset, offset + limit);
|
|
241
|
+
const hasMore = offset + limit < totalCount;
|
|
242
|
+
|
|
243
|
+
return { sessions, totalCount, hasMore };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ============================================================================
|
|
247
|
+
// Self-exclusion — find the calling session by toolUseId
|
|
248
|
+
// ============================================================================
|
|
249
|
+
|
|
250
|
+
/** Size of tail chunk to scan for toolUseId (bytes). */
|
|
251
|
+
const TAIL_SCAN_BYTES = 32_768;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Scan the tail of a JSONL file for a tool_use ID string.
|
|
255
|
+
* Returns true if found. Only reads the last TAIL_SCAN_BYTES of the file
|
|
256
|
+
* to keep I/O minimal — the tool_use that triggered the MCP call will be
|
|
257
|
+
* near the very end of the active session file.
|
|
258
|
+
*/
|
|
259
|
+
function tailContains(filePath: string, needle: string): boolean {
|
|
260
|
+
let fd: number | undefined;
|
|
261
|
+
try {
|
|
262
|
+
const stat = statSync(filePath);
|
|
263
|
+
const size = stat.size;
|
|
264
|
+
if (size === 0) return false;
|
|
265
|
+
|
|
266
|
+
const readSize = Math.min(size, TAIL_SCAN_BYTES);
|
|
267
|
+
const offset = size - readSize;
|
|
268
|
+
const buf = Buffer.alloc(readSize);
|
|
269
|
+
|
|
270
|
+
fd = openSync(filePath, "r");
|
|
271
|
+
readSync(fd, buf, 0, readSize, offset);
|
|
272
|
+
closeSync(fd);
|
|
273
|
+
fd = undefined;
|
|
274
|
+
|
|
275
|
+
return buf.includes(needle);
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
} finally {
|
|
279
|
+
if (fd !== undefined) {
|
|
280
|
+
try { closeSync(fd); } catch {}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Find the session that contains a specific tool_use ID.
|
|
287
|
+
*
|
|
288
|
+
* When CC calls an MCP tool, it writes the assistant message (containing
|
|
289
|
+
* the tool_use block) to the session JSONL *before* invoking the tool.
|
|
290
|
+
* CC passes the tool_use ID in the MCP request's `_meta` field as
|
|
291
|
+
* `"claudecode/toolUseId"`. We scan the tail of candidate session files
|
|
292
|
+
* to find the one containing that ID — that's the calling session.
|
|
293
|
+
*
|
|
294
|
+
* @param toolUseId The tool_use ID from MCP request `_meta`
|
|
295
|
+
* @param candidates Session files to search (pre-filtered by routing mode)
|
|
296
|
+
* @returns The matching session's ID, or null if not found
|
|
297
|
+
*/
|
|
298
|
+
export function findCallingSession(
|
|
299
|
+
toolUseId: string,
|
|
300
|
+
candidates: SessionFileInfo[],
|
|
301
|
+
): string | null {
|
|
302
|
+
if (!toolUseId) return null;
|
|
303
|
+
|
|
304
|
+
for (const session of candidates) {
|
|
305
|
+
if (tailContains(session.path, toolUseId)) {
|
|
306
|
+
return session.sessionId;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Resolve session files with self-exclusion.
|
|
315
|
+
*
|
|
316
|
+
* Like `resolveSessionFiles` but also accepts a `toolUseId` to identify
|
|
317
|
+
* and exclude the calling session from results.
|
|
318
|
+
*/
|
|
319
|
+
export function resolveSessionFilesExcludingSelf(
|
|
320
|
+
params: RoutingParams & { toolUseId?: string },
|
|
321
|
+
): RoutingResult & { excludedSessionId: string | null } {
|
|
322
|
+
const result = resolveSessionFiles(params);
|
|
323
|
+
|
|
324
|
+
if (!params.toolUseId || result.sessions.length === 0) {
|
|
325
|
+
return { ...result, excludedSessionId: null };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const excludedId = findCallingSession(params.toolUseId, result.sessions);
|
|
329
|
+
if (!excludedId) {
|
|
330
|
+
return { ...result, excludedSessionId: null };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const filtered = result.sessions.filter((s) => s.sessionId !== excludedId);
|
|
334
|
+
return {
|
|
335
|
+
sessions: filtered,
|
|
336
|
+
totalCount: result.totalCount - 1,
|
|
337
|
+
hasMore: result.hasMore,
|
|
338
|
+
excludedSessionId: excludedId,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Duncan CC — MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes duncan session querying as an MCP tool that Claude Code can call.
|
|
6
|
+
* Uses stdio transport.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx tsx src/mcp-server.ts
|
|
10
|
+
* # or after build:
|
|
11
|
+
* node dist/mcp-server.js
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import {
|
|
17
|
+
CallToolRequestSchema,
|
|
18
|
+
ListToolsRequestSchema,
|
|
19
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
+
|
|
21
|
+
import { processSessionFile, processSessionWindows } from "./pipeline.js";
|
|
22
|
+
import { resolveSessionFiles, resolveSessionFilesExcludingSelf, getProjectsDir, listAllSessionFiles } from "./discovery.js";
|
|
23
|
+
import { querySingleWindow, queryBatch, querySelf, queryAncestors } from "./query.js";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Server setup
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
const server = new Server(
|
|
30
|
+
{ name: "duncan-cc", version: "0.1.0" },
|
|
31
|
+
{ capabilities: { tools: {} } },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Tool definitions
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
39
|
+
tools: [
|
|
40
|
+
{
|
|
41
|
+
name: "duncan_query",
|
|
42
|
+
description:
|
|
43
|
+
"Query dormant Claude Code sessions to recall information from previous conversations. " +
|
|
44
|
+
"Loads session context and asks the target session's model whether it has relevant information. " +
|
|
45
|
+
"Use when you need to find something discussed in a previous CC session.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object" as const,
|
|
48
|
+
properties: {
|
|
49
|
+
question: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "The question to ask previous sessions. Be specific and self-contained.",
|
|
52
|
+
},
|
|
53
|
+
mode: {
|
|
54
|
+
type: "string",
|
|
55
|
+
enum: ["project", "global", "session", "self", "ancestors"],
|
|
56
|
+
description:
|
|
57
|
+
"Routing mode. 'project': sessions from a specific project dir. " +
|
|
58
|
+
"'global': all sessions across all projects (newest first). " +
|
|
59
|
+
"'session': a specific session file. " +
|
|
60
|
+
"'self': query own active window N times for sampling diversity. " +
|
|
61
|
+
"'ancestors': query own prior compaction windows (excluding active).",
|
|
62
|
+
},
|
|
63
|
+
projectDir: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "For 'project' mode: explicit project directory path. If omitted, uses cwd.",
|
|
66
|
+
},
|
|
67
|
+
sessionId: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "For 'session' mode: session file path or session ID.",
|
|
70
|
+
},
|
|
71
|
+
cwd: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "Working directory for context resolution (CLAUDE.md, git status). Defaults to process.cwd().",
|
|
74
|
+
},
|
|
75
|
+
limit: {
|
|
76
|
+
type: "number",
|
|
77
|
+
description: "Max sessions to query (default: 10).",
|
|
78
|
+
},
|
|
79
|
+
offset: {
|
|
80
|
+
type: "number",
|
|
81
|
+
description: "Skip this many sessions for pagination (default: 0).",
|
|
82
|
+
},
|
|
83
|
+
includeSubagents: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
description: "Include subagent transcripts in search (default: false).",
|
|
86
|
+
},
|
|
87
|
+
copies: {
|
|
88
|
+
type: "number",
|
|
89
|
+
description: "For 'self' mode: number of parallel queries for sampling diversity (default: 3).",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ["question", "mode"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "duncan_list_sessions",
|
|
97
|
+
description: "List available Claude Code sessions. Use to discover sessions before querying.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: "object" as const,
|
|
100
|
+
properties: {
|
|
101
|
+
mode: {
|
|
102
|
+
type: "string",
|
|
103
|
+
enum: ["project", "global"],
|
|
104
|
+
description: "'project': sessions from a project dir. 'global': all sessions.",
|
|
105
|
+
},
|
|
106
|
+
projectDir: {
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "For 'project' mode: project directory path.",
|
|
109
|
+
},
|
|
110
|
+
cwd: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description: "For 'project' mode: working directory to resolve project dir.",
|
|
113
|
+
},
|
|
114
|
+
limit: {
|
|
115
|
+
type: "number",
|
|
116
|
+
description: "Max sessions to list (default: 20).",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
required: ["mode"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Tool handlers
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
130
|
+
const { name, arguments: args, _meta } = request.params;
|
|
131
|
+
|
|
132
|
+
// CC passes tool_use ID in _meta — used for self-exclusion
|
|
133
|
+
const toolUseId = (_meta as Record<string, unknown> | undefined)?.["claudecode/toolUseId"] as string | undefined;
|
|
134
|
+
|
|
135
|
+
switch (name) {
|
|
136
|
+
case "duncan_query":
|
|
137
|
+
return handleDuncanQuery(args as any, toolUseId);
|
|
138
|
+
case "duncan_list_sessions":
|
|
139
|
+
return handleListSessions(args as any);
|
|
140
|
+
default:
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
143
|
+
isError: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
async function handleDuncanQuery(args: {
|
|
149
|
+
question: string;
|
|
150
|
+
mode: string;
|
|
151
|
+
projectDir?: string;
|
|
152
|
+
sessionId?: string;
|
|
153
|
+
cwd?: string;
|
|
154
|
+
limit?: number;
|
|
155
|
+
offset?: number;
|
|
156
|
+
includeSubagents?: boolean;
|
|
157
|
+
copies?: number;
|
|
158
|
+
}, toolUseId?: string) {
|
|
159
|
+
try {
|
|
160
|
+
// Self mode: query own active window N times for sampling diversity
|
|
161
|
+
if (args.mode === "self") {
|
|
162
|
+
if (!toolUseId) {
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: "text", text: "Self mode requires toolUseId from _meta (only available when called from CC)." }],
|
|
165
|
+
isError: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const result = await querySelf(args.question, {
|
|
169
|
+
toolUseId,
|
|
170
|
+
copies: args.copies ?? 3,
|
|
171
|
+
apiKey: undefined,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (result.results.length === 0) {
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: "Could not find calling session for self-query." }],
|
|
177
|
+
isError: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Format: show all N answers
|
|
182
|
+
const answers = result.results.map((r, i) => {
|
|
183
|
+
return `### Sample ${i + 1}\n${r.result.answer}\n*— ${r.model}*`;
|
|
184
|
+
}).join("\n\n---\n\n");
|
|
185
|
+
|
|
186
|
+
const contextCount = result.results.filter(r => r.result.hasContext).length;
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text", text: `**${args.question}** (${result.results.length} samples)\n\n${answers}\n\n*${contextCount}/${result.results.length} had relevant context. queryId: ${result.queryId}*` }],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Ancestors mode: query prior compaction windows of the calling session
|
|
193
|
+
if (args.mode === "ancestors") {
|
|
194
|
+
if (!toolUseId) {
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: "text", text: "Ancestors mode requires toolUseId from _meta (only available when called from CC)." }],
|
|
197
|
+
isError: true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const result = await queryAncestors(args.question, {
|
|
201
|
+
toolUseId,
|
|
202
|
+
limit: args.limit ?? 50,
|
|
203
|
+
offset: args.offset ?? 0,
|
|
204
|
+
apiKey: undefined,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (result.results.length === 0) {
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text: "No ancestor windows found. This session has no compaction boundaries." }],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const withContext = result.results.filter(r => r.result.hasContext);
|
|
214
|
+
const relevant = withContext.length > 0 ? withContext : result.results;
|
|
215
|
+
|
|
216
|
+
const answers = relevant.map((r) => {
|
|
217
|
+
const label = relevant.length === 1 ? "" : `### Window ${r.windowIndex}\n`;
|
|
218
|
+
return `${label}${r.result.answer}\n*— ${r.model}*`;
|
|
219
|
+
}).join("\n\n---\n\n");
|
|
220
|
+
|
|
221
|
+
const parts = [`**${args.question}**\n\n${answers}`];
|
|
222
|
+
|
|
223
|
+
if (result.hasMore) {
|
|
224
|
+
const nextOffset = (args.offset ?? 0) + (args.limit ?? 50);
|
|
225
|
+
const remaining = result.totalWindows - nextOffset;
|
|
226
|
+
parts.push(`\n\n---\n*Queried ${result.results.length} of ${result.totalWindows} windows. ${remaining} more available — call again with offset: ${nextOffset}.*`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const contextCount = withContext.length;
|
|
230
|
+
parts.push(`\n\n*${contextCount}/${result.results.length} windows had relevant context. queryId: ${result.queryId}*`);
|
|
231
|
+
|
|
232
|
+
return { content: [{ type: "text", text: parts.join("") }] };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const result = await queryBatch(
|
|
236
|
+
args.question,
|
|
237
|
+
{
|
|
238
|
+
mode: args.mode as any,
|
|
239
|
+
projectDir: args.projectDir,
|
|
240
|
+
sessionId: args.sessionId,
|
|
241
|
+
cwd: args.cwd,
|
|
242
|
+
limit: args.limit ?? 10,
|
|
243
|
+
offset: args.offset ?? 0,
|
|
244
|
+
includeSubagents: args.includeSubagents ?? false,
|
|
245
|
+
toolUseId, // for self-exclusion
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
apiKey: undefined,
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (result.results.length === 0) {
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: "text", text: "No sessions found matching the routing criteria." }],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const errors = result.results.filter((r) => r.result.answer.startsWith("Error: "));
|
|
259
|
+
const nonErrors = result.results.filter((r) => !r.result.answer.startsWith("Error: "));
|
|
260
|
+
const withContext = nonErrors.filter((r) => r.result.hasContext);
|
|
261
|
+
const relevant = withContext.length > 0 ? withContext : nonErrors.length > 0 ? nonErrors : result.results;
|
|
262
|
+
|
|
263
|
+
const answers = relevant
|
|
264
|
+
.map((r) => {
|
|
265
|
+
const label = relevant.length === 1
|
|
266
|
+
? ""
|
|
267
|
+
: `### ${r.sessionId.slice(0, 12)} (window ${r.windowIndex})\n`;
|
|
268
|
+
const modelLine = `\n*— ${r.model}*`;
|
|
269
|
+
return `${label}${r.result.answer}${modelLine}`;
|
|
270
|
+
})
|
|
271
|
+
.join("\n\n---\n\n");
|
|
272
|
+
|
|
273
|
+
const parts = [`**${args.question}**\n\n${answers}`];
|
|
274
|
+
|
|
275
|
+
if (errors.length > 0) {
|
|
276
|
+
const errorLines = errors.map((r) => `- ${r.sessionId.slice(0, 12)} (window ${r.windowIndex}): ${r.result.answer}`).join("\n");
|
|
277
|
+
parts.push(`\n\n---\n**${errors.length} error(s):**\n${errorLines}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (result.hasMore) {
|
|
281
|
+
const nextOffset = (args.offset ?? 0) + (args.limit ?? 10);
|
|
282
|
+
const remaining = result.totalWindows - nextOffset;
|
|
283
|
+
parts.push(
|
|
284
|
+
`\n\n---\n*Queried ${result.results.length} of ${result.totalWindows} windows. ${remaining} more available — call again with offset: ${nextOffset}.*`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const contextCount = withContext.length;
|
|
289
|
+
const totalCount = result.results.length;
|
|
290
|
+
parts.push(`\n\n*${contextCount}/${totalCount} sessions had relevant context. queryId: ${result.queryId}*`);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
content: [{ type: "text", text: parts.join("") }],
|
|
294
|
+
};
|
|
295
|
+
} catch (err: any) {
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: "text", text: `Duncan query error: ${err.message}` }],
|
|
298
|
+
isError: true,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function handleListSessions(args: {
|
|
304
|
+
mode: string;
|
|
305
|
+
projectDir?: string;
|
|
306
|
+
cwd?: string;
|
|
307
|
+
limit?: number;
|
|
308
|
+
}) {
|
|
309
|
+
try {
|
|
310
|
+
const resolved = resolveSessionFiles({
|
|
311
|
+
mode: args.mode as any,
|
|
312
|
+
projectDir: args.projectDir,
|
|
313
|
+
cwd: args.cwd,
|
|
314
|
+
limit: args.limit ?? 20,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (resolved.sessions.length === 0) {
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: "text", text: "No sessions found." }],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const lines = resolved.sessions.map((s) => {
|
|
324
|
+
const date = s.mtime.toISOString().slice(0, 16);
|
|
325
|
+
const size = s.size > 1024 * 1024
|
|
326
|
+
? `${(s.size / 1024 / 1024).toFixed(1)}MB`
|
|
327
|
+
: `${(s.size / 1024).toFixed(0)}KB`;
|
|
328
|
+
return `${s.sessionId.slice(0, 12)} ${date} ${size} ${s.projectDir.split("/").slice(-1)[0]}`;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const header = `${resolved.totalCount} sessions${resolved.hasMore ? ` (showing ${resolved.sessions.length})` : ""}:`;
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: "text", text: `${header}\n\n${lines.join("\n")}` }],
|
|
334
|
+
};
|
|
335
|
+
} catch (err: any) {
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: `Error listing sessions: ${err.message}` }],
|
|
338
|
+
isError: true,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ============================================================================
|
|
344
|
+
// Start server
|
|
345
|
+
// ============================================================================
|
|
346
|
+
|
|
347
|
+
async function main() {
|
|
348
|
+
const transport = new StdioServerTransport();
|
|
349
|
+
await server.connect(transport);
|
|
350
|
+
console.error("duncan-cc MCP server running on stdio");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
main().catch((err) => {
|
|
354
|
+
console.error("Fatal:", err);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
});
|