@gswangg/duncan-cc 0.1.0 → 0.2.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 +184 -49
- package/SPEC.md +43 -5
- package/package.json +1 -1
- package/src/discovery.ts +176 -3
- package/src/mcp-server.ts +151 -9
- package/src/query-logger.ts +117 -0
- package/src/query.ts +165 -8
package/README.md
CHANGED
|
@@ -30,71 +30,206 @@ claude mcp add duncan -- npx tsx /path/to/duncan-cc/src/mcp-server.ts
|
|
|
30
30
|
|
|
31
31
|
## Authentication
|
|
32
32
|
|
|
33
|
-
Duncan resolves auth automatically:
|
|
33
|
+
Duncan resolves auth automatically in this order:
|
|
34
34
|
|
|
35
|
-
1. Explicit apiKey/token parameter
|
|
36
|
-
2. CC OAuth credentials
|
|
37
|
-
3. `ANTHROPIC_API_KEY` environment variable
|
|
35
|
+
1. **Explicit** apiKey/token parameter (if passed)
|
|
36
|
+
2. **CC OAuth** credentials from `~/.claude/.credentials.json` (primary for CC users)
|
|
37
|
+
3. **API key** from `ANTHROPIC_API_KEY` environment variable
|
|
38
|
+
|
|
39
|
+
Most CC users authenticate via OAuth — duncan picks this up automatically with no configuration.
|
|
38
40
|
|
|
39
41
|
## Tools
|
|
40
42
|
|
|
43
|
+
Duncan exposes three MCP tools:
|
|
44
|
+
|
|
41
45
|
### `duncan_query`
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
|
46
|
-
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
47
|
+
Query dormant sessions to recall information from previous conversations. Loads session context and asks the target session's model whether it has relevant information.
|
|
48
|
+
|
|
49
|
+
| Parameter | Type | Required | Default | Description |
|
|
50
|
+
|-----------|------|----------|---------|-------------|
|
|
51
|
+
| `question` | string | ✓ | — | The question to ask. Be specific and self-contained. |
|
|
52
|
+
| `mode` | string | ✓ | — | Routing mode (see [Routing Modes](#routing-modes) below) |
|
|
53
|
+
| `projectDir` | string | | cwd-based | Explicit project directory path (for `project` and `branch` modes) |
|
|
54
|
+
| `sessionId` | string | | — | Session file path or ID (for `session` mode) |
|
|
55
|
+
| `cwd` | string | | process.cwd() | Working directory for context resolution |
|
|
56
|
+
| `limit` | number | | 10 | Max sessions/windows to query |
|
|
57
|
+
| `offset` | number | | 0 | Skip this many sessions for pagination |
|
|
58
|
+
| `copies` | number | | 3 | For `self` mode: number of parallel samples |
|
|
59
|
+
| `includeSubagents` | boolean | | false | Include subagent transcripts in search |
|
|
60
|
+
| `batchSize` | number | | 5 | Max concurrent API calls per batch |
|
|
61
|
+
| `gitBranch` | string | | auto-detected | For `branch` mode: explicit branch name |
|
|
62
|
+
|
|
63
|
+
### `duncan_projects`
|
|
64
|
+
|
|
65
|
+
List all CC projects with metadata. Use to discover what projects exist before targeting a specific project with `duncan_query`.
|
|
66
|
+
|
|
67
|
+
| Parameter | Type | Required | Default | Description |
|
|
68
|
+
|-----------|------|----------|---------|-------------|
|
|
69
|
+
| `limit` | number | | 50 | Max projects to list |
|
|
70
|
+
| `offset` | number | | 0 | Pagination offset |
|
|
71
|
+
|
|
72
|
+
Returns for each project:
|
|
73
|
+
- Original working directory path (reconstructed from CC's hashed directory name)
|
|
74
|
+
- Session count
|
|
75
|
+
- Most recent activity timestamp
|
|
76
|
+
- Git branches seen across recent sessions
|
|
54
77
|
|
|
55
78
|
### `duncan_list_sessions`
|
|
56
79
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
|
60
|
-
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
80
|
+
List available sessions for a project or globally.
|
|
81
|
+
|
|
82
|
+
| Parameter | Type | Required | Default | Description |
|
|
83
|
+
|-----------|------|----------|---------|-------------|
|
|
84
|
+
| `mode` | string | ✓ | — | `project` or `global` |
|
|
85
|
+
| `projectDir` | string | | cwd-based | For `project` mode |
|
|
86
|
+
| `cwd` | string | | process.cwd() | Working directory |
|
|
87
|
+
| `limit` | number | | 20 | Max sessions to list |
|
|
88
|
+
|
|
89
|
+
## Routing Modes
|
|
90
|
+
|
|
91
|
+
### `project`
|
|
92
|
+
|
|
93
|
+
Query all sessions from the same project directory. The calling session is automatically excluded via self-detection. Sessions are ordered by modification time (newest first).
|
|
94
|
+
|
|
95
|
+
Use when you want to search through recent work in the current project.
|
|
96
|
+
|
|
97
|
+
### `global`
|
|
98
|
+
|
|
99
|
+
Query all sessions across all CC projects (newest first). Self-excluded. The broadest search — useful when you don't know which project holds the information.
|
|
100
|
+
|
|
101
|
+
### `session`
|
|
102
|
+
|
|
103
|
+
Query a specific session by ID or file path. No self-exclusion (you might intentionally target a known session). Pass the session ID via the `sessionId` parameter — either a UUID or full file path.
|
|
104
|
+
|
|
105
|
+
### `self`
|
|
63
106
|
|
|
64
|
-
|
|
107
|
+
Query your own active window multiple times for **sampling diversity**. Instead of searching other sessions, this sends the same question to N independent copies of the current conversation context. Useful for exploring different perspectives on complex problems.
|
|
65
108
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
| `global` | All sessions across all projects (self-excluded) |
|
|
70
|
-
| `session` | Specific session by ID or path |
|
|
71
|
-
| `self` | Own active window, queried N times for sampling diversity |
|
|
72
|
-
| `ancestors` | Own prior compaction windows (excluding active) |
|
|
109
|
+
Uses a **two-wave cache strategy**:
|
|
110
|
+
1. **Wave 1**: 1 query primes the prompt cache (pays full input token cost)
|
|
111
|
+
2. **Wave 2**: remaining N-1 queries in parallel (hit cached prefix, ~90% cheaper)
|
|
73
112
|
|
|
74
|
-
|
|
113
|
+
The `copies` parameter controls how many samples to take (default: 3).
|
|
75
114
|
|
|
76
|
-
|
|
115
|
+
### `ancestors`
|
|
77
116
|
|
|
78
|
-
|
|
79
|
-
2. Relink preserved segments (compaction tree surgery)
|
|
80
|
-
3. Walk parentUuid chain from leaf to root
|
|
81
|
-
4. Post-process (merge split assistants, fix orphan tool results)
|
|
82
|
-
5. Normalize messages (filter, convert types, merge, 8 post-transforms)
|
|
83
|
-
6. Apply content replacements (persisted outputs from disk)
|
|
84
|
-
7. Microcompact (truncate old tool results)
|
|
85
|
-
8. Inject userContext (CLAUDE.md + date)
|
|
86
|
-
9. Build system prompt (full parity with CC's static sections + dynamic context from project dir)
|
|
87
|
-
10. Convert to API format
|
|
88
|
-
11. Add prompt caching breakpoints
|
|
89
|
-
12. Query with `duncan_response` structured output tool
|
|
117
|
+
Query the calling session's **prior compaction windows**. When CC compacts a session, the old context is summarized but the original messages remain in the JSONL file. Ancestors mode lets you query that pre-compaction context.
|
|
90
118
|
|
|
91
|
-
|
|
119
|
+
Returns nothing if the session has no compaction boundaries. In CC (which has no dfork lineage), "ancestors" always means the session's own compacted-away history.
|
|
120
|
+
|
|
121
|
+
### `subagents`
|
|
122
|
+
|
|
123
|
+
Query **subagent transcripts** of the calling session. When CC spawns subagent tasks (e.g., parallel tool calls), their transcripts are stored alongside the main session. This mode searches through those transcripts.
|
|
124
|
+
|
|
125
|
+
Subagent files are sorted by modification time for deterministic pagination.
|
|
126
|
+
|
|
127
|
+
### `branch`
|
|
128
|
+
|
|
129
|
+
Query all sessions in the same project that share the **same git branch** as the calling session. CC records `gitBranch` in JSONL entries, so this grouping works without explicit lineage metadata.
|
|
130
|
+
|
|
131
|
+
Useful when work on a feature spans multiple sessions — `branch` naturally groups them by the git branch they were on.
|
|
132
|
+
|
|
133
|
+
If the calling session's branch can't be auto-detected, pass it explicitly via the `gitBranch` parameter.
|
|
134
|
+
|
|
135
|
+
## How It Works
|
|
136
|
+
|
|
137
|
+
### Pipeline
|
|
138
|
+
|
|
139
|
+
Duncan replicates CC's full session-to-API message pipeline, then substitutes its own query as the final message:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
Session file (.jsonl)
|
|
143
|
+
│
|
|
144
|
+
▼ Parse JSONL — separate transcript from metadata
|
|
145
|
+
▼ Relink preserved segments (compaction tree surgery)
|
|
146
|
+
▼ Walk parentUuid chain from leaf to root
|
|
147
|
+
▼ Post-process (merge split assistants, fix orphan tool results)
|
|
148
|
+
▼ Slice from last compaction boundary
|
|
149
|
+
▼ Normalize messages (filter, convert types, merge, 8 post-transforms)
|
|
150
|
+
▼ Content replacements (resolve persisted tool outputs from disk)
|
|
151
|
+
▼ Microcompact (truncate old tool results)
|
|
152
|
+
▼ Inject userContext (CLAUDE.md + date)
|
|
153
|
+
▼ Build system prompt (full CC parity)
|
|
154
|
+
▼ Convert to API format
|
|
155
|
+
▼ Add prompt cache breakpoints
|
|
156
|
+
▼ Append duncan question as final user message
|
|
157
|
+
▼ Query with duncan_response structured output tool
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Self-Exclusion
|
|
161
|
+
|
|
162
|
+
When CC calls an MCP tool, it writes the assistant message (containing the `tool_use` block) to the session JSONL *before* invoking the tool. CC passes the tool_use ID in the MCP request's `_meta` as `claudecode/toolUseId`.
|
|
163
|
+
|
|
164
|
+
Duncan scans the last 32KB of candidate session files for this ID to deterministically identify the calling session — no configuration needed, safe for concurrent sessions.
|
|
165
|
+
|
|
166
|
+
### System Prompt Reconstruction
|
|
167
|
+
|
|
168
|
+
Duncan rebuilds the system prompt with full parity to CC's own prompt:
|
|
169
|
+
|
|
170
|
+
**Static sections** (embedded verbatim from CC):
|
|
171
|
+
- Identity/intro, system rules, coding instructions
|
|
172
|
+
- Careful actions guidelines, tool usage, tone/style, output efficiency
|
|
173
|
+
|
|
174
|
+
**Dynamic sections** (reconstructed from session context):
|
|
175
|
+
- **Environment**: from session JSONL metadata (cwd, model) + local filesystem
|
|
176
|
+
- **CLAUDE.md**: loaded from session's original cwd hierarchy (if paths exist)
|
|
177
|
+
- **Memory**: from CC project directory (`~/.claude/projects/<hash>/memory/MEMORY.md`)
|
|
178
|
+
- **Tool-conditional instructions**: only included when the corresponding tools appear in the session (e.g., "use Read instead of cat" only when Read tool was used)
|
|
179
|
+
- **Language**: configurable
|
|
180
|
+
|
|
181
|
+
Tool schemas are NOT included — duncan sends only its own `duncan_response` tool. The session's original tools aren't callable during a duncan query.
|
|
182
|
+
|
|
183
|
+
### Prompt Caching
|
|
184
|
+
|
|
185
|
+
Cache breakpoints placed on:
|
|
186
|
+
- **System prompt**: each text block gets `cache_control: { type: "ephemeral" }`
|
|
187
|
+
- **Messages**: breakpoint on last content block of penultimate message
|
|
188
|
+
|
|
189
|
+
This caches the session context (stable across queries) while letting the duncan question (last message) vary without invalidating cache. For multi-session batch queries, each session's context is cached independently.
|
|
190
|
+
|
|
191
|
+
### Query Logging
|
|
192
|
+
|
|
193
|
+
Every query is logged to `~/.claude/duncan.jsonl` as append-only JSONL. Each record captures:
|
|
194
|
+
|
|
195
|
+
- Batch ID, question, answer, hasContext flag
|
|
196
|
+
- Target session, window index, source session
|
|
197
|
+
- Routing strategy, model used
|
|
198
|
+
- Token counts (input, output, cache creation, cache read)
|
|
199
|
+
- Latency in milliseconds, timestamp
|
|
200
|
+
|
|
201
|
+
Process with standard tools: `cat ~/.claude/duncan.jsonl | jq .`
|
|
202
|
+
|
|
203
|
+
### Progress Notifications
|
|
204
|
+
|
|
205
|
+
During batch queries, duncan sends MCP progress notifications via the standard `progressToken` mechanism. As each session window completes, a notification is sent so the calling session can display real-time status (e.g., "Querying 3/7 sessions...").
|
|
206
|
+
|
|
207
|
+
## Session Storage Layout
|
|
208
|
+
|
|
209
|
+
Duncan reads CC's native session storage:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
~/.claude/
|
|
213
|
+
├── .credentials.json # OAuth credentials
|
|
214
|
+
├── duncan.jsonl # Query log (written by duncan)
|
|
215
|
+
└── projects/
|
|
216
|
+
└── <hashed-cwd>/ # e.g., -Users-foo-bar
|
|
217
|
+
├── <session-id>.jsonl # Session transcript
|
|
218
|
+
├── <session-id>/
|
|
219
|
+
│ ├── subagents/ # Subagent transcripts
|
|
220
|
+
│ │ └── <subdir>/
|
|
221
|
+
│ │ └── agent-<id>.jsonl
|
|
222
|
+
│ └── tool-results/ # Persisted tool outputs
|
|
223
|
+
│ └── <id>.txt
|
|
224
|
+
└── memory/
|
|
225
|
+
└── MEMORY.md # Project memory
|
|
226
|
+
```
|
|
92
227
|
|
|
93
|
-
## Known
|
|
228
|
+
## Known Gaps
|
|
94
229
|
|
|
95
|
-
- **MCP server instructions** —
|
|
96
|
-
- **Tool schemas** — only `duncan_response` is sent; session's original tools aren't callable
|
|
97
|
-
- **Compaction test coverage** — synthetic
|
|
230
|
+
- **MCP server instructions** — CC injects MCP server `instructions` from the initialize handshake into the system prompt. These aren't persisted to disk, so duncan can't reconstruct them for dormant sessions. Equivalent to resuming a CC session with tools disconnected.
|
|
231
|
+
- **Tool schemas** — only `duncan_response` is sent; the session's original tools aren't callable during a duncan query.
|
|
232
|
+
- **Compaction test coverage** — compaction logic is tested with synthetic fixtures only. No real compacted sessions in the current test corpus (CC's 30-day `cleanupPeriodDays` default purged them before capture).
|
|
98
233
|
|
|
99
234
|
## Tests
|
|
100
235
|
|
|
@@ -106,5 +241,5 @@ Corpus-dependent tests skip gracefully when `testdata/` is absent.
|
|
|
106
241
|
|
|
107
242
|
## Related
|
|
108
243
|
|
|
109
|
-
- [duncan-pi](https://github.com/gswangg/duncan-pi) —
|
|
244
|
+
- [duncan-pi](https://github.com/gswangg/duncan-pi) — Duncan for the [pi](https://github.com/badlogic/pi-mono) coding agent
|
|
110
245
|
- [The Duncan Idaho Approach to Agent Memory](https://gswangg.net/posts/duncan-idaho-agent-memory) — design writeup
|
package/SPEC.md
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
Duncan-cc replicates CC's full message pipeline to hydrate dormant CC sessions,
|
|
6
6
|
then queries them with questions via the Anthropic API. Exposed as an MCP server
|
|
7
|
-
(stdio transport) with
|
|
7
|
+
(stdio transport) with three tools: `duncan_query`, `duncan_projects`, and
|
|
8
|
+
`duncan_list_sessions`.
|
|
8
9
|
|
|
9
10
|
## Pipeline: Disk → API
|
|
10
11
|
|
|
@@ -63,7 +64,7 @@ Build system prompt (full parity with CC):
|
|
|
63
64
|
├── System rules
|
|
64
65
|
├── Coding instructions
|
|
65
66
|
├── Careful actions guidelines
|
|
66
|
-
├── Tool usage
|
|
67
|
+
├── Tool usage (+ per-tool instructions based on session's tools)
|
|
67
68
|
├── Tone and style
|
|
68
69
|
├── Output efficiency
|
|
69
70
|
├── Environment info (cwd, platform, model)
|
|
@@ -95,6 +96,8 @@ messages.create() with duncan_response tool
|
|
|
95
96
|
| `session` | Specific session by ID/path | — |
|
|
96
97
|
| `self` | Own active window, N copies (sampling diversity) | — (queries self intentionally) |
|
|
97
98
|
| `ancestors` | Own prior compaction windows (excluding active) | Active window excluded |
|
|
99
|
+
| `subagents` | Subagent transcripts of the active session | — |
|
|
100
|
+
| `branch` | Sessions sharing the same git branch in the project | ✅ via toolUseId |
|
|
98
101
|
|
|
99
102
|
### Self-exclusion
|
|
100
103
|
|
|
@@ -116,6 +119,13 @@ Queries compaction windows of the calling session excluding the active window.
|
|
|
116
119
|
Returns nothing if the session has no compaction boundaries. In CC (no dfork
|
|
117
120
|
lineage), "ancestors" = the compacted-away context from the current session.
|
|
118
121
|
|
|
122
|
+
### Branch mode
|
|
123
|
+
|
|
124
|
+
Collects all sessions from the same project directory that share a git branch
|
|
125
|
+
with the calling session, ordered by mtime. CC sessions store `gitBranch` in
|
|
126
|
+
their JSONL entries. If the calling session's branch can't be auto-detected,
|
|
127
|
+
it can be passed explicitly via the `gitBranch` parameter.
|
|
128
|
+
|
|
119
129
|
## Authentication
|
|
120
130
|
|
|
121
131
|
Resolution order:
|
|
@@ -151,6 +161,24 @@ This matches CC's own resume behavior: rebuild system prompt from current state.
|
|
|
151
161
|
Note: tool schemas are NOT included — duncan sends only its own `duncan_response`
|
|
152
162
|
tool. The session's original tools are not callable during a duncan query.
|
|
153
163
|
|
|
164
|
+
## Query Logging
|
|
165
|
+
|
|
166
|
+
Every query is logged to `~/.claude/duncan.jsonl` as append-only JSONL. Each record:
|
|
167
|
+
- `batchId`, `question`, `answer`, `hasContext`
|
|
168
|
+
- `targetSession`, `windowIndex`, `sourceSession`
|
|
169
|
+
- `strategy`, `model`
|
|
170
|
+
- `inputTokens`, `outputTokens`, `cacheCreationInputTokens`, `cacheReadInputTokens`
|
|
171
|
+
- `latencyMs`, `timestamp`
|
|
172
|
+
|
|
173
|
+
Logging is best-effort (failures don't break queries).
|
|
174
|
+
|
|
175
|
+
## MCP Progress Notifications
|
|
176
|
+
|
|
177
|
+
During batch queries, progress notifications are sent via MCP's standard
|
|
178
|
+
`progressToken` mechanism. Each completed session window sends a notification
|
|
179
|
+
with `{ progress: completed, total: totalWindows }`. Requires the caller to
|
|
180
|
+
include `_meta.progressToken` in the request.
|
|
181
|
+
|
|
154
182
|
## Known Gaps
|
|
155
183
|
|
|
156
184
|
### MCP Server Instructions
|
|
@@ -172,22 +200,32 @@ Compaction logic is tested with synthetic fixtures only.
|
|
|
172
200
|
- **Subagent transcripts**: `<project-dir>/<session-id>/subagents/<subdir>/agent-<id>.jsonl`
|
|
173
201
|
- **Tool results**: `<project-dir>/<session-id>/tool-results/<id>.txt`
|
|
174
202
|
- **Memory**: `<project-dir>/memory/MEMORY.md`
|
|
203
|
+
- **Query log**: `~/.claude/duncan.jsonl`
|
|
175
204
|
|
|
176
205
|
## MCP Server
|
|
177
206
|
|
|
178
|
-
|
|
207
|
+
Three tools exposed via stdio transport:
|
|
179
208
|
|
|
180
209
|
### duncan_query
|
|
181
210
|
Query dormant sessions. Parameters:
|
|
182
211
|
- `question` (required): the question to ask
|
|
183
|
-
- `mode` (required): `project`, `global`, `session`, `self`, `ancestors`
|
|
184
|
-
- `projectDir`: for project mode
|
|
212
|
+
- `mode` (required): `project`, `global`, `session`, `self`, `ancestors`, `subagents`, `branch`
|
|
213
|
+
- `projectDir`: for project/branch mode
|
|
185
214
|
- `sessionId`: for session mode
|
|
186
215
|
- `cwd`: working directory context
|
|
187
216
|
- `limit`: max sessions/windows (default: 10)
|
|
188
217
|
- `offset`: pagination offset
|
|
189
218
|
- `copies`: for self mode, number of samples (default: 3)
|
|
190
219
|
- `includeSubagents`: include subagent transcripts (default: false)
|
|
220
|
+
- `batchSize`: max concurrent API calls per batch (default: 5)
|
|
221
|
+
- `gitBranch`: for branch mode, explicit branch name
|
|
222
|
+
|
|
223
|
+
### duncan_projects
|
|
224
|
+
List all CC projects with metadata. Parameters:
|
|
225
|
+
- `limit`: max projects (default: 50)
|
|
226
|
+
- `offset`: pagination offset
|
|
227
|
+
|
|
228
|
+
Returns: project cwd, session count, last activity, git branches.
|
|
191
229
|
|
|
192
230
|
### duncan_list_sessions
|
|
193
231
|
List available sessions. Parameters:
|
package/package.json
CHANGED
package/src/discovery.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Also discovers subagent transcripts.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readdirSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
12
|
+
import { readdirSync, statSync, existsSync, openSync, readSync, closeSync, readFileSync } from "node:fs";
|
|
13
13
|
import { join, basename, dirname } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
|
|
@@ -148,14 +148,144 @@ export function listSubagentFiles(sessionFile: string): SessionFileInfo[] {
|
|
|
148
148
|
scanDir(sessionDir);
|
|
149
149
|
} catch {}
|
|
150
150
|
|
|
151
|
+
// Sort by mtime, newest first (deterministic ordering)
|
|
152
|
+
files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
151
153
|
return files;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// ============================================================================
|
|
157
|
+
// Git branch extraction
|
|
158
|
+
// ============================================================================
|
|
159
|
+
|
|
160
|
+
/** Size of head chunk to scan for gitBranch (bytes). */
|
|
161
|
+
const HEAD_SCAN_BYTES = 8_192;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract git branch(es) from a session file by scanning the head.
|
|
165
|
+
* Returns unique branches found. Scans only the first HEAD_SCAN_BYTES
|
|
166
|
+
* since gitBranch appears in the earliest entries.
|
|
167
|
+
*/
|
|
168
|
+
export function extractGitBranches(filePath: string): string[] {
|
|
169
|
+
try {
|
|
170
|
+
const stat = statSync(filePath);
|
|
171
|
+
const readSize = Math.min(stat.size, HEAD_SCAN_BYTES);
|
|
172
|
+
const buf = Buffer.alloc(readSize);
|
|
173
|
+
const fd = openSync(filePath, "r");
|
|
174
|
+
readSync(fd, buf, 0, readSize, 0);
|
|
175
|
+
closeSync(fd);
|
|
176
|
+
|
|
177
|
+
const text = buf.toString("utf-8");
|
|
178
|
+
const branches = new Set<string>();
|
|
179
|
+
const re = /"gitBranch":"([^"]+)"/g;
|
|
180
|
+
let match;
|
|
181
|
+
while ((match = re.exec(text)) !== null) {
|
|
182
|
+
branches.add(match[1]);
|
|
183
|
+
}
|
|
184
|
+
return [...branches];
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Extract the primary git branch for a session (first found).
|
|
192
|
+
*/
|
|
193
|
+
export function extractGitBranch(filePath: string): string | null {
|
|
194
|
+
const branches = extractGitBranches(filePath);
|
|
195
|
+
return branches.length > 0 ? branches[0] : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// Project listing
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
export interface ProjectInfo {
|
|
203
|
+
/** The hashed project directory name (e.g., "-Users-foo-bar") */
|
|
204
|
+
dirName: string;
|
|
205
|
+
/** Full path to the project directory under ~/.claude/projects/ */
|
|
206
|
+
projectDir: string;
|
|
207
|
+
/** Reconstructed original working directory path */
|
|
208
|
+
cwd: string;
|
|
209
|
+
/** Number of sessions in this project */
|
|
210
|
+
sessionCount: number;
|
|
211
|
+
/** Most recent session activity */
|
|
212
|
+
lastActivity: Date;
|
|
213
|
+
/** Git branches seen across sessions */
|
|
214
|
+
branches: string[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Reconstruct the original cwd from a CC project directory name.
|
|
219
|
+
* CC hashes cwd by replacing `/` with `-`, so `/Users/foo/bar` → `-Users-foo-bar`.
|
|
220
|
+
*/
|
|
221
|
+
function cwdFromDirName(dirName: string): string {
|
|
222
|
+
// Replace leading - and all subsequent - with /
|
|
223
|
+
// This is lossy if the original path contained hyphens, but it's the best we can do
|
|
224
|
+
return dirName.replace(/-/g, "/");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* List all CC projects with metadata.
|
|
229
|
+
*/
|
|
230
|
+
export function listProjects(opts?: { limit?: number; offset?: number }): {
|
|
231
|
+
projects: ProjectInfo[];
|
|
232
|
+
totalCount: number;
|
|
233
|
+
hasMore: boolean;
|
|
234
|
+
} {
|
|
235
|
+
const projectsDir = getProjectsDir();
|
|
236
|
+
if (!existsSync(projectsDir)) {
|
|
237
|
+
return { projects: [], totalCount: 0, hasMore: false };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const allProjects: ProjectInfo[] = [];
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const dirs = readdirSync(projectsDir, { withFileTypes: true });
|
|
244
|
+
for (const d of dirs) {
|
|
245
|
+
if (!d.isDirectory()) continue;
|
|
246
|
+
const fullPath = join(projectsDir, d.name);
|
|
247
|
+
const sessions = listSessionFiles(fullPath);
|
|
248
|
+
if (sessions.length === 0) continue;
|
|
249
|
+
|
|
250
|
+
// Collect branches from recent sessions (scan up to 5 for efficiency)
|
|
251
|
+
const branchSet = new Set<string>();
|
|
252
|
+
for (const s of sessions.slice(0, 5)) {
|
|
253
|
+
for (const b of extractGitBranches(s.path)) {
|
|
254
|
+
branchSet.add(b);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
allProjects.push({
|
|
259
|
+
dirName: d.name,
|
|
260
|
+
projectDir: fullPath,
|
|
261
|
+
cwd: cwdFromDirName(d.name),
|
|
262
|
+
sessionCount: sessions.length,
|
|
263
|
+
lastActivity: sessions[0].mtime, // sessions already sorted newest-first
|
|
264
|
+
branches: [...branchSet],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
return { projects: [], totalCount: 0, hasMore: false };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Sort by last activity, newest first
|
|
272
|
+
allProjects.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
|
|
273
|
+
|
|
274
|
+
const limit = opts?.limit ?? 50;
|
|
275
|
+
const offset = opts?.offset ?? 0;
|
|
276
|
+
const page = allProjects.slice(offset, offset + limit);
|
|
277
|
+
return {
|
|
278
|
+
projects: page,
|
|
279
|
+
totalCount: allProjects.length,
|
|
280
|
+
hasMore: offset + limit < allProjects.length,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
154
284
|
// ============================================================================
|
|
155
285
|
// Routing
|
|
156
286
|
// ============================================================================
|
|
157
287
|
|
|
158
|
-
export type RoutingMode = "project" | "global" | "session" | "self" | "ancestors";
|
|
288
|
+
export type RoutingMode = "project" | "global" | "session" | "self" | "ancestors" | "subagents" | "branch";
|
|
159
289
|
|
|
160
290
|
export interface RoutingParams {
|
|
161
291
|
mode: RoutingMode;
|
|
@@ -171,6 +301,10 @@ export interface RoutingParams {
|
|
|
171
301
|
limit?: number;
|
|
172
302
|
/** Offset for pagination */
|
|
173
303
|
offset?: number;
|
|
304
|
+
/** For "branch" mode: explicit git branch (auto-detected from calling session if omitted) */
|
|
305
|
+
gitBranch?: string;
|
|
306
|
+
/** Tool use ID from MCP _meta (used for self-exclusion and branch detection) */
|
|
307
|
+
toolUseId?: string;
|
|
174
308
|
}
|
|
175
309
|
|
|
176
310
|
export interface RoutingResult {
|
|
@@ -232,6 +366,45 @@ export function resolveSessionFiles(params: RoutingParams): RoutingResult {
|
|
|
232
366
|
break;
|
|
233
367
|
}
|
|
234
368
|
|
|
369
|
+
case "branch": {
|
|
370
|
+
// Find all sessions in the same project that share a git branch with the current session.
|
|
371
|
+
// Requires toolUseId to identify the calling session, or an explicit gitBranch param.
|
|
372
|
+
const projectDir = params.projectDir ?? (params.cwd ? getProjectDir(params.cwd) : null);
|
|
373
|
+
if (!projectDir) {
|
|
374
|
+
return { sessions: [], totalCount: 0, hasMore: false };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const projectSessions = listSessionFiles(projectDir);
|
|
378
|
+
let targetBranch: string | null = null;
|
|
379
|
+
|
|
380
|
+
// If we have a toolUseId, find the calling session's branch
|
|
381
|
+
if (params.toolUseId) {
|
|
382
|
+
const callingId = findCallingSession(params.toolUseId, projectSessions);
|
|
383
|
+
if (callingId) {
|
|
384
|
+
const callingSession = projectSessions.find(s => s.sessionId === callingId);
|
|
385
|
+
if (callingSession) {
|
|
386
|
+
targetBranch = extractGitBranch(callingSession.path);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Fallback: use the explicit gitBranch param
|
|
392
|
+
if (!targetBranch && params.gitBranch) {
|
|
393
|
+
targetBranch = params.gitBranch;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!targetBranch) {
|
|
397
|
+
return { sessions: [], totalCount: 0, hasMore: false };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Filter to sessions that share the same branch
|
|
401
|
+
allSessions = projectSessions.filter(s => {
|
|
402
|
+
const branches = extractGitBranches(s.path);
|
|
403
|
+
return branches.includes(targetBranch!);
|
|
404
|
+
});
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
|
|
235
408
|
default:
|
|
236
409
|
return { sessions: [], totalCount: 0, hasMore: false };
|
|
237
410
|
}
|
|
@@ -317,7 +490,7 @@ export function findCallingSession(
|
|
|
317
490
|
* and exclude the calling session from results.
|
|
318
491
|
*/
|
|
319
492
|
export function resolveSessionFilesExcludingSelf(
|
|
320
|
-
params: RoutingParams
|
|
493
|
+
params: RoutingParams,
|
|
321
494
|
): RoutingResult & { excludedSessionId: string | null } {
|
|
322
495
|
const result = resolveSessionFiles(params);
|
|
323
496
|
|
package/src/mcp-server.ts
CHANGED
|
@@ -19,15 +19,15 @@ import {
|
|
|
19
19
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
20
20
|
|
|
21
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";
|
|
22
|
+
import { resolveSessionFiles, resolveSessionFilesExcludingSelf, getProjectsDir, listAllSessionFiles, listProjects, extractGitBranch } from "./discovery.js";
|
|
23
|
+
import { querySingleWindow, queryBatch, querySelf, queryAncestors, querySubagents } from "./query.js";
|
|
24
24
|
|
|
25
25
|
// ============================================================================
|
|
26
26
|
// Server setup
|
|
27
27
|
// ============================================================================
|
|
28
28
|
|
|
29
29
|
const server = new Server(
|
|
30
|
-
{ name: "duncan-cc", version: "0.
|
|
30
|
+
{ name: "duncan-cc", version: "0.2.0" },
|
|
31
31
|
{ capabilities: { tools: {} } },
|
|
32
32
|
);
|
|
33
33
|
|
|
@@ -52,13 +52,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
52
52
|
},
|
|
53
53
|
mode: {
|
|
54
54
|
type: "string",
|
|
55
|
-
enum: ["project", "global", "session", "self", "ancestors"],
|
|
55
|
+
enum: ["project", "global", "session", "self", "ancestors", "subagents", "branch"],
|
|
56
56
|
description:
|
|
57
57
|
"Routing mode. 'project': sessions from a specific project dir. " +
|
|
58
58
|
"'global': all sessions across all projects (newest first). " +
|
|
59
59
|
"'session': a specific session file. " +
|
|
60
60
|
"'self': query own active window N times for sampling diversity. " +
|
|
61
|
-
"'ancestors': query own prior compaction windows (excluding active)."
|
|
61
|
+
"'ancestors': query own prior compaction windows (excluding active). " +
|
|
62
|
+
"'subagents': query subagent transcripts of the active session. " +
|
|
63
|
+
"'branch': sessions from the same project that share the current git branch.",
|
|
62
64
|
},
|
|
63
65
|
projectDir: {
|
|
64
66
|
type: "string",
|
|
@@ -88,10 +90,39 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
88
90
|
type: "number",
|
|
89
91
|
description: "For 'self' mode: number of parallel queries for sampling diversity (default: 3).",
|
|
90
92
|
},
|
|
93
|
+
batchSize: {
|
|
94
|
+
type: "number",
|
|
95
|
+
description: "Max concurrent API calls per batch (default: 5).",
|
|
96
|
+
},
|
|
97
|
+
gitBranch: {
|
|
98
|
+
type: "string",
|
|
99
|
+
description: "For 'branch' mode: explicit git branch name. If omitted, uses the calling session's branch.",
|
|
100
|
+
},
|
|
91
101
|
},
|
|
92
102
|
required: ["question", "mode"],
|
|
93
103
|
},
|
|
94
104
|
},
|
|
105
|
+
{
|
|
106
|
+
name: "duncan_projects",
|
|
107
|
+
description:
|
|
108
|
+
"List all Claude Code projects with metadata. Use to discover what projects exist " +
|
|
109
|
+
"before targeting a specific project with duncan_query. Returns project directories, " +
|
|
110
|
+
"session counts, last activity timestamps, and git branches.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: "object" as const,
|
|
113
|
+
properties: {
|
|
114
|
+
limit: {
|
|
115
|
+
type: "number",
|
|
116
|
+
description: "Max projects to list (default: 50).",
|
|
117
|
+
},
|
|
118
|
+
offset: {
|
|
119
|
+
type: "number",
|
|
120
|
+
description: "Pagination offset (default: 0).",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: [],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
95
126
|
{
|
|
96
127
|
name: "duncan_list_sessions",
|
|
97
128
|
description: "List available Claude Code sessions. Use to discover sessions before querying.",
|
|
@@ -130,11 +161,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
130
161
|
const { name, arguments: args, _meta } = request.params;
|
|
131
162
|
|
|
132
163
|
// CC passes tool_use ID in _meta — used for self-exclusion
|
|
133
|
-
const
|
|
164
|
+
const meta = _meta as Record<string, unknown> | undefined;
|
|
165
|
+
const toolUseId = meta?.["claudecode/toolUseId"] as string | undefined;
|
|
166
|
+
const progressToken = meta?.progressToken as string | number | undefined;
|
|
134
167
|
|
|
135
168
|
switch (name) {
|
|
136
169
|
case "duncan_query":
|
|
137
|
-
return handleDuncanQuery(args as any, toolUseId);
|
|
170
|
+
return handleDuncanQuery(args as any, toolUseId, progressToken);
|
|
171
|
+
case "duncan_projects":
|
|
172
|
+
return handleListProjects(args as any);
|
|
138
173
|
case "duncan_list_sessions":
|
|
139
174
|
return handleListSessions(args as any);
|
|
140
175
|
default:
|
|
@@ -145,6 +180,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
145
180
|
}
|
|
146
181
|
});
|
|
147
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Send an MCP progress notification if a progressToken was provided.
|
|
185
|
+
*/
|
|
186
|
+
async function sendProgress(progressToken: string | number | undefined, progress: number, total: number): Promise<void> {
|
|
187
|
+
if (progressToken === undefined) return;
|
|
188
|
+
try {
|
|
189
|
+
await server.notification({
|
|
190
|
+
method: "notifications/progress",
|
|
191
|
+
params: { progressToken, progress, total },
|
|
192
|
+
});
|
|
193
|
+
} catch {
|
|
194
|
+
// Best-effort — don't let progress notifications break queries
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
148
198
|
async function handleDuncanQuery(args: {
|
|
149
199
|
question: string;
|
|
150
200
|
mode: string;
|
|
@@ -155,7 +205,9 @@ async function handleDuncanQuery(args: {
|
|
|
155
205
|
offset?: number;
|
|
156
206
|
includeSubagents?: boolean;
|
|
157
207
|
copies?: number;
|
|
158
|
-
|
|
208
|
+
batchSize?: number;
|
|
209
|
+
gitBranch?: string;
|
|
210
|
+
}, toolUseId?: string, progressToken?: string | number) {
|
|
159
211
|
try {
|
|
160
212
|
// Self mode: query own active window N times for sampling diversity
|
|
161
213
|
if (args.mode === "self") {
|
|
@@ -168,7 +220,9 @@ async function handleDuncanQuery(args: {
|
|
|
168
220
|
const result = await querySelf(args.question, {
|
|
169
221
|
toolUseId,
|
|
170
222
|
copies: args.copies ?? 3,
|
|
223
|
+
batchSize: args.batchSize,
|
|
171
224
|
apiKey: undefined,
|
|
225
|
+
onProgress: (completed, total) => sendProgress(progressToken, completed, total),
|
|
172
226
|
});
|
|
173
227
|
|
|
174
228
|
if (result.results.length === 0) {
|
|
@@ -201,7 +255,9 @@ async function handleDuncanQuery(args: {
|
|
|
201
255
|
toolUseId,
|
|
202
256
|
limit: args.limit ?? 50,
|
|
203
257
|
offset: args.offset ?? 0,
|
|
258
|
+
batchSize: args.batchSize,
|
|
204
259
|
apiKey: undefined,
|
|
260
|
+
onProgress: (completed, total) => sendProgress(progressToken, completed, total),
|
|
205
261
|
});
|
|
206
262
|
|
|
207
263
|
if (result.results.length === 0) {
|
|
@@ -232,6 +288,58 @@ async function handleDuncanQuery(args: {
|
|
|
232
288
|
return { content: [{ type: "text", text: parts.join("") }] };
|
|
233
289
|
}
|
|
234
290
|
|
|
291
|
+
// Subagents mode: query subagent transcripts of the calling session
|
|
292
|
+
if (args.mode === "subagents") {
|
|
293
|
+
if (!toolUseId) {
|
|
294
|
+
return {
|
|
295
|
+
content: [{ type: "text", text: "Subagents mode requires toolUseId from _meta (only available when called from CC)." }],
|
|
296
|
+
isError: true,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const result = await querySubagents(args.question, {
|
|
300
|
+
toolUseId,
|
|
301
|
+
limit: args.limit ?? 50,
|
|
302
|
+
offset: args.offset ?? 0,
|
|
303
|
+
batchSize: args.batchSize,
|
|
304
|
+
apiKey: undefined,
|
|
305
|
+
onProgress: (completed, total) => sendProgress(progressToken, completed, total),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (result.results.length === 0) {
|
|
309
|
+
return {
|
|
310
|
+
content: [{ type: "text", text: "No subagent transcripts found for this session." }],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const errors = result.results.filter(r => r.result.answer.startsWith("Error: "));
|
|
315
|
+
const nonErrors = result.results.filter(r => !r.result.answer.startsWith("Error: "));
|
|
316
|
+
const withContext = nonErrors.filter(r => r.result.hasContext);
|
|
317
|
+
const relevant = withContext.length > 0 ? withContext : nonErrors.length > 0 ? nonErrors : result.results;
|
|
318
|
+
|
|
319
|
+
const answers = relevant.map((r) => {
|
|
320
|
+
const label = relevant.length === 1 ? "" : `### ${r.sessionId.slice(0, 20)} (window ${r.windowIndex})\n`;
|
|
321
|
+
return `${label}${r.result.answer}\n*— ${r.model}*`;
|
|
322
|
+
}).join("\n\n---\n\n");
|
|
323
|
+
|
|
324
|
+
const parts = [`**${args.question}**\n\n${answers}`];
|
|
325
|
+
|
|
326
|
+
if (errors.length > 0) {
|
|
327
|
+
const errorLines = errors.map(r => `- ${r.sessionId.slice(0, 20)} (window ${r.windowIndex}): ${r.result.answer}`).join("\n");
|
|
328
|
+
parts.push(`\n\n---\n**${errors.length} error(s):**\n${errorLines}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (result.hasMore) {
|
|
332
|
+
const nextOffset = (args.offset ?? 0) + (args.limit ?? 50);
|
|
333
|
+
const remaining = result.totalWindows - nextOffset;
|
|
334
|
+
parts.push(`\n\n---\n*Queried ${result.results.length} of ${result.totalWindows} windows. ${remaining} more available — call again with offset: ${nextOffset}.*`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const contextCount = withContext.length;
|
|
338
|
+
parts.push(`\n\n*${contextCount}/${result.results.length} subagent windows had relevant context. queryId: ${result.queryId}*`);
|
|
339
|
+
|
|
340
|
+
return { content: [{ type: "text", text: parts.join("") }] };
|
|
341
|
+
}
|
|
342
|
+
|
|
235
343
|
const result = await queryBatch(
|
|
236
344
|
args.question,
|
|
237
345
|
{
|
|
@@ -242,10 +350,13 @@ async function handleDuncanQuery(args: {
|
|
|
242
350
|
limit: args.limit ?? 10,
|
|
243
351
|
offset: args.offset ?? 0,
|
|
244
352
|
includeSubagents: args.includeSubagents ?? false,
|
|
245
|
-
toolUseId, // for self-exclusion
|
|
353
|
+
toolUseId, // for self-exclusion + branch detection
|
|
354
|
+
gitBranch: args.gitBranch,
|
|
246
355
|
},
|
|
247
356
|
{
|
|
248
357
|
apiKey: undefined,
|
|
358
|
+
batchSize: args.batchSize,
|
|
359
|
+
onProgress: (completed, total) => sendProgress(progressToken, completed, total),
|
|
249
360
|
},
|
|
250
361
|
);
|
|
251
362
|
|
|
@@ -300,6 +411,37 @@ async function handleDuncanQuery(args: {
|
|
|
300
411
|
}
|
|
301
412
|
}
|
|
302
413
|
|
|
414
|
+
async function handleListProjects(args: {
|
|
415
|
+
limit?: number;
|
|
416
|
+
offset?: number;
|
|
417
|
+
}) {
|
|
418
|
+
try {
|
|
419
|
+
const result = listProjects({ limit: args.limit, offset: args.offset });
|
|
420
|
+
|
|
421
|
+
if (result.projects.length === 0) {
|
|
422
|
+
return {
|
|
423
|
+
content: [{ type: "text", text: "No projects found." }],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const lines = result.projects.map((p) => {
|
|
428
|
+
const date = p.lastActivity.toISOString().slice(0, 16);
|
|
429
|
+
const branches = p.branches.length > 0 ? p.branches.join(", ") : "—";
|
|
430
|
+
return `${p.cwd}\n sessions: ${p.sessionCount} last: ${date} branches: ${branches}`;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const header = `${result.totalCount} projects${result.hasMore ? ` (showing ${result.projects.length})` : ""}:`;
|
|
434
|
+
return {
|
|
435
|
+
content: [{ type: "text", text: `${header}\n\n${lines.join("\n\n")}` }],
|
|
436
|
+
};
|
|
437
|
+
} catch (err: any) {
|
|
438
|
+
return {
|
|
439
|
+
content: [{ type: "text", text: `Error listing projects: ${err.message}` }],
|
|
440
|
+
isError: true,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
303
445
|
async function handleListSessions(args: {
|
|
304
446
|
mode: string;
|
|
305
447
|
projectDir?: string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duncan Query Logger
|
|
3
|
+
*
|
|
4
|
+
* Appends structured records to ~/.claude/duncan.jsonl for every query.
|
|
5
|
+
* Captures tokens, latency, cache hits, routing strategy, and results.
|
|
6
|
+
* Append-only JSONL — easy to process with standard tools.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { appendFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export interface DuncanLogRecord {
|
|
18
|
+
/** Batch ID grouping related queries */
|
|
19
|
+
batchId: string;
|
|
20
|
+
/** The question asked */
|
|
21
|
+
question: string;
|
|
22
|
+
/** The answer returned */
|
|
23
|
+
answer: string;
|
|
24
|
+
/** Whether the session had relevant context */
|
|
25
|
+
hasContext: boolean;
|
|
26
|
+
/** Target session ID */
|
|
27
|
+
targetSession: string;
|
|
28
|
+
/** Window index within the session */
|
|
29
|
+
windowIndex: number;
|
|
30
|
+
/** Source session ID (the calling session, if known) */
|
|
31
|
+
sourceSession: string | null;
|
|
32
|
+
/** Routing strategy used */
|
|
33
|
+
strategy: string;
|
|
34
|
+
/** Model used for the query */
|
|
35
|
+
model: string;
|
|
36
|
+
/** Input tokens consumed */
|
|
37
|
+
inputTokens: number;
|
|
38
|
+
/** Output tokens generated */
|
|
39
|
+
outputTokens: number;
|
|
40
|
+
/** Cache creation input tokens */
|
|
41
|
+
cacheCreationInputTokens: number;
|
|
42
|
+
/** Cache read input tokens */
|
|
43
|
+
cacheReadInputTokens: number;
|
|
44
|
+
/** Query latency in milliseconds */
|
|
45
|
+
latencyMs: number;
|
|
46
|
+
/** ISO timestamp */
|
|
47
|
+
timestamp: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Logger
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
let logPath: string | null = null;
|
|
55
|
+
|
|
56
|
+
function getLogPath(): string {
|
|
57
|
+
if (!logPath) {
|
|
58
|
+
const claudeDir = join(homedir(), ".claude");
|
|
59
|
+
if (!existsSync(claudeDir)) {
|
|
60
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
logPath = join(claudeDir, "duncan.jsonl");
|
|
63
|
+
}
|
|
64
|
+
return logPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record a single query result to the log file.
|
|
69
|
+
*/
|
|
70
|
+
export function recordQuery(record: DuncanLogRecord): void {
|
|
71
|
+
try {
|
|
72
|
+
const line = JSON.stringify(record) + "\n";
|
|
73
|
+
appendFileSync(getLogPath(), line, "utf-8");
|
|
74
|
+
} catch {
|
|
75
|
+
// Logging is best-effort — don't let it break queries
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Helper to create a log record from query context.
|
|
81
|
+
*/
|
|
82
|
+
export function buildLogRecord(opts: {
|
|
83
|
+
batchId: string;
|
|
84
|
+
question: string;
|
|
85
|
+
answer: string;
|
|
86
|
+
hasContext: boolean;
|
|
87
|
+
targetSession: string;
|
|
88
|
+
windowIndex: number;
|
|
89
|
+
sourceSession?: string | null;
|
|
90
|
+
strategy: string;
|
|
91
|
+
model: string;
|
|
92
|
+
usage?: {
|
|
93
|
+
input_tokens?: number;
|
|
94
|
+
output_tokens?: number;
|
|
95
|
+
cache_creation_input_tokens?: number;
|
|
96
|
+
cache_read_input_tokens?: number;
|
|
97
|
+
};
|
|
98
|
+
latencyMs: number;
|
|
99
|
+
}): DuncanLogRecord {
|
|
100
|
+
return {
|
|
101
|
+
batchId: opts.batchId,
|
|
102
|
+
question: opts.question,
|
|
103
|
+
answer: opts.answer,
|
|
104
|
+
hasContext: opts.hasContext,
|
|
105
|
+
targetSession: opts.targetSession,
|
|
106
|
+
windowIndex: opts.windowIndex,
|
|
107
|
+
sourceSession: opts.sourceSession ?? null,
|
|
108
|
+
strategy: opts.strategy,
|
|
109
|
+
model: opts.model,
|
|
110
|
+
inputTokens: opts.usage?.input_tokens ?? 0,
|
|
111
|
+
outputTokens: opts.usage?.output_tokens ?? 0,
|
|
112
|
+
cacheCreationInputTokens: opts.usage?.cache_creation_input_tokens ?? 0,
|
|
113
|
+
cacheReadInputTokens: opts.usage?.cache_read_input_tokens ?? 0,
|
|
114
|
+
latencyMs: opts.latencyMs,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { processSessionFile, processSessionWindows, type PipelineResult, type WindowPipelineResult } from "./pipeline.js";
|
|
14
|
-
import { resolveSessionFilesExcludingSelf, findCallingSession, listAllSessionFiles, type RoutingParams, type RoutingResult } from "./discovery.js";
|
|
14
|
+
import { resolveSessionFilesExcludingSelf, findCallingSession, listAllSessionFiles, listSubagentFiles, type RoutingParams, type RoutingResult } from "./discovery.js";
|
|
15
|
+
import { recordQuery, buildLogRecord } from "./query-logger.js";
|
|
15
16
|
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// OAuth token resolution
|
|
@@ -65,7 +66,7 @@ function oauthClientConfig(token: string): ResolvedAuth {
|
|
|
65
66
|
"accept": "application/json",
|
|
66
67
|
"anthropic-dangerous-direct-browser-access": "true",
|
|
67
68
|
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14",
|
|
68
|
-
"user-agent": "duncan-cc/0.
|
|
69
|
+
"user-agent": "duncan-cc/0.2.0",
|
|
69
70
|
"x-app": "cli",
|
|
70
71
|
},
|
|
71
72
|
};
|
|
@@ -105,6 +106,13 @@ const DUNCAN_PREFIX = `Answer solely based on the conversation above. If you don
|
|
|
105
106
|
export interface DuncanResult {
|
|
106
107
|
hasContext: boolean;
|
|
107
108
|
answer: string;
|
|
109
|
+
usage?: {
|
|
110
|
+
input_tokens: number;
|
|
111
|
+
output_tokens: number;
|
|
112
|
+
cache_creation_input_tokens?: number;
|
|
113
|
+
cache_read_input_tokens?: number;
|
|
114
|
+
};
|
|
115
|
+
latencyMs?: number;
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
export interface DuncanQueryResult {
|
|
@@ -190,6 +198,8 @@ export async function querySingleWindow(
|
|
|
190
198
|
} as any);
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
const startTime = Date.now();
|
|
202
|
+
|
|
193
203
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
194
204
|
const response = await client.messages.create({
|
|
195
205
|
model,
|
|
@@ -207,7 +217,13 @@ export async function querySingleWindow(
|
|
|
207
217
|
if (toolCall) {
|
|
208
218
|
const input = toolCall.input as { hasContext: boolean; answer: string };
|
|
209
219
|
if (typeof input.hasContext === "boolean" && typeof input.answer === "string") {
|
|
210
|
-
|
|
220
|
+
const latencyMs = Date.now() - startTime;
|
|
221
|
+
return {
|
|
222
|
+
hasContext: input.hasContext,
|
|
223
|
+
answer: input.answer,
|
|
224
|
+
usage: response.usage as any,
|
|
225
|
+
latencyMs,
|
|
226
|
+
};
|
|
211
227
|
}
|
|
212
228
|
}
|
|
213
229
|
|
|
@@ -226,6 +242,35 @@ export async function querySingleWindow(
|
|
|
226
242
|
throw new Error(`Duncan query failed after ${MAX_RETRIES} retries: model did not produce a valid duncan_response tool call`);
|
|
227
243
|
}
|
|
228
244
|
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Logging Helper
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Log all results in a batch to ~/.claude/duncan.jsonl.
|
|
251
|
+
*/
|
|
252
|
+
function logBatchResults(
|
|
253
|
+
batchResult: DuncanBatchResult,
|
|
254
|
+
strategy: string,
|
|
255
|
+
sourceSession: string | null,
|
|
256
|
+
): void {
|
|
257
|
+
for (const r of batchResult.results) {
|
|
258
|
+
recordQuery(buildLogRecord({
|
|
259
|
+
batchId: batchResult.queryId,
|
|
260
|
+
question: batchResult.question,
|
|
261
|
+
answer: r.result.answer,
|
|
262
|
+
hasContext: r.result.hasContext,
|
|
263
|
+
targetSession: r.sessionId,
|
|
264
|
+
windowIndex: r.windowIndex,
|
|
265
|
+
sourceSession,
|
|
266
|
+
strategy,
|
|
267
|
+
model: r.model,
|
|
268
|
+
usage: r.result.usage,
|
|
269
|
+
latencyMs: r.result.latencyMs ?? 0,
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
229
274
|
// ============================================================================
|
|
230
275
|
// Batch Query
|
|
231
276
|
// ============================================================================
|
|
@@ -235,7 +280,7 @@ export async function querySingleWindow(
|
|
|
235
280
|
*/
|
|
236
281
|
export async function queryBatch(
|
|
237
282
|
question: string,
|
|
238
|
-
routing: RoutingParams
|
|
283
|
+
routing: RoutingParams,
|
|
239
284
|
opts: {
|
|
240
285
|
apiKey?: string;
|
|
241
286
|
model?: string;
|
|
@@ -328,7 +373,7 @@ export async function queryBatch(
|
|
|
328
373
|
results.push(...batchResults);
|
|
329
374
|
}
|
|
330
375
|
|
|
331
|
-
|
|
376
|
+
const batchResult: DuncanBatchResult = {
|
|
332
377
|
queryId,
|
|
333
378
|
question,
|
|
334
379
|
results,
|
|
@@ -336,6 +381,8 @@ export async function queryBatch(
|
|
|
336
381
|
hasMore: resolved.hasMore,
|
|
337
382
|
offset: routing.offset ?? 0,
|
|
338
383
|
};
|
|
384
|
+
logBatchResults(batchResult, routing.mode, resolved.excludedSessionId);
|
|
385
|
+
return batchResult;
|
|
339
386
|
}
|
|
340
387
|
|
|
341
388
|
// ============================================================================
|
|
@@ -434,7 +481,9 @@ export async function querySelf(
|
|
|
434
481
|
// Wave 1: prime the cache with a single query
|
|
435
482
|
results.push(await queryOnce());
|
|
436
483
|
if (opts.signal?.aborted || copies <= 1) {
|
|
437
|
-
|
|
484
|
+
const batchResult: DuncanBatchResult = { queryId, question, results, totalWindows: total, hasMore: false, offset: 0 };
|
|
485
|
+
logBatchResults(batchResult, "self", callingSessionId);
|
|
486
|
+
return batchResult;
|
|
438
487
|
}
|
|
439
488
|
|
|
440
489
|
// Wave 2: remaining copies in batches, hitting cached prefix
|
|
@@ -449,7 +498,9 @@ export async function querySelf(
|
|
|
449
498
|
results.push(...batchResults);
|
|
450
499
|
}
|
|
451
500
|
|
|
452
|
-
|
|
501
|
+
const batchResult: DuncanBatchResult = { queryId, question, results, totalWindows: total, hasMore: false, offset: 0 };
|
|
502
|
+
logBatchResults(batchResult, "self", callingSessionId);
|
|
503
|
+
return batchResult;
|
|
453
504
|
}
|
|
454
505
|
|
|
455
506
|
// ============================================================================
|
|
@@ -546,7 +597,7 @@ export async function queryAncestors(
|
|
|
546
597
|
results.push(...batchResults);
|
|
547
598
|
}
|
|
548
599
|
|
|
549
|
-
|
|
600
|
+
const batchResult: DuncanBatchResult = {
|
|
550
601
|
queryId,
|
|
551
602
|
question,
|
|
552
603
|
results,
|
|
@@ -554,6 +605,112 @@ export async function queryAncestors(
|
|
|
554
605
|
hasMore: offset + limit < totalWindows,
|
|
555
606
|
offset,
|
|
556
607
|
};
|
|
608
|
+
logBatchResults(batchResult, "ancestors", callingSessionId);
|
|
609
|
+
return batchResult;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// Subagents Query — subagent transcripts of the active session
|
|
614
|
+
// ============================================================================
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Query the calling session's subagent transcripts.
|
|
618
|
+
*/
|
|
619
|
+
export async function querySubagents(
|
|
620
|
+
question: string,
|
|
621
|
+
opts: {
|
|
622
|
+
toolUseId: string;
|
|
623
|
+
limit?: number;
|
|
624
|
+
offset?: number;
|
|
625
|
+
batchSize?: number;
|
|
626
|
+
apiKey?: string;
|
|
627
|
+
model?: string;
|
|
628
|
+
signal?: AbortSignal;
|
|
629
|
+
onProgress?: (completed: number, total: number) => void;
|
|
630
|
+
},
|
|
631
|
+
): Promise<DuncanBatchResult> {
|
|
632
|
+
const queryId = randomUUID();
|
|
633
|
+
const limit = opts.limit ?? 50;
|
|
634
|
+
const offset = opts.offset ?? 0;
|
|
635
|
+
|
|
636
|
+
const allSessions = listAllSessionFiles();
|
|
637
|
+
const callingSessionId = findCallingSession(opts.toolUseId, allSessions);
|
|
638
|
+
if (!callingSessionId) {
|
|
639
|
+
return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const session = allSessions.find(s => s.sessionId === callingSessionId);
|
|
643
|
+
if (!session) {
|
|
644
|
+
return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const subagentFiles = listSubagentFiles(session.path);
|
|
648
|
+
if (subagentFiles.length === 0) {
|
|
649
|
+
return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Expand subagent files to windows
|
|
653
|
+
const allTargets: Array<{ sessionFile: string; sessionId: string; pipeline: WindowPipelineResult }> = [];
|
|
654
|
+
for (const sub of subagentFiles) {
|
|
655
|
+
try {
|
|
656
|
+
const windows = processSessionWindows(sub.path);
|
|
657
|
+
for (const w of windows) {
|
|
658
|
+
if (w.messages.length === 0) continue;
|
|
659
|
+
allTargets.push({ sessionFile: sub.path, sessionId: sub.sessionId, pipeline: w });
|
|
660
|
+
}
|
|
661
|
+
} catch {}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (allTargets.length === 0) {
|
|
665
|
+
return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const totalWindows = allTargets.length;
|
|
669
|
+
const page = allTargets.slice(offset, offset + limit);
|
|
670
|
+
|
|
671
|
+
const batchSize = opts.batchSize ?? 5;
|
|
672
|
+
const results: DuncanQueryResult[] = [];
|
|
673
|
+
let completed = 0;
|
|
674
|
+
|
|
675
|
+
for (let i = 0; i < page.length; i += batchSize) {
|
|
676
|
+
if (opts.signal?.aborted) break;
|
|
677
|
+
const batch = page.slice(i, i + batchSize);
|
|
678
|
+
const batchResults = await Promise.all(
|
|
679
|
+
batch.map(async (target) => {
|
|
680
|
+
try {
|
|
681
|
+
const result = await querySingleWindow(target.pipeline, question, {
|
|
682
|
+
apiKey: opts.apiKey,
|
|
683
|
+
model: opts.model ?? target.pipeline.modelInfo?.modelId,
|
|
684
|
+
signal: opts.signal,
|
|
685
|
+
});
|
|
686
|
+
completed++;
|
|
687
|
+
opts.onProgress?.(completed, page.length);
|
|
688
|
+
return {
|
|
689
|
+
queryId, sessionFile: target.sessionFile, sessionId: target.sessionId,
|
|
690
|
+
windowIndex: target.pipeline.windowIndex,
|
|
691
|
+
model: target.pipeline.modelInfo?.modelId ?? "unknown", result,
|
|
692
|
+
};
|
|
693
|
+
} catch (err: any) {
|
|
694
|
+
completed++;
|
|
695
|
+
opts.onProgress?.(completed, page.length);
|
|
696
|
+
return {
|
|
697
|
+
queryId, sessionFile: target.sessionFile, sessionId: target.sessionId,
|
|
698
|
+
windowIndex: target.pipeline.windowIndex,
|
|
699
|
+
model: target.pipeline.modelInfo?.modelId ?? "unknown",
|
|
700
|
+
result: { hasContext: false, answer: `Error: ${err.message}` },
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
results.push(...batchResults);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const batchResult: DuncanBatchResult = {
|
|
709
|
+
queryId, question, results, totalWindows,
|
|
710
|
+
hasMore: offset + limit < totalWindows, offset,
|
|
711
|
+
};
|
|
712
|
+
logBatchResults(batchResult, "subagents", callingSessionId);
|
|
713
|
+
return batchResult;
|
|
557
714
|
}
|
|
558
715
|
|
|
559
716
|
// ============================================================================
|