@gswangg/duncan-cc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/SPEC.md +195 -0
- package/package.json +39 -0
- package/src/content-replacements.ts +185 -0
- package/src/discovery.ts +340 -0
- package/src/mcp-server.ts +356 -0
- package/src/normalize.ts +702 -0
- package/src/parser.ts +257 -0
- package/src/pipeline.ts +274 -0
- package/src/query.ts +626 -0
- package/src/system-prompt.ts +408 -0
- package/src/tree.ts +371 -0
- package/tests/_skip-if-no-corpus.ts +12 -0
- package/tests/compaction.test.ts +205 -0
- package/tests/content-replacements.test.ts +214 -0
- package/tests/discovery.test.ts +129 -0
- package/tests/normalize.test.ts +192 -0
- package/tests/parity.test.ts +226 -0
- package/tests/parser-tree.test.ts +268 -0
- package/tests/pipeline.test.ts +174 -0
- package/tests/self-exclusion.test.ts +272 -0
- package/tests/system-prompt.test.ts +238 -0
- package/tsconfig.json +14 -0
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CC Message Normalization
|
|
3
|
+
*
|
|
4
|
+
* Converts internal CC message format to API-compatible format.
|
|
5
|
+
* Handles: filtering, type conversion, merging, attachment conversion,
|
|
6
|
+
* and 8 post-normalization transforms matching CC 2.1.85's chain:
|
|
7
|
+
*
|
|
8
|
+
* Pipeline: reorder-attachments → filter → type-switch → post-transforms (8 steps)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CCMessage } from "./parser.js";
|
|
12
|
+
import { isApiErrorMessage, isCompactBoundary, isLocalCommand } from "./parser.js";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Helpers
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/** Make a synthetic user message */
|
|
19
|
+
function makeUserMessage(content: string | any[], opts: Partial<CCMessage> = {}): CCMessage {
|
|
20
|
+
return {
|
|
21
|
+
type: "user",
|
|
22
|
+
uuid: opts.uuid ?? crypto.randomUUID(),
|
|
23
|
+
parentUuid: null,
|
|
24
|
+
timestamp: opts.timestamp ?? new Date().toISOString(),
|
|
25
|
+
isMeta: opts.isMeta ?? true,
|
|
26
|
+
message: {
|
|
27
|
+
role: "user",
|
|
28
|
+
content: content,
|
|
29
|
+
},
|
|
30
|
+
...opts,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Normalize content to array form */
|
|
35
|
+
function toContentArray(content: string | any[]): any[] {
|
|
36
|
+
if (typeof content === "string") {
|
|
37
|
+
return [{ type: "text", text: content }];
|
|
38
|
+
}
|
|
39
|
+
return content;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Merge two user messages */
|
|
43
|
+
function mergeUsers(a: CCMessage, b: CCMessage): CCMessage {
|
|
44
|
+
const aContent = toContentArray(a.message.content);
|
|
45
|
+
const bContent = toContentArray(b.message.content);
|
|
46
|
+
|
|
47
|
+
// CC's uyq: tool_results first, then other content
|
|
48
|
+
const toolResults = [...aContent, ...bContent].filter((c) => c.type === "tool_result");
|
|
49
|
+
const other = [...aContent, ...bContent].filter((c) => c.type !== "tool_result");
|
|
50
|
+
const merged = [...toolResults, ...other];
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...a,
|
|
54
|
+
uuid: a.isMeta ? b.uuid : a.uuid,
|
|
55
|
+
message: {
|
|
56
|
+
...a.message,
|
|
57
|
+
content: merged,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Check if a content block is a tool_reference — CC's bx() */
|
|
63
|
+
function isToolReference(block: any): boolean {
|
|
64
|
+
return block?.type === "tool_reference" || block?.type === "server_tool_use";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Last element of array */
|
|
68
|
+
function last<T>(arr: T[]): T | undefined {
|
|
69
|
+
return arr[arr.length - 1];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Check if content is thinking-only — CC's St1() */
|
|
73
|
+
function isThinkingBlock(block: any): boolean {
|
|
74
|
+
return block?.type === "thinking" || block?.type === "redacted_thinking";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Check if all content blocks are whitespace-only text — CC's djY() */
|
|
78
|
+
function isWhitespaceOnly(content: any[]): boolean {
|
|
79
|
+
if (content.length === 0) return false;
|
|
80
|
+
return content.every(
|
|
81
|
+
(c) => c.type === "text" && (c.text === undefined || c.text.trim() === "")
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Attachment conversion
|
|
87
|
+
//
|
|
88
|
+
// For duncan queries, we do a simplified version that preserves the
|
|
89
|
+
// semantic content without needing the full tool definitions.
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
function convertAttachment(msg: CCMessage): CCMessage[] {
|
|
93
|
+
const attachment = msg.attachment;
|
|
94
|
+
if (!attachment) return [];
|
|
95
|
+
|
|
96
|
+
switch (attachment.type) {
|
|
97
|
+
case "directory":
|
|
98
|
+
return [
|
|
99
|
+
makeUserMessage(
|
|
100
|
+
`Called the Bash tool with the following input: ${JSON.stringify({ command: `ls ${attachment.path}` })}`,
|
|
101
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
102
|
+
),
|
|
103
|
+
makeUserMessage(
|
|
104
|
+
`Result of calling the Bash tool: ${JSON.stringify({ stdout: attachment.content, stderr: "", interrupted: false })}`,
|
|
105
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
106
|
+
),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
case "file": {
|
|
110
|
+
const content = attachment.content;
|
|
111
|
+
if (content?.type === "image") {
|
|
112
|
+
return [makeUserMessage(
|
|
113
|
+
Array.isArray(content.content) ? content.content : [content],
|
|
114
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
115
|
+
)];
|
|
116
|
+
}
|
|
117
|
+
// text, notebook, pdf
|
|
118
|
+
const text = typeof content === "string"
|
|
119
|
+
? content
|
|
120
|
+
: content?.text ?? content?.content ?? JSON.stringify(content);
|
|
121
|
+
return [makeUserMessage(
|
|
122
|
+
`Result of calling the Read tool: ${text}`,
|
|
123
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
124
|
+
)];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "edited_text_file":
|
|
128
|
+
return [makeUserMessage(
|
|
129
|
+
`Note: ${attachment.filename} was modified, either by the user or by a linter. Here are the relevant changes:\n${attachment.snippet}`,
|
|
130
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
131
|
+
)];
|
|
132
|
+
|
|
133
|
+
case "selected_lines_in_ide": {
|
|
134
|
+
const content = attachment.content?.length > 2000
|
|
135
|
+
? attachment.content.substring(0, 2000) + "\n... (truncated)"
|
|
136
|
+
: attachment.content;
|
|
137
|
+
return [makeUserMessage(
|
|
138
|
+
`The user selected lines ${attachment.lineStart} to ${attachment.lineEnd} from ${attachment.filename}:\n${content}\n\nThis may or may not be related to the current task.`,
|
|
139
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
140
|
+
)];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case "opened_file_in_ide":
|
|
144
|
+
return [makeUserMessage(
|
|
145
|
+
`The user opened the file ${attachment.filename} in the IDE. This may or may not be related to the current task.`,
|
|
146
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
147
|
+
)];
|
|
148
|
+
|
|
149
|
+
case "compact_file_reference":
|
|
150
|
+
return [makeUserMessage(
|
|
151
|
+
`Note: ${attachment.filename} was read before the last conversation was summarized, but the contents are too large to include. Use Read tool if you need to access it.`,
|
|
152
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
153
|
+
)];
|
|
154
|
+
|
|
155
|
+
case "plan_file_reference":
|
|
156
|
+
return [makeUserMessage(
|
|
157
|
+
`A plan file exists from plan mode at: ${attachment.planFilePath}\n\nPlan contents:\n\n${attachment.planContent}`,
|
|
158
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
159
|
+
)];
|
|
160
|
+
|
|
161
|
+
case "invoked_skills": {
|
|
162
|
+
if (!attachment.skills?.length) return [];
|
|
163
|
+
const skillsText = attachment.skills
|
|
164
|
+
.map((s: any) => `### Skill: ${s.name}\nPath: ${s.path}\n\n${s.content}`)
|
|
165
|
+
.join("\n\n---\n\n");
|
|
166
|
+
return [makeUserMessage(
|
|
167
|
+
`The following skills were invoked in this session:\n\n${skillsText}`,
|
|
168
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
169
|
+
)];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case "pdf_reference":
|
|
173
|
+
return [makeUserMessage(
|
|
174
|
+
`PDF file: ${attachment.filename} (${attachment.pageCount} pages, ${attachment.fileSize}). Use the Read tool with pages parameter to read specific page ranges.`,
|
|
175
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
176
|
+
)];
|
|
177
|
+
|
|
178
|
+
case "teammate_mailbox":
|
|
179
|
+
case "team_context":
|
|
180
|
+
// Simplified: include the raw content
|
|
181
|
+
return [makeUserMessage(
|
|
182
|
+
JSON.stringify(attachment),
|
|
183
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
184
|
+
)];
|
|
185
|
+
|
|
186
|
+
case "todo_reminder": {
|
|
187
|
+
if (!attachment.content?.length) return [];
|
|
188
|
+
const todos = attachment.content
|
|
189
|
+
.map((t: any, i: number) => `${i + 1}. [${t.status}] ${t.text}`)
|
|
190
|
+
.join("\n");
|
|
191
|
+
return [makeUserMessage(
|
|
192
|
+
`Active todos:\n${todos}`,
|
|
193
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
194
|
+
)];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
default:
|
|
198
|
+
// Unknown attachment type — include as JSON
|
|
199
|
+
return [makeUserMessage(
|
|
200
|
+
`[Attachment: ${attachment.type}]\n${JSON.stringify(attachment)}`,
|
|
201
|
+
{ isMeta: true, timestamp: msg.timestamp },
|
|
202
|
+
)];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// Pre-step: Reorder attachments adjacent to referencing messages
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
function reorderAttachments(messages: CCMessage[]): CCMessage[] {
|
|
211
|
+
const result: CCMessage[] = [];
|
|
212
|
+
const pendingAttachments: CCMessage[] = [];
|
|
213
|
+
|
|
214
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
215
|
+
const msg = messages[i];
|
|
216
|
+
if (msg.type === "attachment") {
|
|
217
|
+
pendingAttachments.push(msg);
|
|
218
|
+
} else if (
|
|
219
|
+
(msg.type === "assistant" ||
|
|
220
|
+
(msg.type === "user" &&
|
|
221
|
+
Array.isArray(msg.message.content) &&
|
|
222
|
+
msg.message.content[0]?.type === "tool_result")) &&
|
|
223
|
+
pendingAttachments.length > 0
|
|
224
|
+
) {
|
|
225
|
+
for (const att of pendingAttachments) result.push(att);
|
|
226
|
+
result.push(msg);
|
|
227
|
+
pendingAttachments.length = 0;
|
|
228
|
+
} else {
|
|
229
|
+
result.push(msg);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Remaining attachments
|
|
233
|
+
for (const att of pendingAttachments) result.push(att);
|
|
234
|
+
|
|
235
|
+
return result.reverse();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ============================================================================
|
|
239
|
+
// Strip tool references from user messages — CC's It1()
|
|
240
|
+
// ============================================================================
|
|
241
|
+
|
|
242
|
+
function stripToolReferences(msg: CCMessage): CCMessage {
|
|
243
|
+
const content = msg.message.content;
|
|
244
|
+
if (!Array.isArray(content)) return msg;
|
|
245
|
+
|
|
246
|
+
const hasRefs = content.some(
|
|
247
|
+
(c) =>
|
|
248
|
+
c.type === "tool_result" &&
|
|
249
|
+
Array.isArray(c.content) &&
|
|
250
|
+
c.content.some(isToolReference)
|
|
251
|
+
);
|
|
252
|
+
if (!hasRefs) return msg;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
...msg,
|
|
256
|
+
message: {
|
|
257
|
+
...msg.message,
|
|
258
|
+
content: content.map((c) => {
|
|
259
|
+
if (c.type !== "tool_result" || !Array.isArray(c.content)) return c;
|
|
260
|
+
const filtered = c.content.filter((b: any) => !isToolReference(b));
|
|
261
|
+
if (filtered.length === 0)
|
|
262
|
+
return { ...c, content: [{ type: "text", text: "[Tool references removed]" }] };
|
|
263
|
+
return { ...c, content: filtered };
|
|
264
|
+
}),
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// Main normalization
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
export function normalizeMessages(messages: CCMessage[]): CCMessage[] {
|
|
274
|
+
// Pre-step: reorder attachments
|
|
275
|
+
const reordered = reorderAttachments(messages);
|
|
276
|
+
|
|
277
|
+
// Filter and convert
|
|
278
|
+
const filtered = reordered.filter((msg) => {
|
|
279
|
+
// Remove progress messages
|
|
280
|
+
if (msg.type === "progress") return false;
|
|
281
|
+
// Remove non-local-command system messages (including compact boundaries)
|
|
282
|
+
if (msg.type === "system" && !isLocalCommand(msg)) return false;
|
|
283
|
+
// Remove API error messages
|
|
284
|
+
if (isApiErrorMessage(msg)) return false;
|
|
285
|
+
return true;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const result: CCMessage[] = [];
|
|
289
|
+
|
|
290
|
+
for (const msg of filtered) {
|
|
291
|
+
switch (msg.type) {
|
|
292
|
+
case "system": {
|
|
293
|
+
// Only local_command system messages reach here
|
|
294
|
+
// Convert to user message
|
|
295
|
+
const userMsg = makeUserMessage(msg.content ?? "", {
|
|
296
|
+
uuid: msg.uuid,
|
|
297
|
+
timestamp: msg.timestamp,
|
|
298
|
+
});
|
|
299
|
+
const prev = last(result);
|
|
300
|
+
if (prev?.type === "user") {
|
|
301
|
+
result[result.length - 1] = mergeUsers(prev, userMsg);
|
|
302
|
+
} else {
|
|
303
|
+
result.push(userMsg);
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case "user": {
|
|
309
|
+
// Strip tool references
|
|
310
|
+
const stripped = stripToolReferences(msg);
|
|
311
|
+
const prev = last(result);
|
|
312
|
+
if (prev?.type === "user") {
|
|
313
|
+
result[result.length - 1] = mergeUsers(prev, stripped);
|
|
314
|
+
} else {
|
|
315
|
+
result.push(stripped);
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case "assistant": {
|
|
321
|
+
// Remap tool names (simplified: keep as-is for duncan)
|
|
322
|
+
// Merge split assistant messages (same message.id)
|
|
323
|
+
const prev = last(result);
|
|
324
|
+
if (
|
|
325
|
+
prev?.type === "assistant" &&
|
|
326
|
+
prev.message.id &&
|
|
327
|
+
msg.message.id &&
|
|
328
|
+
prev.message.id === msg.message.id
|
|
329
|
+
) {
|
|
330
|
+
// Merge content arrays
|
|
331
|
+
result[result.length - 1] = {
|
|
332
|
+
...prev,
|
|
333
|
+
message: {
|
|
334
|
+
...prev.message,
|
|
335
|
+
content: [
|
|
336
|
+
...(Array.isArray(prev.message.content) ? prev.message.content : []),
|
|
337
|
+
...(Array.isArray(msg.message.content) ? msg.message.content : []),
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
} else {
|
|
342
|
+
result.push(msg);
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case "attachment": {
|
|
348
|
+
const converted = convertAttachment(msg);
|
|
349
|
+
for (const convMsg of converted) {
|
|
350
|
+
const prev = last(result);
|
|
351
|
+
if (prev?.type === "user") {
|
|
352
|
+
result[result.length - 1] = mergeUsers(prev, convMsg);
|
|
353
|
+
} else {
|
|
354
|
+
result.push(convMsg);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Post-transforms (8 steps matching CC pipeline)
|
|
363
|
+
let normalized = result;
|
|
364
|
+
normalized = relocateDeferredToolRefText(normalized);
|
|
365
|
+
normalized = filterOrphanedThinking(normalized);
|
|
366
|
+
normalized = removeTrailingThinking(normalized);
|
|
367
|
+
normalized = removeWhitespaceAssistant(normalized);
|
|
368
|
+
normalized = fixEmptyAssistantContent(normalized);
|
|
369
|
+
normalized = reorderSystemReminders(normalized);
|
|
370
|
+
// re-merge consecutive users is inlined in removeWhitespaceAssistant
|
|
371
|
+
normalized = flattenErrorToolResults(normalized);
|
|
372
|
+
normalized = fixOrphanedToolUse(normalized); // ensure every tool_use has a tool_result
|
|
373
|
+
|
|
374
|
+
return normalized;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ============================================================================
|
|
378
|
+
// Post-transform 1: Filter orphaned thinking-only assistant messages
|
|
379
|
+
// ============================================================================
|
|
380
|
+
|
|
381
|
+
function filterOrphanedThinking(messages: CCMessage[]): CCMessage[] {
|
|
382
|
+
// Collect message IDs that have non-thinking content
|
|
383
|
+
const hasNonThinking = new Set<string>();
|
|
384
|
+
for (const msg of messages) {
|
|
385
|
+
if (msg.type !== "assistant") continue;
|
|
386
|
+
const content = msg.message.content;
|
|
387
|
+
if (!Array.isArray(content)) continue;
|
|
388
|
+
if (content.some((c) => !isThinkingBlock(c)) && msg.message.id) {
|
|
389
|
+
hasNonThinking.add(msg.message.id);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return messages.filter((msg) => {
|
|
394
|
+
if (msg.type !== "assistant") return true;
|
|
395
|
+
const content = msg.message.content;
|
|
396
|
+
if (!Array.isArray(content) || content.length === 0) return true;
|
|
397
|
+
// Keep if has non-thinking content
|
|
398
|
+
if (!content.every(isThinkingBlock)) return true;
|
|
399
|
+
// Keep if another message with same ID has non-thinking content
|
|
400
|
+
if (msg.message.id && hasNonThinking.has(msg.message.id)) return true;
|
|
401
|
+
// Filter out
|
|
402
|
+
return false;
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// Post-transform 2: Remove trailing thinking from last assistant
|
|
408
|
+
// ============================================================================
|
|
409
|
+
|
|
410
|
+
function removeTrailingThinking(messages: CCMessage[]): CCMessage[] {
|
|
411
|
+
if (messages.length === 0) return messages;
|
|
412
|
+
const lastMsg = messages[messages.length - 1];
|
|
413
|
+
if (lastMsg.type !== "assistant") return messages;
|
|
414
|
+
|
|
415
|
+
const content = lastMsg.message.content;
|
|
416
|
+
if (!Array.isArray(content)) return messages;
|
|
417
|
+
|
|
418
|
+
// Find last non-thinking index
|
|
419
|
+
const lastBlock = content[content.length - 1];
|
|
420
|
+
if (!lastBlock || !isThinkingBlock(lastBlock)) return messages;
|
|
421
|
+
|
|
422
|
+
let lastNonThinking = content.length - 1;
|
|
423
|
+
while (lastNonThinking >= 0 && isThinkingBlock(content[lastNonThinking])) {
|
|
424
|
+
lastNonThinking--;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const trimmed =
|
|
428
|
+
lastNonThinking < 0
|
|
429
|
+
? [{ type: "text", text: "[No message content]", citations: [] }]
|
|
430
|
+
: content.slice(0, lastNonThinking + 1);
|
|
431
|
+
|
|
432
|
+
const result = [...messages];
|
|
433
|
+
result[messages.length - 1] = {
|
|
434
|
+
...lastMsg,
|
|
435
|
+
message: { ...lastMsg.message, content: trimmed },
|
|
436
|
+
};
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ============================================================================
|
|
441
|
+
// Post-transform 3: Remove whitespace-only assistant messages
|
|
442
|
+
// ============================================================================
|
|
443
|
+
|
|
444
|
+
function removeWhitespaceAssistant(messages: CCMessage[]): CCMessage[] {
|
|
445
|
+
let hasRemoval = false;
|
|
446
|
+
const filtered = messages.filter((msg) => {
|
|
447
|
+
if (msg.type !== "assistant") return true;
|
|
448
|
+
const content = msg.message.content;
|
|
449
|
+
if (!Array.isArray(content) || content.length === 0) return true;
|
|
450
|
+
if (isWhitespaceOnly(content)) {
|
|
451
|
+
hasRemoval = true;
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
if (!hasRemoval) return messages;
|
|
458
|
+
|
|
459
|
+
// Merge resulting adjacent user messages
|
|
460
|
+
const merged: CCMessage[] = [];
|
|
461
|
+
for (const msg of filtered) {
|
|
462
|
+
const prev = last(merged);
|
|
463
|
+
if (msg.type === "user" && prev?.type === "user") {
|
|
464
|
+
merged[merged.length - 1] = mergeUsers(prev, msg);
|
|
465
|
+
} else {
|
|
466
|
+
merged.push(msg);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return merged;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ============================================================================
|
|
473
|
+
// Post-transform 4: Fix empty assistant content
|
|
474
|
+
// ============================================================================
|
|
475
|
+
|
|
476
|
+
function fixEmptyAssistantContent(messages: CCMessage[]): CCMessage[] {
|
|
477
|
+
const PLACEHOLDER = "[No message content]";
|
|
478
|
+
|
|
479
|
+
return messages.map((msg, i) => {
|
|
480
|
+
if (msg.type !== "assistant") return msg;
|
|
481
|
+
// Don't fix the last message
|
|
482
|
+
if (i === messages.length - 1) return msg;
|
|
483
|
+
const content = msg.message.content;
|
|
484
|
+
if (Array.isArray(content) && content.length === 0) {
|
|
485
|
+
return {
|
|
486
|
+
...msg,
|
|
487
|
+
message: {
|
|
488
|
+
...msg.message,
|
|
489
|
+
content: [{ type: "text", text: PLACEHOLDER, citations: [] }],
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return msg;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ============================================================================
|
|
498
|
+
// Post-transform 5: Relocate deferred tool_reference text
|
|
499
|
+
// Moves text blocks from user messages that contain tool_references into the
|
|
500
|
+
// next user message that has tool_results (but no tool_references itself).
|
|
501
|
+
// This keeps reference context adjacent to the tool output it describes.
|
|
502
|
+
// ============================================================================
|
|
503
|
+
|
|
504
|
+
function hasToolReferences(content: any[]): boolean {
|
|
505
|
+
return content.some((c: any) => c.type === "tool_reference");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function relocateDeferredToolRefText(messages: CCMessage[]): CCMessage[] {
|
|
509
|
+
const result = [...messages];
|
|
510
|
+
|
|
511
|
+
for (let i = 0; i < result.length; i++) {
|
|
512
|
+
const msg = result[i];
|
|
513
|
+
if (msg.type !== "user") continue;
|
|
514
|
+
const content = msg.message.content;
|
|
515
|
+
if (!Array.isArray(content)) continue;
|
|
516
|
+
if (!hasToolReferences(content)) continue;
|
|
517
|
+
|
|
518
|
+
const textBlocks = content.filter((c: any) => c.type === "text");
|
|
519
|
+
if (textBlocks.length === 0) continue;
|
|
520
|
+
|
|
521
|
+
// Find the next user message with tool_results but without tool_references
|
|
522
|
+
let targetIdx = -1;
|
|
523
|
+
for (let j = i + 1; j < result.length; j++) {
|
|
524
|
+
const candidate = result[j];
|
|
525
|
+
if (candidate.type !== "user") continue;
|
|
526
|
+
const cc = candidate.message.content;
|
|
527
|
+
if (!Array.isArray(cc)) continue;
|
|
528
|
+
if (!cc.some((c: any) => c.type === "tool_result")) continue;
|
|
529
|
+
if (hasToolReferences(cc)) continue;
|
|
530
|
+
targetIdx = j;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (targetIdx === -1) continue;
|
|
535
|
+
|
|
536
|
+
// Move text blocks from source to target
|
|
537
|
+
result[i] = {
|
|
538
|
+
...msg,
|
|
539
|
+
message: {
|
|
540
|
+
...msg.message,
|
|
541
|
+
content: content.filter((c: any) => c.type !== "text"),
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const target = result[targetIdx];
|
|
546
|
+
result[targetIdx] = {
|
|
547
|
+
...target,
|
|
548
|
+
message: {
|
|
549
|
+
...target.message,
|
|
550
|
+
content: [...target.message.content, ...textBlocks],
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ============================================================================
|
|
559
|
+
// Post-transform 6: Reorder system-reminder blocks in tool_results
|
|
560
|
+
// Moves <system-reminder> text blocks from user messages into the last
|
|
561
|
+
// tool_result in that same message, keeping them adjacent to tool output.
|
|
562
|
+
// ============================================================================
|
|
563
|
+
|
|
564
|
+
function reorderSystemReminders(messages: CCMessage[]): CCMessage[] {
|
|
565
|
+
return messages.map((msg) => {
|
|
566
|
+
if (msg.type !== "user") return msg;
|
|
567
|
+
const content = msg.message.content;
|
|
568
|
+
if (!Array.isArray(content)) return msg;
|
|
569
|
+
if (!content.some((c: any) => c.type === "tool_result")) return msg;
|
|
570
|
+
|
|
571
|
+
// Separate system-reminder text blocks from everything else
|
|
572
|
+
const reminders: any[] = [];
|
|
573
|
+
const rest: any[] = [];
|
|
574
|
+
for (const block of content) {
|
|
575
|
+
if (
|
|
576
|
+
block.type === "text" &&
|
|
577
|
+
typeof block.text === "string" &&
|
|
578
|
+
block.text.startsWith("<system-reminder>")
|
|
579
|
+
) {
|
|
580
|
+
reminders.push(block);
|
|
581
|
+
} else {
|
|
582
|
+
rest.push(block);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (reminders.length === 0) return msg;
|
|
587
|
+
|
|
588
|
+
// Find the last tool_result and inject reminders into its content
|
|
589
|
+
const lastToolResultIdx = rest.map((c: any) => c.type).lastIndexOf("tool_result");
|
|
590
|
+
if (lastToolResultIdx === -1) return msg;
|
|
591
|
+
|
|
592
|
+
const lastToolResult = rest[lastToolResultIdx];
|
|
593
|
+
const existingContent = Array.isArray(lastToolResult.content)
|
|
594
|
+
? lastToolResult.content
|
|
595
|
+
: typeof lastToolResult.content === "string"
|
|
596
|
+
? [{ type: "text", text: lastToolResult.content }]
|
|
597
|
+
: [];
|
|
598
|
+
|
|
599
|
+
const updated = {
|
|
600
|
+
...lastToolResult,
|
|
601
|
+
content: [...existingContent, ...reminders],
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const newContent = [...rest.slice(0, lastToolResultIdx), updated, ...rest.slice(lastToolResultIdx + 1)];
|
|
605
|
+
return {
|
|
606
|
+
...msg,
|
|
607
|
+
message: { ...msg.message, content: newContent },
|
|
608
|
+
};
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// Post-transform 7: Flatten error tool_results
|
|
614
|
+
// Error tool_results that contain non-text blocks (images, etc.) get stripped
|
|
615
|
+
// to text-only content. Prevents sending binary content in error responses.
|
|
616
|
+
// ============================================================================
|
|
617
|
+
|
|
618
|
+
function flattenErrorToolResults(messages: CCMessage[]): CCMessage[] {
|
|
619
|
+
return messages.map((msg) => {
|
|
620
|
+
if (msg.type !== "user") return msg;
|
|
621
|
+
const content = msg.message.content;
|
|
622
|
+
if (!Array.isArray(content)) return msg;
|
|
623
|
+
|
|
624
|
+
let changed = false;
|
|
625
|
+
const newContent = content.map((block: any) => {
|
|
626
|
+
if (block.type !== "tool_result" || !block.is_error) return block;
|
|
627
|
+
const inner = block.content;
|
|
628
|
+
if (!Array.isArray(inner)) return block;
|
|
629
|
+
// If all content is text, leave it alone
|
|
630
|
+
if (inner.every((c: any) => c.type === "text")) return block;
|
|
631
|
+
|
|
632
|
+
changed = true;
|
|
633
|
+
const textParts = inner.filter((c: any) => c.type === "text").map((c: any) => c.text);
|
|
634
|
+
return {
|
|
635
|
+
...block,
|
|
636
|
+
content: textParts.length > 0 ? [{ type: "text", text: textParts.join("\n\n") }] : [],
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
if (!changed) return msg;
|
|
641
|
+
return { ...msg, message: { ...msg.message, content: newContent } };
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ============================================================================
|
|
646
|
+
// Post-transform 8: Fix orphaned tool_use blocks
|
|
647
|
+
// Ensure every tool_use in an assistant message has a corresponding tool_result
|
|
648
|
+
// in the following user message. Insert synthetic results for missing ones.
|
|
649
|
+
// ============================================================================
|
|
650
|
+
|
|
651
|
+
function fixOrphanedToolUse(messages: CCMessage[]): CCMessage[] {
|
|
652
|
+
const result: CCMessage[] = [];
|
|
653
|
+
|
|
654
|
+
for (let i = 0; i < messages.length; i++) {
|
|
655
|
+
result.push(messages[i]);
|
|
656
|
+
|
|
657
|
+
const msg = messages[i];
|
|
658
|
+
if (msg.type !== "assistant" || !Array.isArray(msg.message.content)) continue;
|
|
659
|
+
|
|
660
|
+
const toolUses = msg.message.content.filter((c: any) => c.type === "tool_use");
|
|
661
|
+
if (toolUses.length === 0) continue;
|
|
662
|
+
|
|
663
|
+
// Check next message for matching tool_results
|
|
664
|
+
const next = messages[i + 1];
|
|
665
|
+
const nextContent = (next?.type === "user" && Array.isArray(next?.message?.content))
|
|
666
|
+
? next.message.content
|
|
667
|
+
: [];
|
|
668
|
+
const existingResultIds = new Set(
|
|
669
|
+
nextContent.filter((c: any) => c.type === "tool_result").map((c: any) => c.tool_use_id)
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
const orphaned = toolUses.filter((tu: any) => !existingResultIds.has(tu.id));
|
|
673
|
+
if (orphaned.length === 0) continue;
|
|
674
|
+
|
|
675
|
+
// Build synthetic tool_result blocks
|
|
676
|
+
const syntheticResults = orphaned.map((tu: any) => ({
|
|
677
|
+
type: "tool_result",
|
|
678
|
+
tool_use_id: tu.id,
|
|
679
|
+
content: "[Tool execution interrupted]",
|
|
680
|
+
is_error: true,
|
|
681
|
+
}));
|
|
682
|
+
|
|
683
|
+
if (next?.type === "user" && Array.isArray(next?.message?.content)) {
|
|
684
|
+
// Inject into the existing user message
|
|
685
|
+
messages[i + 1] = {
|
|
686
|
+
...next,
|
|
687
|
+
message: {
|
|
688
|
+
...next.message,
|
|
689
|
+
content: [...syntheticResults, ...nextContent],
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
} else {
|
|
693
|
+
// Insert a new user message with the synthetic results
|
|
694
|
+
result.push(makeUserMessage(syntheticResults, {
|
|
695
|
+
timestamp: msg.timestamp,
|
|
696
|
+
isMeta: true,
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return result;
|
|
702
|
+
}
|