Learn Claude Code
Back to Compact

SkillsCompact

s05 (321 LOC) → s06 (336 LOC)

LOC Delta

+15lines

New Tools

1

compact
New Classes

0

New Functions

5

estimateTokensisToolResultBlockisToolUseBlockmicroCompactautoCompact

Skills

Load on Demand

321 LOC

5 tools: bash, read_file, write_file, edit_file, load_skill

planning

Compact

Three-Layer Compression

336 LOC

5 tools: bash, read_file, write_file, edit_file, compact

memory

Source Code Diff

s05 (s05_skill_loading.ts) -> s06 (s06_context_compact.ts)
11#!/usr/bin/env node
22/**
3- * s05_skill_loading.ts - Skills
3+ * s06_context_compact.ts - Compact
44 *
5- * Two-layer skill injection:
6- * 1. Keep lightweight skill metadata in the system prompt.
7- * 2. Load the full SKILL.md body only when the model asks for it.
5+ * Three-layer compression pipeline:
6+ * 1. Micro-compact old tool results before each model call.
7+ * 2. Auto-compact when the token estimate crosses a threshold.
8+ * 3. Expose a compact tool for manual summarization.
89 */
910
1011import { spawnSync } from "node:child_process";
11-import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
12+import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1213import { resolve } from "node:path";
1314import process from "node:process";
1415import { createInterface } from "node:readline/promises";
1516import type Anthropic from "@anthropic-ai/sdk";
1617import "dotenv/config";
1718import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
1819
19-type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "load_skill";
20+type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "compact";
2021
2122type ToolUseBlock = {
2223 id: string;
2324 type: "tool_use";
2425 name: ToolName;
2526 input: Record<string, unknown>;
2627};
2728
2829type TextBlock = {
2930 type: "text";
3031 text: string;
3132};
3233
3334type ToolResultBlock = {
3435 type: "tool_result";
3536 tool_use_id: string;
3637 content: string;
3738};
3839
39-type MessageContent = string | Array<ToolUseBlock | TextBlock | ToolResultBlock>;
40+type AssistantBlock = ToolUseBlock | TextBlock;
41+type MessageContent = string | Array<AssistantBlock | ToolResultBlock>;
4042
4143type Message = {
4244 role: "user" | "assistant";
4345 content: MessageContent;
4446};
4547
46-type SkillRecord = {
47- meta: Record<string, string>;
48- body: string;
49- path: string;
50-};
51-
5248const WORKDIR = process.cwd();
5349const MODEL = resolveModel();
54-const SKILLS_DIR = resolve(WORKDIR, "..", "skills");
50+const THRESHOLD = 50_000;
51+const KEEP_RECENT = 3;
52+const TRANSCRIPT_DIR = resolve(WORKDIR, ".transcripts");
5553const client = createAnthropicClient();
5654
55+const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use tools to solve tasks.`);
56+
5757function safePath(relativePath: string): string {
5858 const filePath = resolve(WORKDIR, relativePath);
5959 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
6060 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) {
6161 throw new Error(`Path escapes workspace: ${relativePath}`);
6262 }
6363 return filePath;
6464}
6565
6666function runBash(command: string): string {
6767 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
6868 if (dangerous.some((item) => command.includes(item))) {
6969 return "Error: Dangerous command blocked";
7070 }
7171
7272 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
7373 const args = process.platform === "win32"
7474 ? ["/d", "/s", "/c", command]
7575 : ["-lc", command];
7676
7777 const result = spawnSync(shell, args, {
7878 cwd: WORKDIR,
7979 encoding: "utf8",
8080 timeout: 120_000,
8181 });
8282
8383 if (result.error?.name === "TimeoutError") {
8484 return "Error: Timeout (120s)";
8585 }
8686
8787 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
8888 return output.slice(0, 50_000) || "(no output)";
8989}
9090
9191function runRead(path: string, limit?: number): string {
9292 try {
9393 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
9494 if (limit && limit < lines.length) {
9595 lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
9696 }
9797 return lines.join("\n").slice(0, 50_000);
9898 } catch (error) {
9999 return `Error: ${error instanceof Error ? error.message : String(error)}`;
100100 }
101101}
102102
103103function runWrite(path: string, content: string): string {
104104 try {
105105 const filePath = safePath(path);
106106 mkdirSync(resolve(filePath, ".."), { recursive: true });
107107 writeFileSync(filePath, content, "utf8");
108108 return `Wrote ${content.length} bytes`;
109109 } catch (error) {
110110 return `Error: ${error instanceof Error ? error.message : String(error)}`;
111111 }
112112}
113113
114114function runEdit(path: string, oldText: string, newText: string): string {
115115 try {
116116 const filePath = safePath(path);
117117 const content = readFileSync(filePath, "utf8");
118118 if (!content.includes(oldText)) {
119119 return `Error: Text not found in ${path}`;
120120 }
121121 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
122122 return `Edited ${path}`;
123123 } catch (error) {
124124 return `Error: ${error instanceof Error ? error.message : String(error)}`;
125125 }
126126}
127127
128-function parseFrontmatter(text: string): { meta: Record<string, string>; body: string } {
129- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/m.exec(text);
130- if (!match) {
131- return { meta: {}, body: text.trim() };
132- }
128+function estimateTokens(messages: Message[]): number {
129+ return JSON.stringify(messages).length / 4;
130+}
133131
134- const meta: Record<string, string> = {};
135- for (const line of match[1].split(/\r?\n/)) {
136- const separator = line.indexOf(":");
137- if (separator < 0) continue;
138- const key = line.slice(0, separator).trim();
139- const value = line.slice(separator + 1).trim();
140- if (key) meta[key] = value;
141- }
132+function isToolResultBlock(block: unknown): block is ToolResultBlock {
133+ return !!block
134+ && typeof block === "object"
135+ && "type" in block
136+ && (block as { type?: string }).type === "tool_result";
137+}
142138
143- return { meta, body: match[2].trim() };
139+function isToolUseBlock(block: unknown): block is ToolUseBlock {
140+ return !!block
141+ && typeof block === "object"
142+ && "type" in block
143+ && (block as { type?: string }).type === "tool_use";
144144}
145145
146-function collectSkillFiles(dir: string): string[] {
147- if (!existsSync(dir)) {
148- return [];
146+function microCompact(messages: Message[]): Message[] {
147+ const toolResults: ToolResultBlock[] = [];
148+
149+ for (const message of messages) {
150+ if (message.role !== "user" || !Array.isArray(message.content)) continue;
151+ for (const part of message.content) {
152+ if (isToolResultBlock(part)) {
153+ toolResults.push(part);
154+ }
155+ }
149156 }
150157
151- const files: string[] = [];
152- const stack = [dir];
158+ if (toolResults.length <= KEEP_RECENT) {
159+ return messages;
160+ }
153161
154- while (stack.length > 0) {
155- const current = stack.pop();
156- if (!current) continue;
157-
158- for (const entry of readdirSync(current)) {
159- const entryPath = resolve(current, entry);
160- const stats = statSync(entryPath);
161- if (stats.isDirectory()) {
162- stack.push(entryPath);
163- continue;
162+ const toolNameMap: Record<string, string> = {};
163+ for (const message of messages) {
164+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
165+ for (const block of message.content) {
166+ if (isToolUseBlock(block)) {
167+ toolNameMap[block.id] = block.name;
164168 }
165-
166- if (stats.isFile() && entry === "SKILL.md") {
167- files.push(entryPath);
168- }
169169 }
170170 }
171171
172- return files.sort((a, b) => a.localeCompare(b));
172+ for (const result of toolResults.slice(0, -KEEP_RECENT)) {
173+ if (result.content.length <= 100) continue;
174+ const toolName = toolNameMap[result.tool_use_id] ?? "unknown";
175+ result.content = `[Previous: used ${toolName}]`;
176+ }
177+
178+ return messages;
173179}
174180
175-class SkillLoader {
176- skills: Record<string, SkillRecord> = {};
177-
178- constructor(private skillsDir: string) {
179- this.loadAll();
181+async function autoCompact(messages: Message[]): Promise<Message[]> {
182+ if (!existsSync(TRANSCRIPT_DIR)) {
183+ mkdirSync(TRANSCRIPT_DIR, { recursive: true });
180184 }
181185
182- private loadAll() {
183- for (const filePath of collectSkillFiles(this.skillsDir)) {
184- const text = readFileSync(filePath, "utf8");
185- const { meta, body } = parseFrontmatter(text);
186- const normalized = filePath.replace(/\\/g, "/");
187- const fallbackName = normalized.split("/").slice(-2, -1)[0] ?? "unknown";
188- const name = meta.name || fallbackName;
189- this.skills[name] = { meta, body, path: filePath };
190- }
186+ const transcriptPath = resolve(TRANSCRIPT_DIR, `transcript_${Date.now()}.jsonl`);
187+ for (const message of messages) {
188+ appendFileSync(transcriptPath, `${JSON.stringify(message)}\n`, "utf8");
191189 }
190+ console.log(`[transcript saved: ${transcriptPath}]`);
192191
193- getDescriptions(): string {
194- const entries = Object.entries(this.skills);
195- if (entries.length === 0) {
196- return "(no skills available)";
197- }
192+ const conversationText = JSON.stringify(messages).slice(0, 80_000);
193+ const response = await client.messages.create({
194+ model: MODEL,
195+ messages: [{
196+ role: "user",
197+ content:
198+ "Summarize this conversation for continuity. Include: " +
199+ "1) What was accomplished, 2) Current state, 3) Key decisions made. " +
200+ `Be concise but preserve critical details.\n\n${conversationText}`,
201+ }],
202+ max_tokens: 2000,
203+ });
198204
199- return entries
200- .map(([name, skill]) => {
201- const desc = skill.meta.description || "No description";
202- const tags = skill.meta.tags ? ` [${skill.meta.tags}]` : "";
203- return ` - ${name}: ${desc}${tags}`;
204- })
205- .join("\n");
205+ const summaryParts: string[] = [];
206+ for (const block of response.content) {
207+ if (block.type === "text") summaryParts.push(block.text);
206208 }
209+ const summary = summaryParts.join("") || "(no summary)";
207210
208- getContent(name: string): string {
209- const skill = this.skills[name];
210- if (!skill) {
211- const names = Object.keys(this.skills).join(", ");
212- return `Error: Unknown skill '${name}'. Available: ${names}`;
213- }
214- return `<skill name="${name}">\n${skill.body}\n</skill>`;
215- }
211+ return [
212+ {
213+ role: "user",
214+ content: `[Conversation compressed. Transcript: ${transcriptPath}]\n\n${summary}`,
215+ },
216+ {
217+ role: "assistant",
218+ content: "Understood. I have the context from the summary. Continuing.",
219+ },
220+ ];
216221}
217222
218-const skillLoader = new SkillLoader(SKILLS_DIR);
219-
220-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}.
221-Use load_skill to access specialized knowledge before tackling unfamiliar topics.
222-
223-Skills available:
224-${skillLoader.getDescriptions()}`);
225-
226-const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = {
223+const TOOL_HANDLERS: Record<Exclude<ToolName, "compact">, (input: Record<string, unknown>) => string> = {
227224 bash: (input) => runBash(String(input.command ?? "")),
228225 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
229226 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
230227 edit_file: (input) =>
231228 runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
232- load_skill: (input) => skillLoader.getContent(String(input.name ?? "")),
233229};
234230
235231const TOOLS = [
236232 {
237233 name: "bash",
238234 description: shellToolDescription(),
239235 input_schema: {
240236 type: "object",
241237 properties: { command: { type: "string" } },
242238 required: ["command"],
243239 },
244240 },
245241 {
246242 name: "read_file",
247243 description: "Read file contents.",
248244 input_schema: {
249245 type: "object",
250246 properties: { path: { type: "string" }, limit: { type: "integer" } },
251247 required: ["path"],
252248 },
253249 },
254250 {
255251 name: "write_file",
256252 description: "Write content to file.",
257253 input_schema: {
258254 type: "object",
259255 properties: { path: { type: "string" }, content: { type: "string" } },
260256 required: ["path", "content"],
261257 },
262258 },
263259 {
264260 name: "edit_file",
265261 description: "Replace exact text in file.",
266262 input_schema: {
267263 type: "object",
268264 properties: {
269265 path: { type: "string" },
270266 old_text: { type: "string" },
271267 new_text: { type: "string" },
272268 },
273269 required: ["path", "old_text", "new_text"],
274270 },
275271 },
276272 {
277- name: "load_skill",
278- description: "Load specialized knowledge by name.",
273+ name: "compact",
274+ description: "Trigger manual conversation compression.",
279275 input_schema: {
280276 type: "object",
281277 properties: {
282- name: { type: "string", description: "Skill name to load" },
278+ focus: { type: "string", description: "What to preserve in the summary" },
283279 },
284- required: ["name"],
285280 },
286281 },
287282];
288283
289-function assistantText(content: Array<ToolUseBlock | TextBlock | ToolResultBlock>) {
284+function assistantText(content: AssistantBlock[]) {
290285 return content
291286 .filter((block): block is TextBlock => block.type === "text")
292287 .map((block) => block.text)
293288 .join("\n");
294289}
295290
296291export async function agentLoop(messages: Message[]) {
297292 while (true) {
293+ microCompact(messages);
294+
295+ if (estimateTokens(messages) > THRESHOLD) {
296+ console.log("[auto_compact triggered]");
297+ messages.splice(0, messages.length, ...(await autoCompact(messages)));
298+ }
299+
298300 const response = await client.messages.create({
299301 model: MODEL,
300302 system: SYSTEM,
301303 messages: messages as Anthropic.Messages.MessageParam[],
302304 tools: TOOLS as Anthropic.Messages.Tool[],
303305 max_tokens: 8000,
304306 });
305307
306308 messages.push({
307309 role: "assistant",
308- content: response.content as Array<ToolUseBlock | TextBlock>,
310+ content: response.content as AssistantBlock[],
309311 });
310312
311313 if (response.stop_reason !== "tool_use") {
312314 return;
313315 }
314316
317+ let manualCompact = false;
315318 const results: ToolResultBlock[] = [];
319+
316320 for (const block of response.content) {
317321 if (block.type !== "tool_use") continue;
318322
319- const handler = TOOL_HANDLERS[block.name as ToolName];
320- const output = handler
321- ? handler(block.input as Record<string, unknown>)
322- : `Unknown tool: ${block.name}`;
323+ let output: string;
324+ if (block.name === "compact") {
325+ manualCompact = true;
326+ output = "Compressing...";
327+ } else {
328+ const handler = TOOL_HANDLERS[block.name as Exclude<ToolName, "compact">];
329+ output = handler
330+ ? handler(block.input as Record<string, unknown>)
331+ : `Unknown tool: ${block.name}`;
332+ }
323333
324334 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
325335 results.push({
326336 type: "tool_result",
327337 tool_use_id: block.id,
328338 content: output,
329339 });
330340 }
331341
332342 messages.push({ role: "user", content: results });
343+
344+ if (manualCompact) {
345+ console.log("[manual compact]");
346+ messages.splice(0, messages.length, ...(await autoCompact(messages)));
347+ }
333348 }
334349}
335350
336351async function main() {
337352 const rl = createInterface({
338353 input: process.stdin,
339354 output: process.stdout,
340355 });
341356
342357 const history: Message[] = [];
343358
344359 while (true) {
345360 let query = "";
346361 try {
347- query = await rl.question("\x1b[36ms05 >> \x1b[0m");
362+ query = await rl.question("\x1b[36ms06 >> \x1b[0m");
348363 } catch (error) {
349364 if (
350365 error instanceof Error &&
351366 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
352367 ) {
353368 break;
354369 }
355370 throw error;
356371 }
357372 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) {
358373 break;
359374 }
360375
361376 history.push({ role: "user", content: query });
362377 await agentLoop(history);
363378
364379 const last = history[history.length - 1]?.content;
365380 if (Array.isArray(last)) {
366- const text = assistantText(last);
381+ const text = assistantText(last as AssistantBlock[]);
367382 if (text) console.log(text);
368383 }
369384 console.log();
370385 }
371386
372387 rl.close();
373388}
374389
375390void main();