Learn Claude Code
Back to Tasks

CompactTasks

s06 (336 LOC) → s07 (260 LOC)

LOC Delta

-76lines

New Tools

4

task_createtask_updatetask_listtask_get
New Classes

1

TaskManager
New Functions

0

Compact

Three-Layer Compression

336 LOC

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

memory

Tasks

Task Graph + Dependencies

260 LOC

8 tools: bash, read_file, write_file, edit_file, task_create, task_update, task_list, task_get

planning

Source Code Diff

s06 (s06_context_compact.ts) -> s07 (s07_task_system.ts)
11#!/usr/bin/env node
22/**
3- * s06_context_compact.ts - Compact
3+ * s07_task_system.ts - Tasks
44 *
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.
5+ * Persistent task graph stored in .tasks/.
96 */
107
118import { spawnSync } from "node:child_process";
12-import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9+import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1310import { resolve } from "node:path";
1411import process from "node:process";
1512import { createInterface } from "node:readline/promises";
1613import type Anthropic from "@anthropic-ai/sdk";
1714import "dotenv/config";
1815import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
1916
20-type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "compact";
17+type TaskStatus = "pending" | "in_progress" | "completed";
18+type ToolName =
19+ | "bash"
20+ | "read_file"
21+ | "write_file"
22+ | "edit_file"
23+ | "task_create"
24+ | "task_update"
25+ | "task_list"
26+ | "task_get";
2127
22-type ToolUseBlock = {
23- id: string;
24- type: "tool_use";
25- name: ToolName;
26- input: Record<string, unknown>;
28+type Task = {
29+ id: number;
30+ subject: string;
31+ description: string;
32+ status: TaskStatus;
33+ blockedBy: number[];
34+ blocks: number[];
35+ owner: string;
2736};
2837
29-type TextBlock = {
30- type: "text";
31- text: string;
32-};
38+type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record<string, unknown> };
39+type TextBlock = { type: "text"; text: string };
40+type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string };
41+type Message = { role: "user" | "assistant"; content: string | Array<ToolUseBlock | TextBlock | ToolResultBlock> };
3342
34-type ToolResultBlock = {
35- type: "tool_result";
36- tool_use_id: string;
37- content: string;
38-};
39-
40-type AssistantBlock = ToolUseBlock | TextBlock;
41-type MessageContent = string | Array<AssistantBlock | ToolResultBlock>;
42-
43-type Message = {
44- role: "user" | "assistant";
45- content: MessageContent;
46-};
47-
4843const WORKDIR = process.cwd();
4944const MODEL = resolveModel();
50-const THRESHOLD = 50_000;
51-const KEEP_RECENT = 3;
52-const TRANSCRIPT_DIR = resolve(WORKDIR, ".transcripts");
45+const TASKS_DIR = resolve(WORKDIR, ".tasks");
5346const client = createAnthropicClient();
5447
55-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use tools to solve tasks.`);
48+const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use task tools to plan and track work.`);
5649
57-function safePath(relativePath: string): string {
50+function safePath(relativePath: string) {
5851 const filePath = resolve(WORKDIR, relativePath);
5952 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
6053 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) {
6154 throw new Error(`Path escapes workspace: ${relativePath}`);
6255 }
6356 return filePath;
6457}
6558
6659function runBash(command: string): string {
6760 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
68- if (dangerous.some((item) => command.includes(item))) {
69- return "Error: Dangerous command blocked";
70- }
71-
61+ if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked";
7262 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
73- const args = process.platform === "win32"
74- ? ["/d", "/s", "/c", command]
75- : ["-lc", command];
76-
77- const result = spawnSync(shell, args, {
78- cwd: WORKDIR,
79- encoding: "utf8",
80- timeout: 120_000,
81- });
82-
83- if (result.error?.name === "TimeoutError") {
84- return "Error: Timeout (120s)";
85- }
86-
63+ const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command];
64+ const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 });
65+ if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)";
8766 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
8867 return output.slice(0, 50_000) || "(no output)";
8968}
9069
9170function runRead(path: string, limit?: number): string {
9271 try {
9372 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
94- if (limit && limit < lines.length) {
95- lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
96- }
73+ if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
9774 return lines.join("\n").slice(0, 50_000);
9875 } catch (error) {
9976 return `Error: ${error instanceof Error ? error.message : String(error)}`;
10077 }
10178}
10279
10380function runWrite(path: string, content: string): string {
10481 try {
10582 const filePath = safePath(path);
10683 mkdirSync(resolve(filePath, ".."), { recursive: true });
10784 writeFileSync(filePath, content, "utf8");
10885 return `Wrote ${content.length} bytes`;
10986 } catch (error) {
11087 return `Error: ${error instanceof Error ? error.message : String(error)}`;
11188 }
11289}
11390
11491function runEdit(path: string, oldText: string, newText: string): string {
11592 try {
11693 const filePath = safePath(path);
11794 const content = readFileSync(filePath, "utf8");
118- if (!content.includes(oldText)) {
119- return `Error: Text not found in ${path}`;
120- }
95+ if (!content.includes(oldText)) return `Error: Text not found in ${path}`;
12196 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
12297 return `Edited ${path}`;
12398 } catch (error) {
12499 return `Error: ${error instanceof Error ? error.message : String(error)}`;
125100 }
126101}
127102
128-function estimateTokens(messages: Message[]): number {
129- return JSON.stringify(messages).length / 4;
130-}
103+class TaskManager {
104+ private nextId: number;
131105
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-}
106+ constructor(private tasksDir: string) {
107+ mkdirSync(tasksDir, { recursive: true });
108+ this.nextId = this.maxId() + 1;
109+ }
138110
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";
144-}
111+ private maxId(): number {
112+ return readdirSync(this.tasksDir, { withFileTypes: true })
113+ .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name))
114+ .map((entry) => Number(entry.name.match(/\d+/)?.[0] ?? 0))
115+ .reduce((max, id) => Math.max(max, id), 0);
116+ }
145117
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- }
118+ private filePath(taskId: number) {
119+ return resolve(this.tasksDir, `task_${taskId}.json`);
156120 }
157121
158- if (toolResults.length <= KEEP_RECENT) {
159- return messages;
122+ private load(taskId: number): Task {
123+ const path = this.filePath(taskId);
124+ if (!existsSync(path)) throw new Error(`Task ${taskId} not found`);
125+ return JSON.parse(readFileSync(path, "utf8")) as Task;
160126 }
161127
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;
168- }
169- }
128+ private save(task: Task) {
129+ writeFileSync(this.filePath(task.id), `${JSON.stringify(task, null, 2)}\n`, "utf8");
170130 }
171131
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}]`;
132+ create(subject: string, description = "") {
133+ const task: Task = {
134+ id: this.nextId,
135+ subject,
136+ description,
137+ status: "pending",
138+ blockedBy: [],
139+ blocks: [],
140+ owner: "",
141+ };
142+ this.save(task);
143+ this.nextId += 1;
144+ return JSON.stringify(task, null, 2);
176145 }
177146
178- return messages;
179-}
180-
181-async function autoCompact(messages: Message[]): Promise<Message[]> {
182- if (!existsSync(TRANSCRIPT_DIR)) {
183- mkdirSync(TRANSCRIPT_DIR, { recursive: true });
147+ get(taskId: number) {
148+ return JSON.stringify(this.load(taskId), null, 2);
184149 }
185150
186- const transcriptPath = resolve(TRANSCRIPT_DIR, `transcript_${Date.now()}.jsonl`);
187- for (const message of messages) {
188- appendFileSync(transcriptPath, `${JSON.stringify(message)}\n`, "utf8");
151+ private clearDependency(completedId: number) {
152+ for (const entry of readdirSync(this.tasksDir, { withFileTypes: true })) {
153+ if (!entry.isFile() || !/^task_\d+\.json$/.test(entry.name)) continue;
154+ const path = resolve(this.tasksDir, entry.name);
155+ const task = JSON.parse(readFileSync(path, "utf8")) as Task;
156+ if (task.blockedBy.includes(completedId)) {
157+ task.blockedBy = task.blockedBy.filter((id) => id !== completedId);
158+ this.save(task);
159+ }
160+ }
189161 }
190- console.log(`[transcript saved: ${transcriptPath}]`);
191162
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- });
204-
205- const summaryParts: string[] = [];
206- for (const block of response.content) {
207- if (block.type === "text") summaryParts.push(block.text);
163+ update(taskId: number, status?: string, addBlockedBy?: number[], addBlocks?: number[]) {
164+ const task = this.load(taskId);
165+ if (status) {
166+ if (!["pending", "in_progress", "completed"].includes(status)) {
167+ throw new Error(`Invalid status: ${status}`);
168+ }
169+ task.status = status as TaskStatus;
170+ if (task.status === "completed") this.clearDependency(taskId);
171+ }
172+ if (addBlockedBy?.length) task.blockedBy = [...new Set(task.blockedBy.concat(addBlockedBy))];
173+ if (addBlocks?.length) {
174+ task.blocks = [...new Set(task.blocks.concat(addBlocks))];
175+ for (const blockedId of addBlocks) {
176+ try {
177+ const blocked = this.load(blockedId);
178+ if (!blocked.blockedBy.includes(taskId)) {
179+ blocked.blockedBy.push(taskId);
180+ this.save(blocked);
181+ }
182+ } catch {}
183+ }
184+ }
185+ this.save(task);
186+ return JSON.stringify(task, null, 2);
208187 }
209- const summary = summaryParts.join("") || "(no summary)";
210188
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- ];
189+ listAll() {
190+ const tasks = readdirSync(this.tasksDir, { withFileTypes: true })
191+ .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name))
192+ .map((entry) => JSON.parse(readFileSync(resolve(this.tasksDir, entry.name), "utf8")) as Task)
193+ .sort((a, b) => a.id - b.id);
194+ if (!tasks.length) return "No tasks.";
195+ return tasks
196+ .map((task) => {
197+ const marker = { pending: "[ ]", in_progress: "[>]", completed: "[x]" }[task.status] ?? "[?]";
198+ const blocked = task.blockedBy.length ? ` (blocked by: ${JSON.stringify(task.blockedBy)})` : "";
199+ return `${marker} #${task.id}: ${task.subject}${blocked}`;
200+ })
201+ .join("\n");
202+ }
221203}
222204
223-const TOOL_HANDLERS: Record<Exclude<ToolName, "compact">, (input: Record<string, unknown>) => string> = {
205+const TASKS = new TaskManager(TASKS_DIR);
206+
207+const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = {
224208 bash: (input) => runBash(String(input.command ?? "")),
225209 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
226210 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
227- edit_file: (input) =>
228- runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
211+ edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
212+ task_create: (input) => TASKS.create(String(input.subject ?? ""), String(input.description ?? "")),
213+ task_update: (input) => TASKS.update(
214+ Number(input.task_id ?? 0),
215+ typeof input.status === "string" ? input.status : undefined,
216+ Array.isArray(input.addBlockedBy) ? input.addBlockedBy.map(Number) : undefined,
217+ Array.isArray(input.addBlocks) ? input.addBlocks.map(Number) : undefined,
218+ ),
219+ task_list: () => TASKS.listAll(),
220+ task_get: (input) => TASKS.get(Number(input.task_id ?? 0)),
229221};
230222
231223const TOOLS = [
232- {
233- name: "bash",
234- description: shellToolDescription(),
235- input_schema: {
236- type: "object",
237- properties: { command: { type: "string" } },
238- required: ["command"],
239- },
240- },
241- {
242- name: "read_file",
243- description: "Read file contents.",
244- input_schema: {
245- type: "object",
246- properties: { path: { type: "string" }, limit: { type: "integer" } },
247- required: ["path"],
248- },
249- },
250- {
251- name: "write_file",
252- description: "Write content to file.",
253- input_schema: {
254- type: "object",
255- properties: { path: { type: "string" }, content: { type: "string" } },
256- required: ["path", "content"],
257- },
258- },
259- {
260- name: "edit_file",
261- description: "Replace exact text in file.",
262- input_schema: {
263- type: "object",
264- properties: {
265- path: { type: "string" },
266- old_text: { type: "string" },
267- new_text: { type: "string" },
268- },
269- required: ["path", "old_text", "new_text"],
270- },
271- },
272- {
273- name: "compact",
274- description: "Trigger manual conversation compression.",
275- input_schema: {
276- type: "object",
277- properties: {
278- focus: { type: "string", description: "What to preserve in the summary" },
279- },
280- },
281- },
224+ { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
225+ { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
226+ { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
227+ { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } },
228+ { name: "task_create", description: "Create a new task.", input_schema: { type: "object", properties: { subject: { type: "string" }, description: { type: "string" } }, required: ["subject"] } },
229+ { name: "task_update", description: "Update a task's status or dependencies.", input_schema: { type: "object", properties: { task_id: { type: "integer" }, status: { type: "string", enum: ["pending", "in_progress", "completed"] }, addBlockedBy: { type: "array", items: { type: "integer" } }, addBlocks: { type: "array", items: { type: "integer" } } }, required: ["task_id"] } },
230+ { name: "task_list", description: "List all tasks with status summary.", input_schema: { type: "object", properties: {} } },
231+ { name: "task_get", description: "Get full details of a task by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } },
282232];
283233
284-function assistantText(content: AssistantBlock[]) {
285- return content
286- .filter((block): block is TextBlock => block.type === "text")
287- .map((block) => block.text)
288- .join("\n");
234+function assistantText(content: Array<ToolUseBlock | TextBlock>) {
235+ return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n");
289236}
290237
291238export async function agentLoop(messages: Message[]) {
292239 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-
300240 const response = await client.messages.create({
301241 model: MODEL,
302242 system: SYSTEM,
303243 messages: messages as Anthropic.Messages.MessageParam[],
304244 tools: TOOLS as Anthropic.Messages.Tool[],
305245 max_tokens: 8000,
306246 });
247+ messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
248+ if (response.stop_reason !== "tool_use") return;
307249
308- messages.push({
309- role: "assistant",
310- content: response.content as AssistantBlock[],
311- });
312-
313- if (response.stop_reason !== "tool_use") {
314- return;
315- }
316-
317- let manualCompact = false;
318250 const results: ToolResultBlock[] = [];
319-
320251 for (const block of response.content) {
321252 if (block.type !== "tool_use") continue;
322-
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- }
333-
253+ const handler = TOOL_HANDLERS[block.name as ToolName];
254+ const output = handler ? handler(block.input as Record<string, unknown>) : `Unknown tool: ${block.name}`;
334255 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
335- results.push({
336- type: "tool_result",
337- tool_use_id: block.id,
338- content: output,
339- });
256+ results.push({ type: "tool_result", tool_use_id: block.id, content: output });
340257 }
341-
342258 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- }
348259 }
349260}
350261
351262async function main() {
352- const rl = createInterface({
353- input: process.stdin,
354- output: process.stdout,
355- });
356-
263+ const rl = createInterface({ input: process.stdin, output: process.stdout });
357264 const history: Message[] = [];
358-
359265 while (true) {
360266 let query = "";
361267 try {
362- query = await rl.question("\x1b[36ms06 >> \x1b[0m");
268+ query = await rl.question("\x1b[36ms07 >> \x1b[0m");
363269 } catch (error) {
364270 if (
365271 error instanceof Error &&
366272 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
367273 ) {
368274 break;
369275 }
370276 throw error;
371277 }
372- if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) {
373- break;
374- }
375-
278+ if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break;
376279 history.push({ role: "user", content: query });
377280 await agentLoop(history);
378-
379281 const last = history[history.length - 1]?.content;
380282 if (Array.isArray(last)) {
381- const text = assistantText(last as AssistantBlock[]);
283+ const text = assistantText(last as Array<ToolUseBlock | TextBlock>);
382284 if (text) console.log(text);
383285 }
384286 console.log();
385287 }
386-
387288 rl.close();
388289}
389290
390291void main();