Back to Compact
Skills → Compact
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
planningCompact
Three-Layer Compression
336 LOC
5 tools: bash, read_file, write_file, edit_file, compact
memorySource Code Diff
s05 (s05_skill_loading.ts) -> s06 (s06_context_compact.ts)
| 1 | 1 | #!/usr/bin/env node | |
| 2 | 2 | /** | |
| 3 | - | * s05_skill_loading.ts - Skills | |
| 3 | + | * s06_context_compact.ts - Compact | |
| 4 | 4 | * | |
| 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. | |
| 8 | 9 | */ | |
| 9 | 10 | ||
| 10 | 11 | import { 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"; | |
| 12 | 13 | import { resolve } from "node:path"; | |
| 13 | 14 | import process from "node:process"; | |
| 14 | 15 | import { createInterface } from "node:readline/promises"; | |
| 15 | 16 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 16 | 17 | import "dotenv/config"; | |
| 17 | 18 | import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; | |
| 18 | 19 | ||
| 19 | - | type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "load_skill"; | |
| 20 | + | type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "compact"; | |
| 20 | 21 | ||
| 21 | 22 | type ToolUseBlock = { | |
| 22 | 23 | id: string; | |
| 23 | 24 | type: "tool_use"; | |
| 24 | 25 | name: ToolName; | |
| 25 | 26 | input: Record<string, unknown>; | |
| 26 | 27 | }; | |
| 27 | 28 | ||
| 28 | 29 | type TextBlock = { | |
| 29 | 30 | type: "text"; | |
| 30 | 31 | text: string; | |
| 31 | 32 | }; | |
| 32 | 33 | ||
| 33 | 34 | type ToolResultBlock = { | |
| 34 | 35 | type: "tool_result"; | |
| 35 | 36 | tool_use_id: string; | |
| 36 | 37 | content: string; | |
| 37 | 38 | }; | |
| 38 | 39 | ||
| 39 | - | type MessageContent = string | Array<ToolUseBlock | TextBlock | ToolResultBlock>; | |
| 40 | + | type AssistantBlock = ToolUseBlock | TextBlock; | |
| 41 | + | type MessageContent = string | Array<AssistantBlock | ToolResultBlock>; | |
| 40 | 42 | ||
| 41 | 43 | type Message = { | |
| 42 | 44 | role: "user" | "assistant"; | |
| 43 | 45 | content: MessageContent; | |
| 44 | 46 | }; | |
| 45 | 47 | ||
| 46 | - | type SkillRecord = { | |
| 47 | - | meta: Record<string, string>; | |
| 48 | - | body: string; | |
| 49 | - | path: string; | |
| 50 | - | }; | |
| 51 | - | ||
| 52 | 48 | const WORKDIR = process.cwd(); | |
| 53 | 49 | const 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"); | |
| 55 | 53 | const client = createAnthropicClient(); | |
| 56 | 54 | ||
| 55 | + | const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use tools to solve tasks.`); | |
| 56 | + | ||
| 57 | 57 | function safePath(relativePath: string): string { | |
| 58 | 58 | const filePath = resolve(WORKDIR, relativePath); | |
| 59 | 59 | const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; | |
| 60 | 60 | if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { | |
| 61 | 61 | throw new Error(`Path escapes workspace: ${relativePath}`); | |
| 62 | 62 | } | |
| 63 | 63 | return filePath; | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | function runBash(command: string): string { | |
| 67 | 67 | const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; | |
| 68 | 68 | if (dangerous.some((item) => command.includes(item))) { | |
| 69 | 69 | return "Error: Dangerous command blocked"; | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; | |
| 73 | 73 | const args = process.platform === "win32" | |
| 74 | 74 | ? ["/d", "/s", "/c", command] | |
| 75 | 75 | : ["-lc", command]; | |
| 76 | 76 | ||
| 77 | 77 | const result = spawnSync(shell, args, { | |
| 78 | 78 | cwd: WORKDIR, | |
| 79 | 79 | encoding: "utf8", | |
| 80 | 80 | timeout: 120_000, | |
| 81 | 81 | }); | |
| 82 | 82 | ||
| 83 | 83 | if (result.error?.name === "TimeoutError") { | |
| 84 | 84 | return "Error: Timeout (120s)"; | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); | |
| 88 | 88 | return output.slice(0, 50_000) || "(no output)"; | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | function runRead(path: string, limit?: number): string { | |
| 92 | 92 | try { | |
| 93 | 93 | let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); | |
| 94 | 94 | if (limit && limit < lines.length) { | |
| 95 | 95 | lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); | |
| 96 | 96 | } | |
| 97 | 97 | return lines.join("\n").slice(0, 50_000); | |
| 98 | 98 | } catch (error) { | |
| 99 | 99 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 100 | 100 | } | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | 103 | function runWrite(path: string, content: string): string { | |
| 104 | 104 | try { | |
| 105 | 105 | const filePath = safePath(path); | |
| 106 | 106 | mkdirSync(resolve(filePath, ".."), { recursive: true }); | |
| 107 | 107 | writeFileSync(filePath, content, "utf8"); | |
| 108 | 108 | return `Wrote ${content.length} bytes`; | |
| 109 | 109 | } catch (error) { | |
| 110 | 110 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | function runEdit(path: string, oldText: string, newText: string): string { | |
| 115 | 115 | try { | |
| 116 | 116 | const filePath = safePath(path); | |
| 117 | 117 | const content = readFileSync(filePath, "utf8"); | |
| 118 | 118 | if (!content.includes(oldText)) { | |
| 119 | 119 | return `Error: Text not found in ${path}`; | |
| 120 | 120 | } | |
| 121 | 121 | writeFileSync(filePath, content.replace(oldText, newText), "utf8"); | |
| 122 | 122 | return `Edited ${path}`; | |
| 123 | 123 | } catch (error) { | |
| 124 | 124 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 125 | 125 | } | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 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 | + | } | |
| 133 | 131 | ||
| 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 | + | } | |
| 142 | 138 | ||
| 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"; | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 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 | + | } | |
| 149 | 156 | } | |
| 150 | 157 | ||
| 151 | - | const files: string[] = []; | |
| 152 | - | const stack = [dir]; | |
| 158 | + | if (toolResults.length <= KEEP_RECENT) { | |
| 159 | + | return messages; | |
| 160 | + | } | |
| 153 | 161 | ||
| 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; | |
| 164 | 168 | } | |
| 165 | - | ||
| 166 | - | if (stats.isFile() && entry === "SKILL.md") { | |
| 167 | - | files.push(entryPath); | |
| 168 | - | } | |
| 169 | 169 | } | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 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; | |
| 173 | 179 | } | |
| 174 | 180 | ||
| 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 }); | |
| 180 | 184 | } | |
| 181 | 185 | ||
| 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"); | |
| 191 | 189 | } | |
| 190 | + | console.log(`[transcript saved: ${transcriptPath}]`); | |
| 192 | 191 | ||
| 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 | + | }); | |
| 198 | 204 | ||
| 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); | |
| 206 | 208 | } | |
| 209 | + | const summary = summaryParts.join("") || "(no summary)"; | |
| 207 | 210 | ||
| 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 | + | ]; | |
| 216 | 221 | } | |
| 217 | 222 | ||
| 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> = { | |
| 227 | 224 | bash: (input) => runBash(String(input.command ?? "")), | |
| 228 | 225 | read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), | |
| 229 | 226 | write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), | |
| 230 | 227 | edit_file: (input) => | |
| 231 | 228 | runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), | |
| 232 | - | load_skill: (input) => skillLoader.getContent(String(input.name ?? "")), | |
| 233 | 229 | }; | |
| 234 | 230 | ||
| 235 | 231 | const TOOLS = [ | |
| 236 | 232 | { | |
| 237 | 233 | name: "bash", | |
| 238 | 234 | description: shellToolDescription(), | |
| 239 | 235 | input_schema: { | |
| 240 | 236 | type: "object", | |
| 241 | 237 | properties: { command: { type: "string" } }, | |
| 242 | 238 | required: ["command"], | |
| 243 | 239 | }, | |
| 244 | 240 | }, | |
| 245 | 241 | { | |
| 246 | 242 | name: "read_file", | |
| 247 | 243 | description: "Read file contents.", | |
| 248 | 244 | input_schema: { | |
| 249 | 245 | type: "object", | |
| 250 | 246 | properties: { path: { type: "string" }, limit: { type: "integer" } }, | |
| 251 | 247 | required: ["path"], | |
| 252 | 248 | }, | |
| 253 | 249 | }, | |
| 254 | 250 | { | |
| 255 | 251 | name: "write_file", | |
| 256 | 252 | description: "Write content to file.", | |
| 257 | 253 | input_schema: { | |
| 258 | 254 | type: "object", | |
| 259 | 255 | properties: { path: { type: "string" }, content: { type: "string" } }, | |
| 260 | 256 | required: ["path", "content"], | |
| 261 | 257 | }, | |
| 262 | 258 | }, | |
| 263 | 259 | { | |
| 264 | 260 | name: "edit_file", | |
| 265 | 261 | description: "Replace exact text in file.", | |
| 266 | 262 | input_schema: { | |
| 267 | 263 | type: "object", | |
| 268 | 264 | properties: { | |
| 269 | 265 | path: { type: "string" }, | |
| 270 | 266 | old_text: { type: "string" }, | |
| 271 | 267 | new_text: { type: "string" }, | |
| 272 | 268 | }, | |
| 273 | 269 | required: ["path", "old_text", "new_text"], | |
| 274 | 270 | }, | |
| 275 | 271 | }, | |
| 276 | 272 | { | |
| 277 | - | name: "load_skill", | |
| 278 | - | description: "Load specialized knowledge by name.", | |
| 273 | + | name: "compact", | |
| 274 | + | description: "Trigger manual conversation compression.", | |
| 279 | 275 | input_schema: { | |
| 280 | 276 | type: "object", | |
| 281 | 277 | properties: { | |
| 282 | - | name: { type: "string", description: "Skill name to load" }, | |
| 278 | + | focus: { type: "string", description: "What to preserve in the summary" }, | |
| 283 | 279 | }, | |
| 284 | - | required: ["name"], | |
| 285 | 280 | }, | |
| 286 | 281 | }, | |
| 287 | 282 | ]; | |
| 288 | 283 | ||
| 289 | - | function assistantText(content: Array<ToolUseBlock | TextBlock | ToolResultBlock>) { | |
| 284 | + | function assistantText(content: AssistantBlock[]) { | |
| 290 | 285 | return content | |
| 291 | 286 | .filter((block): block is TextBlock => block.type === "text") | |
| 292 | 287 | .map((block) => block.text) | |
| 293 | 288 | .join("\n"); | |
| 294 | 289 | } | |
| 295 | 290 | ||
| 296 | 291 | export async function agentLoop(messages: Message[]) { | |
| 297 | 292 | 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 | + | ||
| 298 | 300 | const response = await client.messages.create({ | |
| 299 | 301 | model: MODEL, | |
| 300 | 302 | system: SYSTEM, | |
| 301 | 303 | messages: messages as Anthropic.Messages.MessageParam[], | |
| 302 | 304 | tools: TOOLS as Anthropic.Messages.Tool[], | |
| 303 | 305 | max_tokens: 8000, | |
| 304 | 306 | }); | |
| 305 | 307 | ||
| 306 | 308 | messages.push({ | |
| 307 | 309 | role: "assistant", | |
| 308 | - | content: response.content as Array<ToolUseBlock | TextBlock>, | |
| 310 | + | content: response.content as AssistantBlock[], | |
| 309 | 311 | }); | |
| 310 | 312 | ||
| 311 | 313 | if (response.stop_reason !== "tool_use") { | |
| 312 | 314 | return; | |
| 313 | 315 | } | |
| 314 | 316 | ||
| 317 | + | let manualCompact = false; | |
| 315 | 318 | const results: ToolResultBlock[] = []; | |
| 319 | + | ||
| 316 | 320 | for (const block of response.content) { | |
| 317 | 321 | if (block.type !== "tool_use") continue; | |
| 318 | 322 | ||
| 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 | + | } | |
| 323 | 333 | ||
| 324 | 334 | console.log(`> ${block.name}: ${output.slice(0, 200)}`); | |
| 325 | 335 | results.push({ | |
| 326 | 336 | type: "tool_result", | |
| 327 | 337 | tool_use_id: block.id, | |
| 328 | 338 | content: output, | |
| 329 | 339 | }); | |
| 330 | 340 | } | |
| 331 | 341 | ||
| 332 | 342 | 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 | + | } | |
| 333 | 348 | } | |
| 334 | 349 | } | |
| 335 | 350 | ||
| 336 | 351 | async function main() { | |
| 337 | 352 | const rl = createInterface({ | |
| 338 | 353 | input: process.stdin, | |
| 339 | 354 | output: process.stdout, | |
| 340 | 355 | }); | |
| 341 | 356 | ||
| 342 | 357 | const history: Message[] = []; | |
| 343 | 358 | ||
| 344 | 359 | while (true) { | |
| 345 | 360 | let query = ""; | |
| 346 | 361 | try { | |
| 347 | - | query = await rl.question("\x1b[36ms05 >> \x1b[0m"); | |
| 362 | + | query = await rl.question("\x1b[36ms06 >> \x1b[0m"); | |
| 348 | 363 | } catch (error) { | |
| 349 | 364 | if ( | |
| 350 | 365 | error instanceof Error && | |
| 351 | 366 | (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") | |
| 352 | 367 | ) { | |
| 353 | 368 | break; | |
| 354 | 369 | } | |
| 355 | 370 | throw error; | |
| 356 | 371 | } | |
| 357 | 372 | if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { | |
| 358 | 373 | break; | |
| 359 | 374 | } | |
| 360 | 375 | ||
| 361 | 376 | history.push({ role: "user", content: query }); | |
| 362 | 377 | await agentLoop(history); | |
| 363 | 378 | ||
| 364 | 379 | const last = history[history.length - 1]?.content; | |
| 365 | 380 | if (Array.isArray(last)) { | |
| 366 | - | const text = assistantText(last); | |
| 381 | + | const text = assistantText(last as AssistantBlock[]); | |
| 367 | 382 | if (text) console.log(text); | |
| 368 | 383 | } | |
| 369 | 384 | console.log(); | |
| 370 | 385 | } | |
| 371 | 386 | ||
| 372 | 387 | rl.close(); | |
| 373 | 388 | } | |
| 374 | 389 | ||
| 375 | 390 | void main(); |