@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 ADDED
@@ -0,0 +1,110 @@
1
+ # duncan-cc
2
+
3
+ Query dormant Claude Code sessions. The [Duncan Idaho approach](https://gswangg.net/posts/duncan-idaho-agent-memory) to agent memory, for CC.
4
+
5
+ When CC sessions end or get compacted, their conversation history is still on disk. Duncan loads that history into a fresh LLM call and asks it your question — leveraging the model's native attention mechanism instead of summaries or search.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @gswangg/duncan-cc
11
+ ```
12
+
13
+ Or from source:
14
+
15
+ ```bash
16
+ git clone https://github.com/gswangg/duncan-cc.git
17
+ cd duncan-cc
18
+ npm install
19
+ ```
20
+
21
+ ## Configure CC
22
+
23
+ ```bash
24
+ # If installed globally via npm:
25
+ claude mcp add duncan -- npx @gswangg/duncan-cc
26
+
27
+ # If installed from source:
28
+ claude mcp add duncan -- npx tsx /path/to/duncan-cc/src/mcp-server.ts
29
+ ```
30
+
31
+ ## Authentication
32
+
33
+ Duncan resolves auth automatically:
34
+
35
+ 1. Explicit apiKey/token parameter
36
+ 2. CC OAuth credentials (`~/.claude/.credentials.json`)
37
+ 3. `ANTHROPIC_API_KEY` environment variable
38
+
39
+ ## Tools
40
+
41
+ ### `duncan_query`
42
+
43
+ | Parameter | Type | Required | Description |
44
+ |-----------|------|----------|-------------|
45
+ | `question` | string | ✓ | The question to ask |
46
+ | `mode` | string | ✓ | `project`, `global`, `session`, `self`, `ancestors` |
47
+ | `projectDir` | string | | For project mode |
48
+ | `sessionId` | string | | For session mode |
49
+ | `cwd` | string | | Working directory for context resolution |
50
+ | `limit` | number | | Max sessions/windows (default: 10) |
51
+ | `offset` | number | | Pagination offset |
52
+ | `copies` | number | | For self mode: sample count (default: 3) |
53
+ | `includeSubagents` | boolean | | Include subagent transcripts |
54
+
55
+ ### `duncan_list_sessions`
56
+
57
+ | Parameter | Type | Required | Description |
58
+ |-----------|------|----------|-------------|
59
+ | `mode` | string | ✓ | `project` or `global` |
60
+ | `projectDir` | string | | For project mode |
61
+ | `cwd` | string | | Working directory |
62
+ | `limit` | number | | Max sessions (default: 20) |
63
+
64
+ ## Routing modes
65
+
66
+ | Mode | Target |
67
+ |------|--------|
68
+ | `project` | Sessions from same project dir (self-excluded) |
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) |
73
+
74
+ ## How it works
75
+
76
+ Duncan replicates CC's full session-to-API pipeline, then substitutes its own query:
77
+
78
+ 1. Parse JSONL session file
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
90
+
91
+ Self-exclusion: the calling session is identified by scanning for the MCP `toolUseId` in session file tails — deterministic, zero config, swarm-safe.
92
+
93
+ ## Known gaps
94
+
95
+ - **MCP server instructions** — not available for dormant sessions (fetched live, not persisted)
96
+ - **Tool schemas** — only `duncan_response` is sent; session's original tools aren't callable
97
+ - **Compaction test coverage** — synthetic tests only; no real compacted sessions in test corpus
98
+
99
+ ## Tests
100
+
101
+ ```bash
102
+ npm test
103
+ ```
104
+
105
+ Corpus-dependent tests skip gracefully when `testdata/` is absent.
106
+
107
+ ## Related
108
+
109
+ - [duncan-pi](https://github.com/gswangg/duncan-pi) — duncan for the [pi](https://github.com/badlogic/pi-mono) coding agent
110
+ - [The Duncan Idaho Approach to Agent Memory](https://gswangg.net/posts/duncan-idaho-agent-memory) — design writeup
package/SPEC.md ADDED
@@ -0,0 +1,195 @@
1
+ # Duncan for Claude Code — Spec
2
+
3
+ ## Overview
4
+
5
+ Duncan-cc replicates CC's full message pipeline to hydrate dormant CC sessions,
6
+ then queries them with questions via the Anthropic API. Exposed as an MCP server
7
+ (stdio transport) with two tools: `duncan_query` and `duncan_list_sessions`.
8
+
9
+ ## Pipeline: Disk → API
10
+
11
+ ```
12
+ Session file (.jsonl)
13
+
14
+
15
+ Parse JSONL — separate transcript from metadata
16
+
17
+
18
+ Preserved segment relinking (compaction tree surgery)
19
+
20
+
21
+ Walk parentUuid chain from leaf to root
22
+
23
+
24
+ Post-process: handle orphan tool results, deduplicate assistant splits
25
+
26
+
27
+ Strip internal fields (isSidechain, parentUuid)
28
+
29
+
30
+ Slice from last compact boundary onward
31
+
32
+
33
+ Content replacements (persisted-output resolution from tool-results/)
34
+
35
+
36
+ Microcompact (truncate old tool results)
37
+
38
+
39
+ Normalize messages:
40
+ ├── Reorder attachments adjacent to referencing messages
41
+ ├── Filter: progress, non-local system, API error messages
42
+ ├── System messages → user messages (system-reminder wrapper)
43
+ ├── Strip tool_references from user messages
44
+ ├── Merge consecutive same-role messages
45
+ ├── Merge split assistant messages (same message.id)
46
+ ├── Convert attachment messages to user messages
47
+
48
+ ├── Post-transform 1: Relocate deferred tool_reference text
49
+ ├── Post-transform 2: Filter orphaned thinking-only assistant messages
50
+ ├── Post-transform 3: Remove trailing thinking from last assistant
51
+ ├── Post-transform 4: Remove whitespace-only assistants + re-merge users
52
+ ├── Post-transform 5: Fix empty assistant content (placeholder)
53
+ ├── Post-transform 6: Reorder system-reminder within tool_results
54
+ ├── Post-transform 7: Flatten error tool_results (text-only)
55
+ └── Post-transform 8: Fix orphaned tool_use (synthetic tool_result)
56
+
57
+
58
+ Inject userContext (<system-reminder> with CLAUDE.md + date)
59
+
60
+
61
+ Build system prompt (full parity with CC):
62
+ ├── Identity/intro
63
+ ├── System rules
64
+ ├── Coding instructions
65
+ ├── Careful actions guidelines
66
+ ├── Tool usage
67
+ ├── Tone and style
68
+ ├── Output efficiency
69
+ ├── Environment info (cwd, platform, model)
70
+ ├── CLAUDE.md (from session's original cwd)
71
+ ├── Memory (from project dir MEMORY.md)
72
+ └── Language preference
73
+
74
+
75
+ Convert to API format: {role, content} only
76
+
77
+
78
+ Add cache_control breakpoints:
79
+ ├── System prompt blocks: ephemeral cache
80
+ └── Penultimate message: ephemeral cache (session context boundary)
81
+
82
+
83
+ Append duncan query as final user message
84
+
85
+
86
+ messages.create() with duncan_response tool
87
+ ```
88
+
89
+ ## Routing Modes
90
+
91
+ | Mode | Target | Self-exclusion |
92
+ |------|--------|----------------|
93
+ | `project` | All sessions in same project dir | ✅ via toolUseId |
94
+ | `global` | All sessions across all projects | ✅ via toolUseId |
95
+ | `session` | Specific session by ID/path | — |
96
+ | `self` | Own active window, N copies (sampling diversity) | — (queries self intentionally) |
97
+ | `ancestors` | Own prior compaction windows (excluding active) | Active window excluded |
98
+
99
+ ### Self-exclusion
100
+
101
+ CC passes `toolUseId` in MCP request `_meta` as `"claudecode/toolUseId"`.
102
+ The assistant message containing that tool_use is written to the session JSONL
103
+ before the tool is invoked (`appendFileSync`). We scan the last 32KB of candidate
104
+ session files for the ID to deterministically identify the calling session.
105
+
106
+ ### Self mode
107
+
108
+ Sends the question to N copies of the active window for sampling diversity.
109
+ Two-wave cache strategy:
110
+ 1. Wave 1: 1 query primes the cache (full input cost)
111
+ 2. Wave 2: remaining N-1 queries in batches (hit cached prefix)
112
+
113
+ ### Ancestors mode
114
+
115
+ Queries compaction windows of the calling session excluding the active window.
116
+ Returns nothing if the session has no compaction boundaries. In CC (no dfork
117
+ lineage), "ancestors" = the compacted-away context from the current session.
118
+
119
+ ## Authentication
120
+
121
+ Resolution order:
122
+ 1. Explicit apiKey/token parameter
123
+ 2. CC OAuth credentials (`~/.claude/.credentials.json`)
124
+ 3. `ANTHROPIC_API_KEY` environment variable
125
+
126
+ ## Prompt Caching
127
+
128
+ Cache breakpoints placed on:
129
+ - **System prompt**: each text block gets `cache_control: { type: "ephemeral" }`
130
+ - **Messages**: breakpoint on last content block of penultimate message
131
+
132
+ This caches the session context (stable across queries) while letting the duncan
133
+ query question (last message) vary without invalidating cache.
134
+
135
+ ## System Prompt Reconstruction
136
+
137
+ Static sections embedded verbatim from CC source:
138
+ - Identity/intro, system rules, coding instructions, careful actions,
139
+ tool usage (conditionally includes per-tool instructions like "use Read
140
+ instead of cat" based on which tools appear in the session), tone/style,
141
+ output efficiency
142
+
143
+ Dynamic sections reconstructed from session context:
144
+ - **Environment**: from session JSONL metadata (cwd, model) + local filesystem
145
+ - **CLAUDE.md**: from session's original cwd hierarchy (if paths exist)
146
+ - **Memory**: from CC project dir (`~/.claude/projects/<hash>/memory/MEMORY.md`)
147
+ - **Language**: configurable
148
+
149
+ This matches CC's own resume behavior: rebuild system prompt from current state.
150
+
151
+ Note: tool schemas are NOT included — duncan sends only its own `duncan_response`
152
+ tool. The session's original tools are not callable during a duncan query.
153
+
154
+ ## Known Gaps
155
+
156
+ ### MCP Server Instructions
157
+ CC injects MCP server `instructions` from the initialize handshake into the system
158
+ prompt. Cannot reconstruct for dormant sessions — instructions are fetched live and
159
+ not persisted to disk. Equivalent to resuming a CC session with tools disconnected.
160
+
161
+ ### Compaction Test Coverage
162
+ No real CC sessions with compaction boundaries in the test corpus (CC's 30-day
163
+ `cleanupPeriodDays` default purged older sessions before the corpus was captured).
164
+ Compaction logic is tested with synthetic fixtures only.
165
+
166
+ ## Session Storage
167
+
168
+ - **Config dir**: `~/.claude/`
169
+ - **Projects dir**: `~/.claude/projects/`
170
+ - **Project dir**: `~/.claude/projects/<hashed-cwd>/` (cwd with `/` → `-`)
171
+ - **Session file**: `<project-dir>/<session-id>.jsonl`
172
+ - **Subagent transcripts**: `<project-dir>/<session-id>/subagents/<subdir>/agent-<id>.jsonl`
173
+ - **Tool results**: `<project-dir>/<session-id>/tool-results/<id>.txt`
174
+ - **Memory**: `<project-dir>/memory/MEMORY.md`
175
+
176
+ ## MCP Server
177
+
178
+ Two tools exposed via stdio transport:
179
+
180
+ ### duncan_query
181
+ Query dormant sessions. Parameters:
182
+ - `question` (required): the question to ask
183
+ - `mode` (required): `project`, `global`, `session`, `self`, `ancestors`
184
+ - `projectDir`: for project mode
185
+ - `sessionId`: for session mode
186
+ - `cwd`: working directory context
187
+ - `limit`: max sessions/windows (default: 10)
188
+ - `offset`: pagination offset
189
+ - `copies`: for self mode, number of samples (default: 3)
190
+ - `includeSubagents`: include subagent transcripts (default: false)
191
+
192
+ ### duncan_list_sessions
193
+ List available sessions. Parameters:
194
+ - `mode` (required): `project`, `global`
195
+ - `projectDir`, `cwd`, `limit`
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@gswangg/duncan-cc",
3
+ "version": "0.1.0",
4
+ "description": "Query dormant Claude Code sessions — the Duncan Idaho approach to agent memory, for CC.",
5
+ "type": "module",
6
+ "bin": {
7
+ "duncan-cc": "./src/mcp-server.ts"
8
+ },
9
+ "scripts": {
10
+ "start": "npx tsx src/mcp-server.ts",
11
+ "test": "for t in tests/*.test.ts; do npx tsx \"$t\" || exit 1; done",
12
+ "test:parser": "npx tsx tests/parser-tree.test.ts",
13
+ "test:normalize": "npx tsx tests/normalize.test.ts",
14
+ "test:replacements": "npx tsx tests/content-replacements.test.ts",
15
+ "test:system": "npx tsx tests/system-prompt.test.ts",
16
+ "test:pipeline": "npx tsx tests/pipeline.test.ts",
17
+ "test:discovery": "npx tsx tests/discovery.test.ts",
18
+ "test:self-exclusion": "npx tsx tests/self-exclusion.test.ts"
19
+ },
20
+ "dependencies": {
21
+ "@anthropic-ai/sdk": "^0.52.0",
22
+ "@modelcontextprotocol/sdk": "^1.12.1",
23
+ "tsx": "^4.21.0"
24
+ },
25
+ "devDependencies": {
26
+ "@babel/generator": "^7.29.1",
27
+ "@babel/parser": "^7.29.2",
28
+ "@babel/traverse": "^7.29.0",
29
+ "@babel/types": "^7.29.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/gswangg/duncan-cc.git"
37
+ },
38
+ "license": "MIT"
39
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Content Replacements + Microcompact
3
+ *
4
+ * Replicates CC's content replacement and microcompact transforms.
5
+ *
6
+ * Content replacements: replace large tool_result content with persisted
7
+ * output references. The persisted outputs live in tool-results/ dirs.
8
+ *
9
+ * Microcompact: on session resume after time gap, truncate old tool results.
10
+ */
11
+
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import type { CCMessage, ParsedSession } from "./parser.js";
15
+
16
+ // ============================================================================
17
+ // Content Replacements
18
+ // ============================================================================
19
+
20
+ const PERSISTED_OUTPUT_MARKER = "<persisted-output>";
21
+
22
+ /**
23
+ * Apply content replacements to messages.
24
+ *
25
+ * Two sources:
26
+ * 1. content-replacement entries from session metadata
27
+ * 2. persisted output files on disk (tool-results/ directory)
28
+ *
29
+ * @param messages - messages to process
30
+ * @param parsed - parsed session with contentReplacements map
31
+ * @param sessionFile - path to session file (for resolving tool-results/ dir)
32
+ */
33
+ export function applyContentReplacements(
34
+ messages: CCMessage[],
35
+ parsed: ParsedSession,
36
+ sessionFile?: string,
37
+ ): CCMessage[] {
38
+ // Build replacement map from session metadata
39
+ const replacements = new Map<string, string>();
40
+ for (const [, repls] of parsed.contentReplacements) {
41
+ for (const r of repls) {
42
+ if (r.kind === "tool-result" && r.toolUseId && r.replacement) {
43
+ replacements.set(r.toolUseId, r.replacement);
44
+ }
45
+ }
46
+ }
47
+
48
+ // Also check for persisted output files on disk
49
+ const toolResultsDir = sessionFile
50
+ ? join(dirname(sessionFile), basename(sessionFile), "tool-results")
51
+ : null;
52
+
53
+ if (replacements.size === 0 && !toolResultsDir) return messages;
54
+
55
+ return messages.map((msg) => {
56
+ if (msg.type !== "user") return msg;
57
+ const content = msg.message.content;
58
+ if (!Array.isArray(content)) return msg;
59
+
60
+ let changed = false;
61
+ const newContent = content.map((block) => {
62
+ if (block.type !== "tool_result") return block;
63
+
64
+ const toolUseId = block.tool_use_id;
65
+ if (!toolUseId) return block;
66
+
67
+ // Check metadata replacements first
68
+ const replacement = replacements.get(toolUseId);
69
+ if (replacement) {
70
+ changed = true;
71
+ return { ...block, content: replacement };
72
+ }
73
+
74
+ // Check if content is a persisted-output reference that we can resolve
75
+ const blockContent = typeof block.content === "string" ? block.content : "";
76
+ if (blockContent.includes(PERSISTED_OUTPUT_MARKER) && toolResultsDir) {
77
+ const resolved = resolvePersistedOutput(toolUseId, toolResultsDir);
78
+ if (resolved) {
79
+ changed = true;
80
+ return { ...block, content: resolved };
81
+ }
82
+ }
83
+
84
+ return block;
85
+ });
86
+
87
+ if (!changed) return msg;
88
+ return { ...msg, message: { ...msg.message, content: newContent } };
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Try to resolve a persisted output from the tool-results directory.
94
+ * Files are named by tool_use_id or a hash.
95
+ */
96
+ function resolvePersistedOutput(toolUseId: string, toolResultsDir: string): string | null {
97
+ if (!existsSync(toolResultsDir)) return null;
98
+
99
+ // Try exact match first
100
+ const exactPath = join(toolResultsDir, `${toolUseId}.txt`);
101
+ if (existsSync(exactPath)) {
102
+ try {
103
+ return readFileSync(exactPath, "utf-8");
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ function basename(path: string): string {
113
+ return path.replace(/\.jsonl$/, "");
114
+ }
115
+
116
+ // ============================================================================
117
+ // Microcompact — CC's Kp() / Oe9()
118
+ // ============================================================================
119
+
120
+ const MICROCOMPACT_PLACEHOLDER = "[content truncated — tool result from previous session segment]";
121
+
122
+ /**
123
+ * Microcompact: truncate old tool results when there's a time gap.
124
+ *
125
+ * CC does this on session resume after a gap > threshold minutes.
126
+ * For duncan, we apply it based on the time gap between the last
127
+ * assistant message and the current time (or a specified reference time).
128
+ *
129
+ * @param messages - messages to process (post-normalization)
130
+ * @param gapThresholdMinutes - minutes of gap to trigger microcompact (default: 30)
131
+ * @param keepRecentTurns - number of recent turns to keep intact (default: 1)
132
+ */
133
+ export function microcompact(
134
+ messages: CCMessage[],
135
+ gapThresholdMinutes: number = 30,
136
+ keepRecentTurns: number = 1,
137
+ ): CCMessage[] {
138
+ // Find the last assistant message
139
+ const lastAssistant = [...messages].reverse().find((m) => m.type === "assistant");
140
+ if (!lastAssistant) return messages;
141
+
142
+ const lastTime = Date.parse(lastAssistant.timestamp);
143
+ const now = Date.now();
144
+ const gapMinutes = (now - lastTime) / 60000;
145
+
146
+ if (!Number.isFinite(gapMinutes) || gapMinutes < gapThresholdMinutes) {
147
+ return messages;
148
+ }
149
+
150
+ // Identify tool_use IDs from recent turns to keep
151
+ const recentToolUseIds = new Set<string>();
152
+ const assistantMessages = messages.filter((m) => m.type === "assistant");
153
+ const recentAssistants = assistantMessages.slice(-keepRecentTurns);
154
+
155
+ for (const msg of recentAssistants) {
156
+ const content = msg.message.content;
157
+ if (!Array.isArray(content)) continue;
158
+ for (const block of content) {
159
+ if (block.type === "tool_use" && block.id) {
160
+ recentToolUseIds.add(block.id);
161
+ }
162
+ }
163
+ }
164
+
165
+ // Truncate old tool results
166
+ return messages.map((msg) => {
167
+ if (msg.type !== "user") return msg;
168
+ const content = msg.message.content;
169
+ if (!Array.isArray(content)) return msg;
170
+
171
+ let changed = false;
172
+ const newContent = content.map((block) => {
173
+ if (block.type !== "tool_result") return block;
174
+ if (recentToolUseIds.has(block.tool_use_id)) return block;
175
+ // Already truncated
176
+ if (block.content === MICROCOMPACT_PLACEHOLDER) return block;
177
+
178
+ changed = true;
179
+ return { ...block, content: MICROCOMPACT_PLACEHOLDER };
180
+ });
181
+
182
+ if (!changed) return msg;
183
+ return { ...msg, message: { ...msg.message, content: newContent } };
184
+ });
185
+ }