@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/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
|
+
}
|