@crewhaus/compaction-snip 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/package.json +41 -0
- package/src/index.test.ts +159 -0
- package/src/index.ts +147 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/compaction-snip",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pure middle-message removal with tool-use/result orphan defense",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@anthropic-ai/sdk": "^0.96.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/compaction-snip"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/compaction-snip#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type Anthropic from "@anthropic-ai/sdk";
|
|
3
|
+
import { snip } from "./index";
|
|
4
|
+
|
|
5
|
+
function userMsg(text: string): Anthropic.MessageParam {
|
|
6
|
+
return { role: "user", content: text };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function asstMsg(text: string): Anthropic.MessageParam {
|
|
10
|
+
return { role: "assistant", content: text };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("snip", () => {
|
|
14
|
+
test("returns a copy unchanged when input fits in head+tail", () => {
|
|
15
|
+
const messages = [userMsg("a"), asstMsg("b"), userMsg("c")];
|
|
16
|
+
const out = snip(messages, 2, 2);
|
|
17
|
+
expect(out).toEqual(messages);
|
|
18
|
+
expect(out).not.toBe(messages); // copy, not the same reference
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("removes middle and inserts marker with correct N", () => {
|
|
22
|
+
const messages = [
|
|
23
|
+
userMsg("u1"),
|
|
24
|
+
asstMsg("a1"),
|
|
25
|
+
userMsg("u2"),
|
|
26
|
+
asstMsg("a2"),
|
|
27
|
+
userMsg("u3"),
|
|
28
|
+
asstMsg("a3"),
|
|
29
|
+
];
|
|
30
|
+
// keepHead=2, keepTail=2 → drop 2 middle messages
|
|
31
|
+
const out = snip(messages, 2, 2);
|
|
32
|
+
expect(out.length).toBe(5);
|
|
33
|
+
expect(out[0]).toEqual(userMsg("u1"));
|
|
34
|
+
expect(out[1]).toEqual(asstMsg("a1"));
|
|
35
|
+
expect(out[2]).toEqual({
|
|
36
|
+
role: "assistant",
|
|
37
|
+
content: "[Context compacted: 2 messages removed]",
|
|
38
|
+
});
|
|
39
|
+
expect(out[3]).toEqual(userMsg("u3"));
|
|
40
|
+
expect(out[4]).toEqual(asstMsg("a3"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("marker text reports the correct N", () => {
|
|
44
|
+
const messages = Array.from({ length: 10 }, (_, i) => userMsg(`m${i}`));
|
|
45
|
+
const out = snip(messages, 1, 1);
|
|
46
|
+
// 10 - 1 - 1 = 8 removed
|
|
47
|
+
expect(out[1]).toEqual({
|
|
48
|
+
role: "assistant",
|
|
49
|
+
content: "[Context compacted: 8 messages removed]",
|
|
50
|
+
});
|
|
51
|
+
expect(out.length).toBe(3);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not mutate the input array", () => {
|
|
55
|
+
const messages = [userMsg("a"), userMsg("b"), userMsg("c"), userMsg("d")];
|
|
56
|
+
const len = messages.length;
|
|
57
|
+
snip(messages, 1, 1);
|
|
58
|
+
expect(messages.length).toBe(len);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("rejects negative keepHead/keepTail", () => {
|
|
62
|
+
expect(() => snip([], -1, 1)).toThrow(RangeError);
|
|
63
|
+
expect(() => snip([], 1, -1)).toThrow(RangeError);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("orphan defense: pulls tailStart back to keep tool_use's result", () => {
|
|
67
|
+
// u(0), a:tool_use tu_1 (1), u:tool_result tu_1 (2), a(3), u(4), a(5), u(6), a(7)
|
|
68
|
+
// Naive snip with keepHead=2, keepTail=2 cuts [2..6), orphaning tu_1's result at index 2.
|
|
69
|
+
const messages: Anthropic.MessageParam[] = [
|
|
70
|
+
userMsg("u0"),
|
|
71
|
+
{
|
|
72
|
+
role: "assistant",
|
|
73
|
+
content: [{ type: "tool_use", id: "tu_1", name: "Bash", input: { cmd: "ls" } }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
role: "user",
|
|
77
|
+
content: [{ type: "tool_result", tool_use_id: "tu_1", content: "ok" }],
|
|
78
|
+
},
|
|
79
|
+
asstMsg("a3"),
|
|
80
|
+
userMsg("u4"),
|
|
81
|
+
asstMsg("a5"),
|
|
82
|
+
userMsg("u6"),
|
|
83
|
+
asstMsg("a7"),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const out = snip(messages, 2, 2);
|
|
87
|
+
// Boundary should pull back so tool_result for tu_1 (index 2) is kept inside head.
|
|
88
|
+
// After adjustment headEnd should be at least 3, tailStart at least 6.
|
|
89
|
+
// Result must contain the tool_use at index 1 AND its matching tool_result at index 2.
|
|
90
|
+
const flat = out.flatMap((m) => (typeof m.content === "string" ? [] : m.content));
|
|
91
|
+
const hasUse = flat.some((b) => b.type === "tool_use" && b.id === "tu_1");
|
|
92
|
+
const hasResult = flat.some((b) => b.type === "tool_result" && b.tool_use_id === "tu_1");
|
|
93
|
+
expect(hasUse).toBe(true);
|
|
94
|
+
expect(hasResult).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("orphan defense: pulls headEnd forward to keep tool_result's use", () => {
|
|
98
|
+
// u(0), a(1), u(2), a:tool_use tu_2 (3), u:tool_result tu_2 (4), a(5)
|
|
99
|
+
// Naive snip with keepHead=2, keepTail=2 cuts [2..4), losing tu_2's tool_use.
|
|
100
|
+
const messages: Anthropic.MessageParam[] = [
|
|
101
|
+
userMsg("u0"),
|
|
102
|
+
asstMsg("a1"),
|
|
103
|
+
userMsg("u2"),
|
|
104
|
+
{
|
|
105
|
+
role: "assistant",
|
|
106
|
+
content: [{ type: "tool_use", id: "tu_2", name: "Read", input: { path: "/x" } }],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
role: "user",
|
|
110
|
+
content: [{ type: "tool_result", tool_use_id: "tu_2", content: "file content" }],
|
|
111
|
+
},
|
|
112
|
+
asstMsg("a5"),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const out = snip(messages, 2, 2);
|
|
116
|
+
const flat = out.flatMap((m) => (typeof m.content === "string" ? [] : m.content));
|
|
117
|
+
const hasUse = flat.some((b) => b.type === "tool_use" && b.id === "tu_2");
|
|
118
|
+
const hasResult = flat.some((b) => b.type === "tool_result" && b.tool_use_id === "tu_2");
|
|
119
|
+
expect(hasUse).toBe(true);
|
|
120
|
+
expect(hasResult).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("orphan defense can collapse to no-op when adjustments meet", () => {
|
|
124
|
+
// Conversation entirely composed of tool pairs that all reference each other —
|
|
125
|
+
// the boundary should walk to either end and nothing gets snipped.
|
|
126
|
+
const messages: Anthropic.MessageParam[] = [
|
|
127
|
+
{
|
|
128
|
+
role: "assistant",
|
|
129
|
+
content: [{ type: "tool_use", id: "tu_A", name: "X", input: {} }],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
role: "user",
|
|
133
|
+
content: [{ type: "tool_result", tool_use_id: "tu_A", content: "x" }],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
role: "assistant",
|
|
137
|
+
content: [{ type: "tool_use", id: "tu_B", name: "Y", input: {} }],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
role: "user",
|
|
141
|
+
content: [{ type: "tool_result", tool_use_id: "tu_B", content: "y" }],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
// keepHead=1, keepTail=1 would cut 2 middle, but both halves orphan their counterparts.
|
|
145
|
+
const out = snip(messages, 1, 1);
|
|
146
|
+
// After defense, the snip should either produce a valid pair-preserving result or
|
|
147
|
+
// collapse to the original. Either way, every tool_result has a matching tool_use.
|
|
148
|
+
const flat = out.flatMap((m) => (typeof m.content === "string" ? [] : m.content));
|
|
149
|
+
const useIds = new Set(
|
|
150
|
+
flat.filter((b) => b.type === "tool_use").map((b) => (b as { id: string }).id),
|
|
151
|
+
);
|
|
152
|
+
const resultIds = flat
|
|
153
|
+
.filter((b) => b.type === "tool_result")
|
|
154
|
+
.map((b) => (b as { tool_use_id: string }).tool_use_id);
|
|
155
|
+
for (const id of resultIds) {
|
|
156
|
+
expect(useIds.has(id)).toBe(true);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R6 `compaction-snip` — pure transformation that drops middle
|
|
3
|
+
* messages and inserts a one-line marker at the snip point. No model
|
|
4
|
+
* call. The Anthropic API rejects a tool_result whose tool_use_id has
|
|
5
|
+
* no matching tool_use earlier in the array, so the snip boundary is
|
|
6
|
+
* widened when the naive cut would orphan a pair.
|
|
7
|
+
*
|
|
8
|
+
* Reference: claude-code/services/compact/snipCompact.ts
|
|
9
|
+
*/
|
|
10
|
+
import type Anthropic from "@anthropic-ai/sdk";
|
|
11
|
+
|
|
12
|
+
const MARKER_PREFIX = "[Context compacted: ";
|
|
13
|
+
const MARKER_SUFFIX = " messages removed]";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keep the first `keepHead` and last `keepTail` messages, replace
|
|
17
|
+
* everything in between with one assistant marker. Returns a copy
|
|
18
|
+
* (does not mutate the input).
|
|
19
|
+
*
|
|
20
|
+
* If the requested boundary would split a tool_use/tool_result pair,
|
|
21
|
+
* the head boundary advances forward and the tail boundary retreats
|
|
22
|
+
* backward until both halves are self-contained. Worst case the input
|
|
23
|
+
* is returned unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export function snip(
|
|
26
|
+
messages: ReadonlyArray<Anthropic.MessageParam>,
|
|
27
|
+
keepHead: number,
|
|
28
|
+
keepTail: number,
|
|
29
|
+
): Anthropic.MessageParam[] {
|
|
30
|
+
if (keepHead < 0 || keepTail < 0) {
|
|
31
|
+
throw new RangeError(`keepHead/keepTail must be >= 0 (got ${keepHead}, ${keepTail})`);
|
|
32
|
+
}
|
|
33
|
+
const total = messages.length;
|
|
34
|
+
if (total <= keepHead + keepTail) {
|
|
35
|
+
return [...messages];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Naive cut: keep [0, headEnd), drop [headEnd, tailStart), keep [tailStart, total).
|
|
39
|
+
let headEnd = keepHead;
|
|
40
|
+
let tailStart = total - keepTail;
|
|
41
|
+
|
|
42
|
+
// Defend against orphaning: ensure no tool_result in the kept tail
|
|
43
|
+
// references a tool_use that was dropped, and no tool_use in the
|
|
44
|
+
// kept head has its tool_result inside the dropped middle.
|
|
45
|
+
const adjusted = adjustForToolPairing(messages, headEnd, tailStart);
|
|
46
|
+
headEnd = adjusted.headEnd;
|
|
47
|
+
tailStart = adjusted.tailStart;
|
|
48
|
+
|
|
49
|
+
if (headEnd >= tailStart) {
|
|
50
|
+
// Adjustment collapsed the snip — nothing to remove.
|
|
51
|
+
return [...messages];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const removed = tailStart - headEnd;
|
|
55
|
+
const marker: Anthropic.MessageParam = {
|
|
56
|
+
role: "assistant",
|
|
57
|
+
content: `${MARKER_PREFIX}${removed}${MARKER_SUFFIX}`,
|
|
58
|
+
};
|
|
59
|
+
return [...messages.slice(0, headEnd), marker, ...messages.slice(tailStart)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function adjustForToolPairing(
|
|
63
|
+
messages: ReadonlyArray<Anthropic.MessageParam>,
|
|
64
|
+
initialHeadEnd: number,
|
|
65
|
+
initialTailStart: number,
|
|
66
|
+
): { headEnd: number; tailStart: number } {
|
|
67
|
+
let headEnd = initialHeadEnd;
|
|
68
|
+
let tailStart = initialTailStart;
|
|
69
|
+
|
|
70
|
+
// Iterate until both boundaries stop moving. Each pass either pulls
|
|
71
|
+
// a stranded tool_use/tool_result back into the kept range or stops.
|
|
72
|
+
for (let i = 0; i < messages.length; i++) {
|
|
73
|
+
const headIds = collectToolUseIds(messages.slice(0, headEnd));
|
|
74
|
+
const tailIds = collectToolResultIds(messages.slice(tailStart));
|
|
75
|
+
|
|
76
|
+
// tool_use in head whose tool_result lives in dropped middle —
|
|
77
|
+
// pull tailStart back so the result is preserved.
|
|
78
|
+
let moved = false;
|
|
79
|
+
for (const id of headIds) {
|
|
80
|
+
const resultIdx = findToolResultIndex(messages, id);
|
|
81
|
+
if (resultIdx >= 0 && resultIdx >= headEnd && resultIdx < tailStart) {
|
|
82
|
+
// Pull the boundary back to include up through resultIdx.
|
|
83
|
+
tailStart = resultIdx;
|
|
84
|
+
moved = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// tool_result in tail whose tool_use lives in dropped middle —
|
|
89
|
+
// pull headEnd forward so the use is preserved.
|
|
90
|
+
for (const id of tailIds) {
|
|
91
|
+
const useIdx = findToolUseIndex(messages, id);
|
|
92
|
+
if (useIdx >= 0 && useIdx >= headEnd && useIdx < tailStart) {
|
|
93
|
+
headEnd = useIdx + 1;
|
|
94
|
+
moved = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!moved) break;
|
|
99
|
+
if (headEnd >= tailStart) break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { headEnd, tailStart };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function collectToolUseIds(messages: ReadonlyArray<Anthropic.MessageParam>): string[] {
|
|
106
|
+
const ids: string[] = [];
|
|
107
|
+
for (const msg of messages) {
|
|
108
|
+
if (typeof msg.content === "string") continue;
|
|
109
|
+
for (const block of msg.content) {
|
|
110
|
+
if (block.type === "tool_use") ids.push(block.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return ids;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collectToolResultIds(messages: ReadonlyArray<Anthropic.MessageParam>): string[] {
|
|
117
|
+
const ids: string[] = [];
|
|
118
|
+
for (const msg of messages) {
|
|
119
|
+
if (typeof msg.content === "string") continue;
|
|
120
|
+
for (const block of msg.content) {
|
|
121
|
+
if (block.type === "tool_result") ids.push(block.tool_use_id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return ids;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findToolUseIndex(messages: ReadonlyArray<Anthropic.MessageParam>, id: string): number {
|
|
128
|
+
for (let i = 0; i < messages.length; i++) {
|
|
129
|
+
const m = messages[i];
|
|
130
|
+
if (!m || typeof m.content === "string") continue;
|
|
131
|
+
for (const block of m.content) {
|
|
132
|
+
if (block.type === "tool_use" && block.id === id) return i;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return -1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findToolResultIndex(messages: ReadonlyArray<Anthropic.MessageParam>, id: string): number {
|
|
139
|
+
for (let i = 0; i < messages.length; i++) {
|
|
140
|
+
const m = messages[i];
|
|
141
|
+
if (!m || typeof m.content === "string") continue;
|
|
142
|
+
for (const block of m.content) {
|
|
143
|
+
if (block.type === "tool_result" && block.tool_use_id === id) return i;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return -1;
|
|
147
|
+
}
|