Back to Skills
Subagents → Skills
s04 (288 LOC) → s05 (321 LOC)
LOC Delta
+33lines
New Tools
1
load_skill
New Classes
1
SkillLoader
New Functions
2
parseFrontmattercollectSkillFiles
Subagents
Clean Context Per Subtask
288 LOC
5 tools: bash, read_file, write_file, edit_file, task
planningSkills
Load on Demand
321 LOC
5 tools: bash, read_file, write_file, edit_file, load_skill
planningSource Code Diff
s04 (s04_subagent.ts) -> s05 (s05_skill_loading.ts)
| 1 | 1 | #!/usr/bin/env node | |
| 2 | 2 | /** | |
| 3 | - | * s04_subagent.ts - Subagents | |
| 3 | + | * s05_skill_loading.ts - Skills | |
| 4 | 4 | * | |
| 5 | - | * Spawn a child agent with fresh messages=[]. | |
| 6 | - | * The child shares the filesystem, but returns only a short summary. | |
| 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. | |
| 7 | 8 | */ | |
| 8 | 9 | ||
| 9 | 10 | import { spawnSync } from "node:child_process"; | |
| 10 | - | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 11 | + | import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; | |
| 11 | 12 | import { resolve } from "node:path"; | |
| 12 | 13 | import process from "node:process"; | |
| 13 | 14 | import { createInterface } from "node:readline/promises"; | |
| 14 | 15 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 15 | 16 | import "dotenv/config"; | |
| 16 | 17 | import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; | |
| 17 | 18 | ||
| 18 | - | type BaseToolName = "bash" | "read_file" | "write_file" | "edit_file"; | |
| 19 | - | type ParentToolName = BaseToolName | "task"; | |
| 19 | + | type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "load_skill"; | |
| 20 | 20 | ||
| 21 | 21 | type ToolUseBlock = { | |
| 22 | 22 | id: string; | |
| 23 | 23 | type: "tool_use"; | |
| 24 | - | name: ParentToolName; | |
| 24 | + | name: ToolName; | |
| 25 | 25 | input: Record<string, unknown>; | |
| 26 | 26 | }; | |
| 27 | 27 | ||
| 28 | 28 | type TextBlock = { | |
| 29 | 29 | type: "text"; | |
| 30 | 30 | text: string; | |
| 31 | 31 | }; | |
| 32 | 32 | ||
| 33 | 33 | type ToolResultBlock = { | |
| 34 | 34 | type: "tool_result"; | |
| 35 | 35 | tool_use_id: string; | |
| 36 | 36 | content: string; | |
| 37 | 37 | }; | |
| 38 | 38 | ||
| 39 | 39 | type MessageContent = string | Array<ToolUseBlock | TextBlock | ToolResultBlock>; | |
| 40 | 40 | ||
| 41 | 41 | type Message = { | |
| 42 | 42 | role: "user" | "assistant"; | |
| 43 | 43 | content: MessageContent; | |
| 44 | 44 | }; | |
| 45 | 45 | ||
| 46 | + | type SkillRecord = { | |
| 47 | + | meta: Record<string, string>; | |
| 48 | + | body: string; | |
| 49 | + | path: string; | |
| 50 | + | }; | |
| 51 | + | ||
| 46 | 52 | const WORKDIR = process.cwd(); | |
| 47 | 53 | const MODEL = resolveModel(); | |
| 54 | + | const SKILLS_DIR = resolve(WORKDIR, "..", "skills"); | |
| 48 | 55 | const client = createAnthropicClient(); | |
| 49 | 56 | ||
| 50 | - | const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use the task tool to delegate exploration or subtasks.`); | |
| 51 | - | const SUBAGENT_SYSTEM = buildSystemPrompt(`You are a coding subagent at ${WORKDIR}. Complete the given task, then summarize your findings.`); | |
| 52 | - | ||
| 53 | 57 | function safePath(relativePath: string): string { | |
| 54 | 58 | const filePath = resolve(WORKDIR, relativePath); | |
| 55 | 59 | const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; | |
| 56 | 60 | if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { | |
| 57 | 61 | throw new Error(`Path escapes workspace: ${relativePath}`); | |
| 58 | 62 | } | |
| 59 | 63 | return filePath; | |
| 60 | 64 | } | |
| 61 | 65 | ||
| 62 | 66 | function runBash(command: string): string { | |
| 63 | 67 | const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; | |
| 64 | 68 | if (dangerous.some((item) => command.includes(item))) { | |
| 65 | 69 | return "Error: Dangerous command blocked"; | |
| 66 | 70 | } | |
| 67 | 71 | ||
| 68 | 72 | const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; | |
| 69 | 73 | const args = process.platform === "win32" | |
| 70 | 74 | ? ["/d", "/s", "/c", command] | |
| 71 | 75 | : ["-lc", command]; | |
| 72 | 76 | ||
| 73 | 77 | const result = spawnSync(shell, args, { | |
| 74 | 78 | cwd: WORKDIR, | |
| 75 | 79 | encoding: "utf8", | |
| 76 | 80 | timeout: 120_000, | |
| 77 | 81 | }); | |
| 78 | 82 | ||
| 79 | 83 | if (result.error?.name === "TimeoutError") { | |
| 80 | 84 | return "Error: Timeout (120s)"; | |
| 81 | 85 | } | |
| 82 | 86 | ||
| 83 | 87 | const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); | |
| 84 | 88 | return output.slice(0, 50_000) || "(no output)"; | |
| 85 | 89 | } | |
| 86 | 90 | ||
| 87 | 91 | function runRead(path: string, limit?: number): string { | |
| 88 | 92 | try { | |
| 89 | 93 | let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); | |
| 90 | 94 | if (limit && limit < lines.length) { | |
| 91 | 95 | lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); | |
| 92 | 96 | } | |
| 93 | 97 | return lines.join("\n").slice(0, 50_000); | |
| 94 | 98 | } catch (error) { | |
| 95 | 99 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 96 | 100 | } | |
| 97 | 101 | } | |
| 98 | 102 | ||
| 99 | 103 | function runWrite(path: string, content: string): string { | |
| 100 | 104 | try { | |
| 101 | 105 | const filePath = safePath(path); | |
| 102 | 106 | mkdirSync(resolve(filePath, ".."), { recursive: true }); | |
| 103 | 107 | writeFileSync(filePath, content, "utf8"); | |
| 104 | 108 | return `Wrote ${content.length} bytes`; | |
| 105 | 109 | } catch (error) { | |
| 106 | 110 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 107 | 111 | } | |
| 108 | 112 | } | |
| 109 | 113 | ||
| 110 | 114 | function runEdit(path: string, oldText: string, newText: string): string { | |
| 111 | 115 | try { | |
| 112 | 116 | const filePath = safePath(path); | |
| 113 | 117 | const content = readFileSync(filePath, "utf8"); | |
| 114 | 118 | if (!content.includes(oldText)) { | |
| 115 | 119 | return `Error: Text not found in ${path}`; | |
| 116 | 120 | } | |
| 117 | 121 | writeFileSync(filePath, content.replace(oldText, newText), "utf8"); | |
| 118 | 122 | return `Edited ${path}`; | |
| 119 | 123 | } catch (error) { | |
| 120 | 124 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 121 | 125 | } | |
| 122 | 126 | } | |
| 123 | 127 | ||
| 124 | - | const TOOL_HANDLERS: Record<BaseToolName, (input: Record<string, unknown>) => string> = { | |
| 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 | + | } | |
| 133 | + | ||
| 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 | + | } | |
| 142 | + | ||
| 143 | + | return { meta, body: match[2].trim() }; | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | function collectSkillFiles(dir: string): string[] { | |
| 147 | + | if (!existsSync(dir)) { | |
| 148 | + | return []; | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | const files: string[] = []; | |
| 152 | + | const stack = [dir]; | |
| 153 | + | ||
| 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; | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | if (stats.isFile() && entry === "SKILL.md") { | |
| 167 | + | files.push(entryPath); | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | return files.sort((a, b) => a.localeCompare(b)); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | class SkillLoader { | |
| 176 | + | skills: Record<string, SkillRecord> = {}; | |
| 177 | + | ||
| 178 | + | constructor(private skillsDir: string) { | |
| 179 | + | this.loadAll(); | |
| 180 | + | } | |
| 181 | + | ||
| 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 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | getDescriptions(): string { | |
| 194 | + | const entries = Object.entries(this.skills); | |
| 195 | + | if (entries.length === 0) { | |
| 196 | + | return "(no skills available)"; | |
| 197 | + | } | |
| 198 | + | ||
| 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"); | |
| 206 | + | } | |
| 207 | + | ||
| 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 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 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> = { | |
| 125 | 227 | bash: (input) => runBash(String(input.command ?? "")), | |
| 126 | 228 | read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), | |
| 127 | 229 | write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), | |
| 128 | 230 | edit_file: (input) => | |
| 129 | 231 | runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), | |
| 232 | + | load_skill: (input) => skillLoader.getContent(String(input.name ?? "")), | |
| 130 | 233 | }; | |
| 131 | 234 | ||
| 132 | - | const CHILD_TOOLS = [ | |
| 235 | + | const TOOLS = [ | |
| 133 | 236 | { | |
| 134 | 237 | name: "bash", | |
| 135 | 238 | description: shellToolDescription(), | |
| 136 | 239 | input_schema: { | |
| 137 | 240 | type: "object", | |
| 138 | 241 | properties: { command: { type: "string" } }, | |
| 139 | 242 | required: ["command"], | |
| 140 | 243 | }, | |
| 141 | 244 | }, | |
| 142 | 245 | { | |
| 143 | 246 | name: "read_file", | |
| 144 | 247 | description: "Read file contents.", | |
| 145 | 248 | input_schema: { | |
| 146 | 249 | type: "object", | |
| 147 | 250 | properties: { path: { type: "string" }, limit: { type: "integer" } }, | |
| 148 | 251 | required: ["path"], | |
| 149 | 252 | }, | |
| 150 | 253 | }, | |
| 151 | 254 | { | |
| 152 | 255 | name: "write_file", | |
| 153 | 256 | description: "Write content to file.", | |
| 154 | 257 | input_schema: { | |
| 155 | 258 | type: "object", | |
| 156 | 259 | properties: { path: { type: "string" }, content: { type: "string" } }, | |
| 157 | 260 | required: ["path", "content"], | |
| 158 | 261 | }, | |
| 159 | 262 | }, | |
| 160 | 263 | { | |
| 161 | 264 | name: "edit_file", | |
| 162 | 265 | description: "Replace exact text in file.", | |
| 163 | 266 | input_schema: { | |
| 164 | 267 | type: "object", | |
| 165 | 268 | properties: { | |
| 166 | 269 | path: { type: "string" }, | |
| 167 | 270 | old_text: { type: "string" }, | |
| 168 | 271 | new_text: { type: "string" }, | |
| 169 | 272 | }, | |
| 170 | 273 | required: ["path", "old_text", "new_text"], | |
| 171 | 274 | }, | |
| 172 | 275 | }, | |
| 173 | - | ]; | |
| 174 | - | ||
| 175 | - | const PARENT_TOOLS = [ | |
| 176 | - | ...CHILD_TOOLS, | |
| 177 | 276 | { | |
| 178 | - | name: "task", | |
| 179 | - | description: "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.", | |
| 277 | + | name: "load_skill", | |
| 278 | + | description: "Load specialized knowledge by name.", | |
| 180 | 279 | input_schema: { | |
| 181 | 280 | type: "object", | |
| 182 | 281 | properties: { | |
| 183 | - | prompt: { type: "string" }, | |
| 184 | - | description: { type: "string" }, | |
| 282 | + | name: { type: "string", description: "Skill name to load" }, | |
| 185 | 283 | }, | |
| 186 | - | required: ["prompt"], | |
| 284 | + | required: ["name"], | |
| 187 | 285 | }, | |
| 188 | 286 | }, | |
| 189 | 287 | ]; | |
| 190 | 288 | ||
| 191 | 289 | function assistantText(content: Array<ToolUseBlock | TextBlock | ToolResultBlock>) { | |
| 192 | 290 | return content | |
| 193 | 291 | .filter((block): block is TextBlock => block.type === "text") | |
| 194 | 292 | .map((block) => block.text) | |
| 195 | 293 | .join("\n"); | |
| 196 | 294 | } | |
| 197 | 295 | ||
| 198 | - | async function runSubagent(prompt: string): Promise<string> { | |
| 199 | - | const subMessages: Message[] = [{ role: "user", content: prompt }]; | |
| 200 | - | let response: Anthropic.Messages.Message | null = null; | |
| 201 | - | ||
| 202 | - | for (let attempt = 0; attempt < 30; attempt += 1) { | |
| 203 | - | response = await client.messages.create({ | |
| 204 | - | model: MODEL, | |
| 205 | - | system: SUBAGENT_SYSTEM, | |
| 206 | - | messages: subMessages as Anthropic.Messages.MessageParam[], | |
| 207 | - | tools: CHILD_TOOLS as Anthropic.Messages.Tool[], | |
| 208 | - | max_tokens: 8000, | |
| 209 | - | }); | |
| 210 | - | ||
| 211 | - | subMessages.push({ | |
| 212 | - | role: "assistant", | |
| 213 | - | content: response.content as Array<ToolUseBlock | TextBlock>, | |
| 214 | - | }); | |
| 215 | - | ||
| 216 | - | if (response.stop_reason !== "tool_use") { | |
| 217 | - | break; | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | const results: ToolResultBlock[] = []; | |
| 221 | - | for (const block of response.content) { | |
| 222 | - | if (block.type !== "tool_use" || block.name === "task") continue; | |
| 223 | - | const handler = TOOL_HANDLERS[block.name as BaseToolName]; | |
| 224 | - | const output = handler | |
| 225 | - | ? handler(block.input as Record<string, unknown>) | |
| 226 | - | : `Unknown tool: ${block.name}`; | |
| 227 | - | results.push({ | |
| 228 | - | type: "tool_result", | |
| 229 | - | tool_use_id: block.id, | |
| 230 | - | content: String(output).slice(0, 50_000), | |
| 231 | - | }); | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | subMessages.push({ role: "user", content: results }); | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | if (!response) return "(no summary)"; | |
| 238 | - | const texts: string[] = []; | |
| 239 | - | for (const block of response.content) { | |
| 240 | - | if (block.type === "text") texts.push(block.text); | |
| 241 | - | } | |
| 242 | - | return texts.join("") || "(no summary)"; | |
| 243 | - | } | |
| 244 | - | ||
| 245 | 296 | export async function agentLoop(messages: Message[]) { | |
| 246 | 297 | while (true) { | |
| 247 | 298 | const response = await client.messages.create({ | |
| 248 | 299 | model: MODEL, | |
| 249 | 300 | system: SYSTEM, | |
| 250 | 301 | messages: messages as Anthropic.Messages.MessageParam[], | |
| 251 | - | tools: PARENT_TOOLS as Anthropic.Messages.Tool[], | |
| 302 | + | tools: TOOLS as Anthropic.Messages.Tool[], | |
| 252 | 303 | max_tokens: 8000, | |
| 253 | 304 | }); | |
| 254 | 305 | ||
| 255 | 306 | messages.push({ | |
| 256 | 307 | role: "assistant", | |
| 257 | 308 | content: response.content as Array<ToolUseBlock | TextBlock>, | |
| 258 | 309 | }); | |
| 259 | 310 | ||
| 260 | 311 | if (response.stop_reason !== "tool_use") { | |
| 261 | 312 | return; | |
| 262 | 313 | } | |
| 263 | 314 | ||
| 264 | 315 | const results: ToolResultBlock[] = []; | |
| 265 | 316 | for (const block of response.content) { | |
| 266 | 317 | if (block.type !== "tool_use") continue; | |
| 267 | 318 | ||
| 268 | - | let output: string; | |
| 269 | - | if (block.name === "task") { | |
| 270 | - | const input = block.input as { description?: string; prompt?: string }; | |
| 271 | - | const description = String(input.description ?? "subtask"); | |
| 272 | - | console.log(`> task (${description}): ${String(input.prompt ?? "").slice(0, 80)}`); | |
| 273 | - | output = await runSubagent(String(input.prompt ?? "")); | |
| 274 | - | } else { | |
| 275 | - | const handler = TOOL_HANDLERS[block.name as BaseToolName]; | |
| 276 | - | output = handler | |
| 277 | - | ? handler(block.input as Record<string, unknown>) | |
| 278 | - | : `Unknown tool: ${block.name}`; | |
| 279 | - | } | |
| 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}`; | |
| 280 | 323 | ||
| 281 | - | console.log(` ${output.slice(0, 200)}`); | |
| 324 | + | console.log(`> ${block.name}: ${output.slice(0, 200)}`); | |
| 282 | 325 | results.push({ | |
| 283 | 326 | type: "tool_result", | |
| 284 | 327 | tool_use_id: block.id, | |
| 285 | 328 | content: output, | |
| 286 | 329 | }); | |
| 287 | 330 | } | |
| 288 | 331 | ||
| 289 | 332 | messages.push({ role: "user", content: results }); | |
| 290 | 333 | } | |
| 291 | 334 | } | |
| 292 | 335 | ||
| 293 | 336 | async function main() { | |
| 294 | 337 | const rl = createInterface({ | |
| 295 | 338 | input: process.stdin, | |
| 296 | 339 | output: process.stdout, | |
| 297 | 340 | }); | |
| 298 | 341 | ||
| 299 | 342 | const history: Message[] = []; | |
| 300 | 343 | ||
| 301 | 344 | while (true) { | |
| 302 | 345 | let query = ""; | |
| 303 | 346 | try { | |
| 304 | - | query = await rl.question("\x1b[36ms04 >> \x1b[0m"); | |
| 347 | + | query = await rl.question("\x1b[36ms05 >> \x1b[0m"); | |
| 305 | 348 | } catch (error) { | |
| 306 | 349 | if ( | |
| 307 | 350 | error instanceof Error && | |
| 308 | 351 | (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") | |
| 309 | 352 | ) { | |
| 310 | 353 | break; | |
| 311 | 354 | } | |
| 312 | 355 | throw error; | |
| 313 | 356 | } | |
| 314 | 357 | if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { | |
| 315 | 358 | break; | |
| 316 | 359 | } | |
| 317 | 360 | ||
| 318 | 361 | history.push({ role: "user", content: query }); | |
| 319 | 362 | await agentLoop(history); | |
| 320 | 363 | ||
| 321 | 364 | const last = history[history.length - 1]?.content; | |
| 322 | 365 | if (Array.isArray(last)) { | |
| 323 | 366 | const text = assistantText(last); | |
| 324 | 367 | if (text) console.log(text); | |
| 325 | 368 | } | |
| 326 | 369 | console.log(); | |
| 327 | 370 | } | |
| 328 | 371 | ||
| 329 | 372 | rl.close(); | |
| 330 | 373 | } | |
| 331 | 374 | ||
| 332 | 375 | void main(); |