agent-from-zero 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/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # mini-agent
2
+
3
+ 用 DeepSeek API 从零手写一个类 Claude Code 的命令行 Agent,TypeScript 实现。
4
+
5
+ 10 天,每天一个 commit。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 天收工时的完整代码。
6
+
7
+ > 代码、注释、commit message 用英文——模型的工具调用主要在英文数据上训练,英文指令引导更稳,也方便国际读者;配套教程是中文。
8
+
9
+ ## 快速开始
10
+
11
+ 最简方式(npm 包,包名 `agent-from-zero`,装完命令叫 `mini-agent`):
12
+
13
+ ```bash
14
+ echo 'DEEPSEEK_API_KEY=sk-你的key' > .env # key 在 platform.deepseek.com 获取
15
+ npx agent-from-zero
16
+ ```
17
+
18
+ 从源码跑:
19
+
20
+ ```bash
21
+ git clone https://github.com/Michael-crazyman/mini-agent.git && cd mini-agent
22
+ npm install
23
+ cp .env.example .env # 填入你的 DeepSeek API Key
24
+ npm start # 跑最新版
25
+ npm run eval # 10 道题的回归考试
26
+ ```
27
+
28
+ ## 换个模型用
29
+
30
+ 默认 DeepSeek,但任何 **OpenAI 兼容接口**都能跑,三个环境变量搞定:
31
+
32
+ ```bash
33
+ MINI_AGENT_API_KEY=sk-...
34
+ MINI_AGENT_BASE_URL=https://api.openai.com/v1 # OpenAI / Kimi / Qwen / GLM / OpenRouter / Ollama 均可
35
+ MINI_AGENT_MODEL=gpt-4.1-mini
36
+ ```
37
+
38
+ 各家 base URL 见 [.env.example](.env.example)。两个注意:模型必须支持 function calling;窗口比 DeepSeek 小的话设 `MINI_AGENT_CONTEXT_WINDOW`(单位 token)。换完跑一遍 `npm run eval`——同样 10 道题,正好当模型横评。
39
+
40
+ ## 进度
41
+
42
+ | Tag | 内容 |
43
+ |---|---|
44
+ | `day1` | ~50 行 raw tool-calling loop:一个工具、一个循环、两条保险 |
45
+ | `day2` | 工具五件套(read/write/edit/search/bash):编辑前必须先读、old_string 唯一性、永不抛异常的 dispatch、四要素工具说明书 |
46
+ | `day3` | 主循环状态机:错误分类、指数退避(带抖动)、双重重试预算、熔断器,8 种终态各配人话提示,Ctrl+C 体面退出 |
47
+ | `day4` | 权限系统(allow/ask/deny):禁飞区硬拦(.git/.ssh/.env)、deny 永远赢(bypass 也压不过)、非交互 fail closed、bash 按输入定危险等级、秘密文件不进上下文 |
48
+ | `day5` | 上下文管理:token 估算(字节法,宁多勿少)、80% 触发线、6 段结构化摘要(双保险禁工具)、文件恢复(最近 5 个,从磁盘重读)、主动+被动压缩、压缩熔断 |
49
+ | `day6` | 多轮 REPL(行队列输入、/clear、Ctrl+C 三态语义)、system prompt 宪法(数字锚/反面禁令/防虚报)、AGENT.md 项目记忆、流式输出 + 双重看门狗(90s 断流/30s 只警告) |
50
+ | `day7` | 子 agent(task 工具:独立上下文、一层分身不套娃、报告是材料不是结论、文件状态快照隔离)+ 最小 eval(10 道固定题、物证判分、8/10 及格线,`npm run eval`) |
51
+ | `day8` | 封装发布:`bin` 入口 + tsc 构建,npx 一行启动(包名 `agent-from-zero`);内置 .env 加载;缺 key 人话报错 |
52
+ | `day9` | 教程本身:11 章中文教程开源进 [docs/](docs/README.md) |
53
+
54
+ ## 📖 配套教程(免费开源)
55
+
56
+ **[完整教程在 docs/ 目录](docs/README.md)**——11 章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
57
+
58
+ 如果这个项目帮你搞懂了 agent,点个 ⭐ 就是最大的支持。
package/dist/agent.js ADDED
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ import OpenAI from "openai"; // the API client
3
+ import chalk from "chalk"; // terminal colors
4
+ import readline from "node:readline/promises"; // promise-based terminal input
5
+ import { CONFIG, requireApiKey } from "./config.js"; // provider-agnostic settings (.env loaded there)
6
+ import { runLoop, TerminateReason, MAX_ROUNDS, MAX_RETRIES } from "./loop.js"; // the state machine
7
+ import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
8
+ import { forgetFilesExcept } from "./tools.js"; // to reset file state on /clear
9
+ requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
10
+ // Build the API client once, for the whole session.
11
+ const client = new OpenAI({
12
+ baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via MINI_AGENT_BASE_URL
13
+ apiKey: CONFIG.apiKey, // from .env or the shell
14
+ maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
15
+ });
16
+ // What we tell the user for every way a query can end. No raw stack traces.
17
+ const EXIT_NOTES = {
18
+ [TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
19
+ [TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
20
+ [TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
21
+ [TerminateReason.RateLimitBudgetExhausted]: "DeepSeek keeps rate-limiting us. Wait a minute, then try again.",
22
+ [TerminateReason.ContextTooLong]: "The conversation no longer fits the model's context window, and compaction could not shrink it enough. Use /clear to start fresh.",
23
+ [TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
24
+ [TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
25
+ [TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
26
+ };
27
+ async function main() {
28
+ // The banner: who am I, which model and host am I on, and the three things worth knowing.
29
+ console.log(chalk.bold("mini-agent") +
30
+ chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | "exit" to quit · "/clear" to reset · Ctrl+C interrupts a running task`));
31
+ // ONE readline interface for the whole session — the task prompt and the
32
+ // permission prompts share it. Two interfaces on one stdin fight each other.
33
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
34
+ // Input plumbing: a line QUEUE instead of rl.question(). With piped stdin
35
+ // all lines arrive at once — question() would catch the first and lose the
36
+ // rest. The queue catches every line; asking pops one (or waits for one).
37
+ // Bonus: in a terminal, lines typed while a task runs queue up as the next
38
+ // commands instead of vanishing.
39
+ const pendingLines = []; // lines that arrived before anyone asked
40
+ const lineWaiters = []; // askers waiting for a line
41
+ let stdinDone = false; // has stdin ended?
42
+ rl.on("line", (line) => {
43
+ const waiter = lineWaiters.shift(); // is somebody waiting?
44
+ if (waiter)
45
+ waiter(line); // hand the line over directly
46
+ else
47
+ pendingLines.push(line); // nobody waiting — queue it
48
+ });
49
+ rl.on("close", () => {
50
+ stdinDone = true; // no more input will ever come
51
+ while (lineWaiters.length)
52
+ lineWaiters.shift()("exit"); // wake every waiter with a polite "exit"
53
+ });
54
+ // Get the next line of user input, showing a prompt if we have to wait.
55
+ const readUserLine = (prompt) => {
56
+ if (pendingLines.length)
57
+ return Promise.resolve(pendingLines.shift()); // typed-ahead (or piped) line — use it
58
+ if (stdinDone)
59
+ return Promise.resolve("exit"); // stdin is gone — leave politely
60
+ rl.setPrompt(prompt); // show the prompt the redraw-safe way
61
+ rl.prompt();
62
+ return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
63
+ };
64
+ // The system message is built ONCE per session: stable prefix = cache hits
65
+ // on every request. Nothing session-specific may sneak into it later.
66
+ const systemMessage = buildSystemMessage();
67
+ let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
68
+ // Per-task interrupt state. `running` decides what Ctrl+C means right now.
69
+ let running = false; // is a task currently executing?
70
+ let interrupted = false; // has the current task been interrupted?
71
+ let controller = new AbortController(); // aborts the in-flight API request
72
+ // Ctrl+C semantics, by state:
73
+ // - at the prompt: exit the session (standard REPL behavior)
74
+ // - while running: first press interrupts the task, second press force-quits
75
+ const onSigint = () => {
76
+ if (!running) {
77
+ console.log(); // end the prompt line cleanly
78
+ process.exit(0); // at the prompt, Ctrl+C = goodbye
79
+ }
80
+ if (interrupted)
81
+ process.exit(130); // second press while running: force quit (130 = killed by SIGINT)
82
+ interrupted = true; // first press: raise the flag...
83
+ controller.abort(); // ...and cancel the in-flight API request
84
+ console.log(chalk.yellow("\n(interrupt — finishing up, Ctrl+C again to force quit)")); // tell the user we heard them
85
+ };
86
+ process.on("SIGINT", onSigint); // covers non-TTY runs
87
+ rl.on("SIGINT", onSigint); // covers TTY raw mode, where readline swallows the signal itself
88
+ // Ask the human to approve a dangerous action — shares the session readline.
89
+ // Fail closed: in a non-interactive session nobody can say yes, so the
90
+ // answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
91
+ // denies were already enforced in permissions.ts before we get here.
92
+ const confirm = async (question) => {
93
+ console.log(chalk.yellow(`\n⚠️ approval needed — ${question}`)); // always show what is being asked
94
+ if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
95
+ console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
96
+ return true; // approve without asking
97
+ }
98
+ if (!process.stdin.isTTY) {
99
+ console.log(chalk.dim(" non-interactive session — denied by default (fail closed)")); // nobody can answer — refuse
100
+ return false; // fail closed
101
+ }
102
+ const answer = await readUserLine(" Allow? [y/N] "); // wait for the human (shares the line queue)
103
+ return /^y(es)?$/i.test(answer.trim()); // anything but y/yes means no
104
+ };
105
+ // The session loop: ask → run → render → ask again. This is what makes it
106
+ // a conversation instead of a one-shot command.
107
+ while (true) {
108
+ const line = (await readUserLine(chalk.green("\n> "))).trim(); // next input, or "exit" on EOF
109
+ if (!line)
110
+ continue; // empty line — just re-prompt
111
+ if (line === "exit" || line === "quit")
112
+ break; // explicit goodbye
113
+ if (line === "/clear") {
114
+ messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
115
+ forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
116
+ console.log(chalk.dim("(history cleared)")); // confirm the reset
117
+ continue;
118
+ }
119
+ messages.push({ role: "user", content: line }); // the new turn joins the shared history
120
+ running = true; // Ctrl+C now means "interrupt the task"
121
+ interrupted = false; // fresh interrupt state for this task
122
+ controller = new AbortController(); // fresh abort signal for this task
123
+ const result = await runLoop(messages, {
124
+ client, // the configured API client
125
+ model: CONFIG.model, // whatever the user configured — must support function calling
126
+ signal: controller.signal, // for aborting requests
127
+ isInterrupted: () => interrupted, // for stopping between steps
128
+ confirm, // for permission prompts
129
+ });
130
+ running = false; // back at the prompt — Ctrl+C means "exit" again
131
+ // Done already streamed its answer to the screen; everything else gets a
132
+ // one-line human explanation. The session continues either way — except a
133
+ // fatal API error, which no amount of chatting will fix.
134
+ if (result.reason !== TerminateReason.Done) {
135
+ console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[result.reason]}`)); // the human explanation
136
+ if (result.detail)
137
+ console.log(chalk.dim(` ${result.detail.slice(0, 300)}`)); // raw detail for the curious
138
+ if (result.reason === TerminateReason.FatalApiError)
139
+ break; // a bad key won't fix itself mid-session
140
+ }
141
+ }
142
+ rl.close(); // release stdin
143
+ console.log(chalk.dim("bye.")); // a clean goodbye
144
+ }
145
+ main(); // kick everything off
package/dist/config.js ADDED
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs"; // for the minimal .env loader
2
+ import chalk from "chalk"; // colored error output
3
+ // Minimal .env loader: lets `npx mini-agent` work in any directory that has a
4
+ // .env file — no dotenv dependency, no --env-file flag to remember.
5
+ // Lives here (not in agent.ts) so the eval harness gets it for free too.
6
+ try {
7
+ for (const line of fs.readFileSync(".env", "utf8").split("\n")) {
8
+ const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/); // KEY=value, nothing fancier
9
+ if (m && !process.env[m[1]])
10
+ process.env[m[1]] = m[2]; // real env vars always win over the file
11
+ }
12
+ }
13
+ catch {
14
+ /* no .env here — the variables may come from the shell instead */
15
+ }
16
+ // Provider-agnostic configuration. mini-agent talks the OpenAI wire protocol,
17
+ // so ANY compatible endpoint works — DeepSeek, OpenAI, Kimi, Qwen, GLM,
18
+ // OpenRouter, a local Ollama... three variables and you're on a new model.
19
+ //
20
+ // Resolution order, most specific wins:
21
+ // MINI_AGENT_* (generic) > DEEPSEEK_* (back-compat) > DeepSeek defaults
22
+ export const CONFIG = {
23
+ baseURL: process.env.MINI_AGENT_BASE_URL || // bring your own provider...
24
+ process.env.DEEPSEEK_BASE_URL || // ...or the original DeepSeek override (kept for back-compat and tests)
25
+ "https://api.deepseek.com", // the default provider
26
+ apiKey: process.env.MINI_AGENT_API_KEY || // generic key wins...
27
+ process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
28
+ "", // empty = missing; requireApiKey() turns that into a helpful error
29
+ model: process.env.MINI_AGENT_MODEL || "deepseek-chat", // must support function calling
30
+ };
31
+ // No key, no point: fail with instructions instead of a stack trace later.
32
+ export function requireApiKey() {
33
+ if (CONFIG.apiKey)
34
+ return; // all good
35
+ console.error(chalk.red("Missing API key."));
36
+ console.error("mini-agent works with any OpenAI-compatible provider. Either:");
37
+ console.error(" echo 'DEEPSEEK_API_KEY=sk-...' > .env (DeepSeek, the default)");
38
+ console.error(" or set MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL");
39
+ console.error(" to use OpenAI, Kimi, Qwen, GLM, OpenRouter, Ollama... (see .env.example)");
40
+ process.exit(1);
41
+ }
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs"; // re-reading files from disk during recovery
2
+ import chalk from "chalk"; // status lines
3
+ import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
4
+ // ---- The context window ------------------------------------------------------
5
+ // 1,048,565 came from a real API error message, not from documentation.
6
+ // Trust what the API tells you over what you remember reading.
7
+ export const CONTEXT_WINDOW = Number(process.env.MINI_AGENT_CONTEXT_WINDOW || 1_048_565); // overridable for other models
8
+ // Compact well before the hard limit: estimation is approximate and the summary
9
+ // itself needs room. 80% is deliberately conservative.
10
+ export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // overridable so tests can trigger compaction cheaply
11
+ // How many times one query may compact / fail to compact before we give up.
12
+ export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
13
+ export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
14
+ // ---- Token estimation ---------------------------------------------------------
15
+ // Estimation is a safety mechanism, not an optimization: overestimating costs
16
+ // one early compaction; underestimating costs a failed request. Always round up.
17
+ export function estimateTokens(text, dense = false) {
18
+ const bytes = Buffer.byteLength(text, "utf8"); // Chinese/emoji take multiple bytes — count bytes, not chars
19
+ return Math.ceil(bytes / (dense ? 2 : 4)); // JSON-ish text packs ~2 bytes/token, prose/code ~4 — and we round UP
20
+ }
21
+ // Estimate the whole conversation, message by message.
22
+ export function estimateHistoryTokens(messages) {
23
+ let total = 0; // running sum
24
+ for (const m of messages) {
25
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); // content can be structured — serialize it
26
+ const isDense = m.role === "tool"; // tool results are JSON-ish → denser tokens
27
+ total += estimateTokens(content, isDense) + 8; // +8 per message for role/formatting overhead
28
+ if ("tool_calls" in m && m.tool_calls)
29
+ total += estimateTokens(JSON.stringify(m.tool_calls), true); // count the call arguments too
30
+ }
31
+ return total;
32
+ }
33
+ // ---- The summary prompt ---------------------------------------------------------
34
+ // Six numbered sections. Each exists because losing that piece of information
35
+ // causes a specific, real failure mode after compaction (e.g. losing section 4
36
+ // means the agent re-tries approaches that already failed).
37
+ const SUMMARY_PROMPT = `You are about to lose your conversation history. Summarize it so the work can continue seamlessly in a fresh context.
38
+ CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Tool calls will be REJECTED and will waste your only turn.
39
+
40
+ Write exactly these 6 sections:
41
+ 1. Primary request and intent — what the user originally asked for, as precisely as possible
42
+ 2. Key technical context — languages, paths, commands, constraints that matter
43
+ 3. Files read or edited — exact paths, and what was changed in each
44
+ 4. Errors and fixes — every error hit so far and how it was (or was not) resolved
45
+ 5. Current state — what has just been done, what is in progress
46
+ 6. Next step — the single most likely next action
47
+
48
+ CRITICAL: TEXT ONLY. No tool calls.`;
49
+ // ---- Post-compaction file recovery ------------------------------------------------
50
+ const RECOVER_MAX_FILES = 5; // restore at most this many recently-used files
51
+ const RECOVER_FILE_CHARS = 4000; // per-file cap
52
+ const RECOVER_TOTAL_CHARS = 16000; // total cap across all recovered files
53
+ // Compaction may forget the conversation, but it must not forget which files
54
+ // were being worked on. Restore the most recent ones — re-read FROM DISK,
55
+ // never from memory: another process may have changed them since we last looked.
56
+ export function recoverFileState() {
57
+ const candidates = recentFiles(RECOVER_MAX_FILES); // most recently touched first
58
+ const blocks = []; // formatted file blocks for the model
59
+ const recovered = []; // paths that actually made it in
60
+ let total = 0; // chars used so far
61
+ for (const p of candidates) {
62
+ if (!fs.existsSync(p))
63
+ continue; // the file may have been deleted — trust the disk
64
+ const content = fs.readFileSync(p, "utf8").slice(0, RECOVER_FILE_CHARS); // fresh from disk, capped
65
+ if (total + content.length > RECOVER_TOTAL_CHARS)
66
+ break; // budget exhausted — stop here
67
+ total += content.length; // account for it
68
+ recovered.push(p); // this one made it in
69
+ blocks.push(`--- ${p} (re-read from disk, may be truncated) ---\n${content}`); // format the block
70
+ }
71
+ forgetFilesExcept(recovered); // files NOT recovered must be re-read before any future edit
72
+ if (!blocks.length)
73
+ return null; // nothing worth recovering
74
+ return `[Recovered file state after compaction — most recently used files, re-read fresh from disk:]\n\n${blocks.join("\n\n")}`;
75
+ }
76
+ // ---- Compaction itself -----------------------------------------------------------
77
+ // Replace the whole history with a structured summary + recovered file state.
78
+ // Throws on failure — the loop decides what a failure means.
79
+ export async function compactHistory(messages, // the history, mutated in place
80
+ client, // the same client the loop uses
81
+ model, // the same model summarizes its own conversation
82
+ signal) {
83
+ const before = estimateHistoryTokens(messages); // for the log line
84
+ console.log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
85
+ const res = await client.chat.completions.create({
86
+ model, // same model — no need for a fancier one to summarize
87
+ messages: [...messages, { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
88
+ // Deliberately NO `tools` parameter: with no tools declared, the API
89
+ // cannot accept a tool call — that is the hard guarantee. The CRITICAL
90
+ // lines in the prompt are the soft second layer of the same defense.
91
+ }, { signal });
92
+ const summary = res.choices[0].message.content ?? ""; // the structured summary text
93
+ if (!summary.trim())
94
+ throw new Error("compaction returned an empty summary"); // empty summary = failed compaction
95
+ // The constitution survives compaction: keep the leading system message and
96
+ // drop everything else. Losing the system prompt would silently change the
97
+ // agent's behavior mid-session.
98
+ const system = messages[0]?.role === "system" ? [messages[0]] : []; // there is at most one, at index 0
99
+ messages.length = 0; // drop the old history entirely
100
+ messages.push(...system); // the constitution goes back first
101
+ messages.push({ role: "user", content: `[Context was compacted. Summary of the conversation so far:]\n\n${summary}` }); // the summary becomes the new history
102
+ const recoveredNote = recoverFileState(); // restore working-file contents from disk
103
+ if (recoveredNote)
104
+ messages.push({ role: "user", content: recoveredNote }); // attach as a second message
105
+ console.log(chalk.magenta(`📦 compacted: ~${before} → ~${estimateHistoryTokens(messages)} tokens`)); // report the result
106
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,53 @@
1
+ import OpenAI from "openai"; // imported for its typed error classes (APIError and friends)
2
+ // Every failure gets a named kind, because every kind has a different recovery
3
+ // strategy and a different retry budget. "Error rate is up" takes hours to
4
+ // debug; "rate_limited jumped from 0 to 5%" takes minutes.
5
+ export var ApiErrorKind;
6
+ (function (ApiErrorKind) {
7
+ ApiErrorKind["Network"] = "network";
8
+ ApiErrorKind["Timeout"] = "timeout";
9
+ ApiErrorKind["RateLimited"] = "rate_limited";
10
+ ApiErrorKind["ServerError"] = "server_error";
11
+ ApiErrorKind["ContextTooLong"] = "context_too_long";
12
+ ApiErrorKind["AuthFailed"] = "auth_failed";
13
+ ApiErrorKind["BadRequest"] = "bad_request";
14
+ ApiErrorKind["Aborted"] = "aborted";
15
+ ApiErrorKind["Unknown"] = "unknown";
16
+ })(ApiErrorKind || (ApiErrorKind = {}));
17
+ // Providers phrase "your input is too big" in many ways — match loosely.
18
+ const CONTEXT_TOO_LONG_RE = /context.*length|maximum.*(context|length|tokens)|too (long|large)|exceed/i;
19
+ // Translate whatever the SDK throws into exactly one ClassifiedError.
20
+ export function classifyError(err) {
21
+ const message = err instanceof Error ? err.message : String(err); // normalize: not everything thrown is an Error
22
+ // User cancellation. The SDK has a dedicated class for it, but an aborted
23
+ // fetch can also surface as a plain Error named "AbortError" — catch both.
24
+ if (err instanceof OpenAI.APIUserAbortError || (err instanceof Error && err.name === "AbortError")) {
25
+ return { kind: ApiErrorKind.Aborted, retryable: false, message }; // not an error to recover from
26
+ }
27
+ // Timeout is checked before Network because it is a subclass of it.
28
+ if (err instanceof OpenAI.APIConnectionTimeoutError) {
29
+ return { kind: ApiErrorKind.Timeout, retryable: true, message }; // slow network — try again
30
+ }
31
+ // Could not reach the server at all (DNS, connection refused, reset...).
32
+ if (err instanceof OpenAI.APIConnectionError) {
33
+ return { kind: ApiErrorKind.Network, retryable: true, message }; // transient — try again
34
+ }
35
+ // The server answered with an error — classify by HTTP status code.
36
+ if (err instanceof OpenAI.APIError) {
37
+ const status = err.status ?? 0; // some APIError instances carry no status — treat as 0
38
+ if (status === 429)
39
+ return { kind: ApiErrorKind.RateLimited, retryable: true, message }; // server says: back off
40
+ if (status === 401 || status === 403)
41
+ return { kind: ApiErrorKind.AuthFailed, retryable: false, message }; // bad key — retries are useless
42
+ if (status === 400 && CONTEXT_TOO_LONG_RE.test(message)) {
43
+ return { kind: ApiErrorKind.ContextTooLong, retryable: false, message }; // needs compaction, not retries
44
+ }
45
+ if (status >= 500)
46
+ return { kind: ApiErrorKind.ServerError, retryable: true, message }; // their side broke — retry
47
+ if (status >= 400)
48
+ return { kind: ApiErrorKind.BadRequest, retryable: false, message }; // our request is wrong — don't
49
+ }
50
+ // Default for the unknown: retry, but it still consumes the budgets —
51
+ // an unknown error that keeps happening will trip the circuit breaker.
52
+ return { kind: ApiErrorKind.Unknown, retryable: true, message };
53
+ }
package/dist/loop.js ADDED
@@ -0,0 +1,311 @@
1
+ import chalk from "chalk"; // terminal colors for status lines
2
+ import ora from "ora"; // the "thinking..." spinner while waiting for the first token
3
+ import { toolDefinitions, dispatch, snapshotFileState, restoreFileState } from "./tools.js"; // the tool manuals + the executor + file-state isolation
4
+ import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
5
+ import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
6
+ import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
7
+ import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
8
+ export const MAX_ROUNDS = 15; // model→tool round cap per query
9
+ export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
10
+ export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
11
+ export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
12
+ const BACKOFF_BASE_MS = 500; // first retry waits ~this long
13
+ const BACKOFF_CAP_MS = 15_000; // no single wait longer than this
14
+ const IDLE_TIMEOUT_MS = 90_000; // no stream events AT ALL for this long → the stream is dead, cut it
15
+ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is not dead — cutting a live stream wastes every token already paid for
16
+ // A query has many ways to die. Name every one of them — each gets its own
17
+ // user-facing explanation and exit code, instead of a generic "error".
18
+ export var TerminateReason;
19
+ (function (TerminateReason) {
20
+ TerminateReason["Done"] = "done";
21
+ TerminateReason["RoundCap"] = "round_cap";
22
+ TerminateReason["CircuitBreaker"] = "circuit_breaker";
23
+ TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
24
+ TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
25
+ TerminateReason["ContextTooLong"] = "context_too_long";
26
+ TerminateReason["CompactionFailed"] = "compaction_failed";
27
+ TerminateReason["FatalApiError"] = "fatal_api_error";
28
+ TerminateReason["UserInterrupt"] = "user_interrupt";
29
+ })(TerminateReason || (TerminateReason = {}));
30
+ // The task tool: the parent's handle on sub-agents. Defined here (not in
31
+ // tools.ts) because running it needs the loop itself — it IS a loop.
32
+ const taskTool = {
33
+ type: "function",
34
+ function: {
35
+ name: "task",
36
+ description: `Delegate one self-contained subtask to a sub-agent that works in a FRESH context.
37
+ The sub-agent has the same tools (except task) but knows NOTHING about this conversation — put every detail it needs into the description.
38
+ Use it for exploration that would flood your context: reading many files, broad searches, summarizing a directory.
39
+ The sub-agent's report is INPUT MATERIAL, not verified truth — re-check key claims yourself before acting on them.
40
+ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a clearer description, or do the work yourself.`,
41
+ parameters: {
42
+ type: "object",
43
+ properties: {
44
+ description: { type: "string", description: "The complete, self-contained task for the sub-agent" },
45
+ },
46
+ required: ["description"],
47
+ },
48
+ },
49
+ };
50
+ // Run one sub-agent: a fresh conversation, same tools minus task, same
51
+ // permission gate, and the parent's file read-state protected by a snapshot.
52
+ async function runSubAgent(description, opts) {
53
+ if (!opts.quiet)
54
+ console.log(chalk.blue(` ⎿ sub-agent started: ${description.slice(0, 100)}`)); // show the delegation
55
+ const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
56
+ try {
57
+ const subMessages = [
58
+ { role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
59
+ { role: "user", content: description }, // the task is its entire world
60
+ ];
61
+ const result = await runLoop(subMessages, { ...opts, subAgent: true }); // recurse, flagged as sub-agent
62
+ if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
63
+ // The framing matters: the parent must treat this as material to verify,
64
+ // not as conclusions to copy — the most common multi-agent failure mode.
65
+ return `Sub-agent report (INPUT MATERIAL — verify key claims before acting on them):\n${result.finalText}`;
66
+ }
67
+ return `[sub-agent failed: ${result.reason}]`; // any non-Done ending, compressed to one line
68
+ }
69
+ finally {
70
+ restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
71
+ if (!opts.quiet)
72
+ console.log(chalk.blue(" ⎿ sub-agent done")); // close the bracket
73
+ }
74
+ }
75
+ // Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
76
+ // Without jitter, every client that failed at the same second retries at the
77
+ // same second — a thundering herd against an already-struggling server.
78
+ function backoffMs(consecutiveFailures) {
79
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1), BACKOFF_CAP_MS); // grow exponentially, then cap
80
+ return Math.round(base * (1 + Math.random() * 0.25)); // add 0–25% random jitter
81
+ }
82
+ // Sleep in small slices so Ctrl+C never has to wait out a long backoff.
83
+ async function interruptibleSleep(ms, isInterrupted) {
84
+ const SLICE = 200; // check for interruption every 200ms
85
+ for (let waited = 0; waited < ms && !isInterrupted(); waited += SLICE) {
86
+ await new Promise((r) => setTimeout(r, Math.min(SLICE, ms - waited))); // sleep one slice (or the remainder)
87
+ }
88
+ }
89
+ // One streaming model call: prints text as it arrives, assembles tool calls
90
+ // from their deltas, and guards the stream with a two-level watchdog.
91
+ async function streamModelCall(messages, // the full history to send
92
+ opts) {
93
+ const idleAbort = new AbortController(); // the watchdog's own kill switch
94
+ const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
95
+ const spinner = opts.quiet
96
+ ? null // the eval harness wants silence
97
+ : ora({ text: opts.subAgent ? "sub-agent working..." : "thinking...", discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
98
+ let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
99
+ let stallWarned = false; // only warn once per quiet stretch
100
+ // The two-level watchdog, checked once per second:
101
+ // - idle (nothing at all for 90s) → cut the stream, let the retry layer handle it
102
+ // - stall (slow but alive for 30s) → log it and keep waiting
103
+ const watchdog = setInterval(() => {
104
+ const quietMs = Date.now() - lastEvent; // ms since the last event
105
+ if (quietMs > IDLE_TIMEOUT_MS)
106
+ idleAbort.abort(); // dead — cut it
107
+ else if (quietMs > STALL_WARN_MS && !stallWarned) {
108
+ stallWarned = true; // don't repeat the warning every second
109
+ console.log(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
110
+ }
111
+ }, 1000);
112
+ try {
113
+ const stream = await opts.client.chat.completions.create(
114
+ // Sub-agents do NOT get the task tool: one level of delegation only.
115
+ // Nested spawning means orphan processes and debugging hell.
116
+ { model: opts.model, messages, tools: opts.subAgent ? toolDefinitions : [...toolDefinitions, taskTool], stream: true }, { signal });
117
+ let content = ""; // accumulated answer text
118
+ let printedPrefix = false; // have we printed the 🤖 prefix yet?
119
+ const calls = []; // tool calls under assembly, indexed by delta.index
120
+ for await (const chunk of stream) {
121
+ lastEvent = Date.now(); // feed the watchdog
122
+ stallWarned = false; // the stream spoke — reset the stall warning
123
+ const delta = chunk.choices[0]?.delta; // this chunk's increment
124
+ if (!delta)
125
+ continue; // keep-alive or usage chunk — nothing to do
126
+ if (delta.content) {
127
+ if (spinner?.isSpinning)
128
+ spinner.stop(); // first token: replace the spinner with real output
129
+ if (!opts.quiet && !opts.subAgent) {
130
+ // Only the top-level agent streams to the screen — a sub-agent's
131
+ // inner monologue would be mistaken for the answer.
132
+ if (!printedPrefix) {
133
+ process.stdout.write("\n🤖 "); // prefix once, then stream raw
134
+ printedPrefix = true;
135
+ }
136
+ process.stdout.write(delta.content); // print the token immediately — this IS the streaming UX
137
+ }
138
+ content += delta.content; // always keep it for the history
139
+ }
140
+ for (const tc of delta.tool_calls ?? []) {
141
+ if (spinner?.isSpinning)
142
+ spinner.stop(); // tool call starting — spinner served its purpose
143
+ const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
144
+ if (tc.id)
145
+ slot.id = tc.id; // id arrives once
146
+ if (tc.function?.name)
147
+ slot.name += tc.function.name; // name usually arrives whole; += is safe either way
148
+ if (tc.function?.arguments)
149
+ slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
150
+ }
151
+ }
152
+ if (printedPrefix)
153
+ process.stdout.write("\n"); // end the streamed line cleanly
154
+ return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
155
+ }
156
+ finally {
157
+ clearInterval(watchdog); // always stop the timer
158
+ if (spinner?.isSpinning)
159
+ spinner.stop(); // and never leave a zombie spinner
160
+ }
161
+ }
162
+ // Execute one approved tool call. The task tool is special — it is not in the
163
+ // registry because running it needs the loop itself (it IS a loop).
164
+ async function execute(call, opts) {
165
+ if (call.name !== "task")
166
+ return dispatch(call.name, call.args); // ordinary tools go through the registry
167
+ if (opts.subAgent)
168
+ return "[error] Sub-agents cannot spawn sub-agents. Do the work yourself."; // one level of delegation only
169
+ let description = ""; // the sub-task text
170
+ try {
171
+ description = JSON.parse(call.args).description ?? ""; // arguments arrive as JSON
172
+ }
173
+ catch {
174
+ /* fall through to the error below */
175
+ }
176
+ if (!description)
177
+ return "[error] task requires a non-empty description argument.";
178
+ return runSubAgent(description, opts); // spawn the worker
179
+ }
180
+ // Run one compaction attempt and keep score. Returns true on success.
181
+ // Every automatic behavior keeps a failure count — that is what lets the
182
+ // caller stop a hot failure loop instead of compacting forever.
183
+ async function tryCompact(messages, // history to compact (mutated in place)
184
+ opts, // for client/model/signal
185
+ compaction) {
186
+ try {
187
+ await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
188
+ compaction.count++; // one more successful compaction this query
189
+ compaction.failures = 0; // success resets the failure streak
190
+ return true;
191
+ }
192
+ catch {
193
+ compaction.failures++; // failed — count it against the breaker
194
+ return false;
195
+ }
196
+ }
197
+ // The agent main loop, as a state machine: every iteration either continues
198
+ // for a named reason or terminates for a named reason — nothing implicit.
199
+ export async function runLoop(messages, // conversation history (mutated in place)
200
+ opts) {
201
+ // The loop's mutable state: budgets and counters, rewritten every iteration.
202
+ const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
203
+ const compaction = { count: 0, failures: 0 }; // compaction score card for this query
204
+ for (let round = 1; round <= MAX_ROUNDS; round++) {
205
+ // One round = one successful model call + its tool results.
206
+ // The inner loop retries the model call until it succeeds or a budget dies.
207
+ while (true) {
208
+ if (opts.isInterrupted())
209
+ return { reason: TerminateReason.UserInterrupt }; // user asked us to stop — obey before spending money
210
+ // Proactive compaction: act BEFORE the API rejects us. Waiting for the
211
+ // hard limit means the failure already happened.
212
+ if (estimateHistoryTokens(messages) > COMPACT_AT) {
213
+ if (compaction.count >= MAX_COMPACTIONS_PER_QUERY)
214
+ return { reason: TerminateReason.CompactionFailed, detail: `already compacted ${compaction.count}x this query — the task is too big for one session` };
215
+ const ok = await tryCompact(messages, opts, compaction); // shrink the history
216
+ if (!ok && compaction.failures >= MAX_COMPACT_FAILURES)
217
+ return { reason: TerminateReason.CompactionFailed, detail: `${compaction.failures} consecutive compaction failures` }; // the compaction circuit breaker
218
+ }
219
+ let out; // the assembled reply for this round
220
+ try {
221
+ out = await streamModelCall(messages, opts); // streaming call with spinner + watchdog
222
+ }
223
+ catch (err) {
224
+ // Interrupt first: an aborted request can surface as all kinds of
225
+ // errors, and none of them deserve a retry line.
226
+ if (opts.isInterrupted())
227
+ return { reason: TerminateReason.UserInterrupt };
228
+ let e = classifyError(err); // turn the raw exception into a named kind
229
+ if (e.kind === ApiErrorKind.Aborted) {
230
+ // The user did NOT press Ctrl+C (checked above), so this abort came
231
+ // from the idle watchdog — treat it as a retryable timeout.
232
+ e = { kind: ApiErrorKind.Timeout, retryable: true, message: "stream went silent for 90s — cut by the idle watchdog" };
233
+ }
234
+ if (e.kind === ApiErrorKind.ContextTooLong) {
235
+ // Reactive compaction: the API just told us we're too big — our
236
+ // estimate was wrong. Compact and retry instead of giving up.
237
+ if (compaction.count < MAX_COMPACTIONS_PER_QUERY && (await tryCompact(messages, opts, compaction)))
238
+ continue; // history is smaller now — retry the same round
239
+ return { reason: TerminateReason.ContextTooLong, detail: e.message }; // compaction could not save us
240
+ }
241
+ if (!e.retryable)
242
+ return { reason: TerminateReason.FatalApiError, detail: `${e.kind}: ${e.message}` }; // bad key etc. — stop now
243
+ attempts.total++; // every failure consumes the overall budget
244
+ attempts.consecutive++; // ...and the breaker counter
245
+ if (e.kind === ApiErrorKind.RateLimited)
246
+ attempts.rateLimited++; // 429s also consume their own budget
247
+ // Order matters: the breaker fires first (hot failure loop), then the
248
+ // specific 429 budget, then the overall budget.
249
+ if (attempts.consecutive >= MAX_CONSECUTIVE_FAILURES)
250
+ return { reason: TerminateReason.CircuitBreaker, detail: `${e.kind}: ${e.message}` };
251
+ if (attempts.rateLimited >= MAX_RATE_LIMIT_RETRIES)
252
+ return { reason: TerminateReason.RateLimitBudgetExhausted, detail: e.message };
253
+ if (attempts.total >= MAX_RETRIES)
254
+ return { reason: TerminateReason.RetryBudgetExhausted, detail: `${e.kind}: ${e.message}` };
255
+ const wait = backoffMs(attempts.consecutive); // how long to back off this time
256
+ console.log(chalk.dim(` [retry] ${e.kind} — attempt ${attempts.total}/${MAX_RETRIES}, waiting ${wait}ms`)); // retries must be visible, or the app just looks frozen
257
+ await interruptibleSleep(wait, opts.isInterrupted); // wait, but stay responsive to Ctrl+C
258
+ continue; // retry the same round
259
+ }
260
+ // An aborted stream does not always throw — sometimes it just ends
261
+ // early and looks like success. Check the flag explicitly, and keep the
262
+ // partial text in history (clearly marked) so the next turn makes sense.
263
+ if (opts.isInterrupted()) {
264
+ if (out.content)
265
+ messages.push({ role: "assistant", content: out.content + "\n[interrupted by user]" }); // text only — partial tool calls must NOT go in (they would need paired results)
266
+ return { reason: TerminateReason.UserInterrupt };
267
+ }
268
+ attempts.consecutive = 0; // success resets the breaker — but never the total budget
269
+ // Rebuild the assistant message from the assembled stream and add it to
270
+ // history — tool results must stay paired with their calls.
271
+ messages.push({
272
+ role: "assistant", // the model's turn
273
+ content: out.content || null, // null when the turn was tool calls only
274
+ ...(out.toolCalls.length
275
+ ? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
276
+ : {}), // omit the field entirely when there were no calls
277
+ });
278
+ if (!out.toolCalls.length) {
279
+ return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
280
+ }
281
+ // Execute every tool call, each behind the permission gate.
282
+ for (const call of out.toolCalls) {
283
+ const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
284
+ if (!opts.quiet)
285
+ console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
286
+ // The permission gate sits between the model's intent and execution.
287
+ const v = checkPermission(call.name, call.args);
288
+ let content; // what goes back to the model as the tool result
289
+ if (v.decision === "deny") {
290
+ if (!opts.quiet)
291
+ console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
292
+ content = `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`; // teach the model the boundary
293
+ }
294
+ else if (v.decision === "ask") {
295
+ const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
296
+ if (!ok && !opts.quiet)
297
+ console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
298
+ content = ok
299
+ ? await execute(call, opts) // approved — actually run it
300
+ : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
301
+ }
302
+ else {
303
+ content = await execute(call, opts); // allow — run without ceremony
304
+ }
305
+ messages.push({ role: "tool", tool_call_id: call.id, content }); // feed the result back, paired by id
306
+ }
307
+ break; // round complete, move to the next one
308
+ }
309
+ }
310
+ return { reason: TerminateReason.RoundCap }; // ran out of rounds before a final answer
311
+ }
@@ -0,0 +1,106 @@
1
+ import path from "node:path"; // used to resolve and split file paths
2
+ // ---- The no-fly zone --------------------------------------------------------
3
+ // Writes here are never OK, in any mode. .git can destroy history, shell rc
4
+ // files inject code into every future terminal, .ssh/.env leak credentials.
5
+ const NO_FLY_DIRS = new Set([".git", ".ssh", ".claude"]); // directory names that must never be written into
6
+ const NO_FLY_FILES = new Set([".env", ".bashrc", ".zshrc", ".gitconfig", ".mcp.json"]); // file names that must never be written
7
+ // Files whose CONTENT must never enter the model's context: once a secret is
8
+ // in the conversation, it is in every later API request (and maybe in logs).
9
+ const SECRET_FILE_RE = /(^|\/)(\.env[^/]*|id_rsa[^/]*|[^/]+\.(pem|key))$/;
10
+ // Does this path touch the no-fly zone? Returns the reason, or null if clean.
11
+ function noFlyHit(p) {
12
+ const abs = path.resolve(p); // normalize to an absolute path first
13
+ for (const part of abs.split(path.sep)) {
14
+ // match whole path segments — ".github" must NOT be caught by ".git"
15
+ if (NO_FLY_DIRS.has(part))
16
+ return `${part}/ is a no-fly zone`;
17
+ }
18
+ if (NO_FLY_FILES.has(path.basename(abs)))
19
+ return `${path.basename(abs)} is a no-fly zone file`; // exact file-name match
20
+ return null; // not in the zone
21
+ }
22
+ // ---- Bash command analysis --------------------------------------------------
23
+ // Input-aware safety: run_bash is not one tool, it is a thousand tools wearing
24
+ // a coat. "ls" and "rm -rf" deserve different treatment, so we look at the input.
25
+ // Hard stops. Not a question — these never run.
26
+ const BASH_DENY = [
27
+ [/\brm\s+(-[a-zA-Z]+\s+)*['"]?(\/|~)['"]?(\s|$)/, "rm targeting / or ~ — catastrophic"], // rm -rf / and rm -rf ~
28
+ [/(^|[\s;&|/])\.git(\/|\s|$)/, "touches the .git directory"], // any command naming .git — the / in the prefix class catches absolute paths like /repo/.git
29
+ [/(^|[\s;&|/])\.env(\s|$|[;&|])/, ".env holds secrets"], // any command that names .env
30
+ [/(^|[\s;&|/])\.ssh(\/|\s|$)/, ".ssh holds credentials"], // any command that names .ssh — same / fix
31
+ ];
32
+ // Dangerous but sometimes legitimate — stop and ask the human.
33
+ const BASH_ASK = [
34
+ [/\brm\b.*-[a-zA-Z]*r/, "recursive delete"], // rm -r / -rf — deletes whole trees
35
+ [/\brm\b/, "deletes files"], // plain rm — still destructive
36
+ [/\bsudo\b/, "runs as root"], // privilege escalation
37
+ [/\bdrop\s+(table|database)\b/i, "destroys database objects"], // SQL drop
38
+ [/\b(curl|wget)\b[^|;&]*\|\s*(ba|z)?sh\b/, "pipes the internet into a shell"], // curl ... | sh
39
+ [/\bgit\s+push\b.*(--force|\s-f\b)/, "force-push rewrites remote history"], // git push --force
40
+ [/\bgit\s+(reset\s+--hard|clean\b)/, "discards local changes"], // git reset --hard / git clean
41
+ [/\bchmod\b/, "changes file permissions"], // permission changes
42
+ ];
43
+ // Single, read-only-ish commands that run without asking.
44
+ const BASH_ALLOW = new Set(["ls", "pwd", "echo", "wc", "which", "date", "node", "npm", "npx", "cat", "head", "tail"]);
45
+ const GIT_READONLY = new Set(["status", "log", "diff", "show", "branch", "remote", "tag"]); // git subcommands that only read
46
+ // Decide what a bash command deserves: deny, ask, or allow.
47
+ function checkBash(command) {
48
+ const summary = command.trim(); // what the user will see in the prompt
49
+ // Order is the security model: deny first, ask second, allow last.
50
+ for (const [re, why] of BASH_DENY)
51
+ if (re.test(command))
52
+ return { decision: "deny", reason: why, summary };
53
+ for (const [re, why] of BASH_ASK)
54
+ if (re.test(command))
55
+ return { decision: "ask", reason: why, summary };
56
+ // Compound commands (&&, ;, |, $(), ``) are too hard to reason about — ask.
57
+ if (/[;&|]|\$\(|`/.test(command))
58
+ return { decision: "ask", reason: "compound command", summary };
59
+ const words = summary.split(/\s+/); // tokenize to inspect the first word
60
+ if (words[0] === "git" && GIT_READONLY.has(words[1] ?? ""))
61
+ return { decision: "allow", reason: "read-only git", summary }; // safe git subcommands
62
+ if (BASH_ALLOW.has(words[0]))
63
+ return { decision: "allow", reason: "safe command", summary }; // known-harmless single command
64
+ // Fail closed: a command we don't recognize is a question for the human.
65
+ return { decision: "ask", reason: "unrecognized command", summary };
66
+ }
67
+ // ---- The single entry point ---------------------------------------------------
68
+ // Called by the loop for EVERY tool call, before anything executes.
69
+ export function checkPermission(toolName, argsJson) {
70
+ let args; // the parsed tool arguments
71
+ try {
72
+ args = JSON.parse(argsJson); // arguments arrive as a raw JSON string from the model
73
+ }
74
+ catch {
75
+ // Let dispatch produce the proper JSON error for the model.
76
+ return { decision: "allow", reason: "unparseable args", summary: toolName };
77
+ }
78
+ switch (toolName) {
79
+ case "search":
80
+ return { decision: "allow", reason: "read-only", summary: "search" }; // searching never mutates anything
81
+ case "task":
82
+ // Spawning a sub-agent is orchestration, not action: every tool the
83
+ // sub-agent uses goes through this same gate individually.
84
+ return { decision: "allow", reason: "sub-agent tools are gated individually", summary: "task" };
85
+ case "read_file": {
86
+ const p = args.path ?? ""; // the file the model wants to read
87
+ if (SECRET_FILE_RE.test(path.resolve(p))) {
88
+ return { decision: "deny", reason: "secret files must never enter the model's context", summary: p }; // keys stay out of the conversation
89
+ }
90
+ return { decision: "allow", reason: "read-only", summary: p }; // normal reads are free
91
+ }
92
+ case "write_file":
93
+ case "edit_file": {
94
+ const p = args.path ?? ""; // the file the model wants to change
95
+ const hit = noFlyHit(p); // is it in the no-fly zone?
96
+ if (hit)
97
+ return { decision: "deny", reason: hit, summary: p }; // hard stop — deny always wins
98
+ return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
99
+ }
100
+ case "run_bash":
101
+ return checkBash(args.command ?? ""); // bash gets input-aware analysis
102
+ default:
103
+ // A tool we don't know gets the most suspicious treatment, not the least.
104
+ return { decision: "ask", reason: "unknown tool", summary: toolName };
105
+ }
106
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs"; // to load the optional AGENT.md project memory
2
+ import path from "node:path"; // to resolve it from the current directory
3
+ // The static constitution: identical for every session, every user, every day.
4
+ // NOTHING session-specific belongs here — DeepSeek caches by prefix, and a
5
+ // single changed byte (a date, a username, a cwd) would break the cache for
6
+ // the entire system prompt on every single request.
7
+ //
8
+ // Style notes, learned from Claude Code's own prompt:
9
+ // - Numeric anchors ("under 150 words"), not adjectives ("be concise") —
10
+ // adjectives make the model guess a different size every time.
11
+ // - Negative rules ("never add comments to code you did not change") — their
12
+ // boundaries are binary and checkable; positive virtues are not.
13
+ // - The accuracy paragraph is False-Claim mitigation. The "don't be overly
14
+ // self-doubting" half is load-bearing: without it the model swings to the
15
+ // other extreme and hedges everything.
16
+ export const SYSTEM_PROMPT = `You are mini-agent, a coding agent that works in the user's terminal.
17
+
18
+ Behavior rules:
19
+ - Use the dedicated tools for file work: read_file, edit_file, write_file, search. Use run_bash only for things that truly need execution.
20
+ - Report results accurately. If a test fails, say it failed. Don't claim success when you're not sure. Don't be overly self-doubting either — distinguish "verified working" from "I think it works".
21
+ - Never add comments to code you did not change. Never reformat code you did not change.
22
+ - Keep final answers under 150 words unless the task genuinely requires more.
23
+ - Between tool calls, write at most one short sentence — usually nothing.
24
+ - A [permission] denial is a hard boundary. Do not attempt workarounds — explain and ask the user.
25
+ - If the task is ambiguous, ask one clarifying question instead of guessing.`;
26
+ // The sub-agent constitution: a worker with a fresh context and one job.
27
+ // Key line: its FINAL message is the entire deliverable — the parent sees
28
+ // nothing else of what it did.
29
+ export const SUB_AGENT_PROMPT = `You are a sub-agent: a worker spawned by a parent agent to complete one self-contained task.
30
+
31
+ Rules:
32
+ - You know nothing about the parent's conversation. Work only from the task description.
33
+ - Use your tools to do the work. Verify what you claim — do not guess.
34
+ - Your FINAL message is returned to the parent verbatim. Make it a compact, factual report: findings, exact paths, numbers, code snippets. No greetings, no questions back.
35
+ - You cannot talk to the user. If the task cannot be completed, report precisely what is missing.`;
36
+ // Project memory: an optional AGENT.md in the current directory — the
37
+ // equivalent of Claude Code's CLAUDE.md. Loaded once per session: stable
38
+ // within a session (cache-friendly), different across projects (by design).
39
+ const MEMORY_CAP_CHARS = 8000; // hard cap — a runaway memory file dilutes attention
40
+ // Build the complete system message for this session.
41
+ export function buildSystemMessage() {
42
+ const p = path.join(process.cwd(), "AGENT.md"); // only the current directory — V1 keeps lookup simple
43
+ if (!fs.existsSync(p))
44
+ return SYSTEM_PROMPT; // no project memory — constitution only
45
+ const memory = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
46
+ // The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
47
+ // compliance; imperative wrappers measurably do better.
48
+ return `${SYSTEM_PROMPT}
49
+
50
+ The user's project instructions from AGENT.md. These OVERRIDE any default behavior and you MUST follow them exactly as written:
51
+
52
+ ${memory}`;
53
+ }
@@ -0,0 +1,75 @@
1
+ import OpenAI from "openai"; // the official OpenAI SDK — DeepSeek is compatible with it
2
+ import { execSync } from "node:child_process"; // runs a shell command and waits for it to finish
3
+ import readline from "node:readline/promises"; // promise-based terminal input (for the question prompt)
4
+ // Create the API client. DeepSeek speaks the OpenAI wire protocol,
5
+ // so the only DeepSeek-specific thing here is the base URL.
6
+ const client = new OpenAI({
7
+ baseURL: "https://api.deepseek.com", // point the SDK at DeepSeek instead of OpenAI
8
+ apiKey: process.env.DEEPSEEK_API_KEY, // never hardcode keys — read from the environment
9
+ });
10
+ // Our first (and only) tool. This is NOT code the model runs — it is a
11
+ // machine-readable manual telling the model that a tool called "run_bash"
12
+ // exists and what arguments it takes.
13
+ const tools = [
14
+ {
15
+ type: "function", // the only tool type the chat API supports
16
+ function: {
17
+ name: "run_bash", // the name the model will call us by
18
+ description: "Run a bash command on the user's machine and return its output.", // when/why to use it
19
+ parameters: {
20
+ type: "object", // arguments always arrive as one JSON object
21
+ properties: {
22
+ command: { type: "string", description: "The bash command to run" }, // the single argument
23
+ },
24
+ required: ["command"], // the model must always provide it
25
+ },
26
+ },
27
+ },
28
+ ];
29
+ // The real implementation of the tool — plain TypeScript, invoked by US
30
+ // after the model asks for it. The model never executes anything itself.
31
+ function runBash(command) {
32
+ console.log(`\n🔧 running: ${command}`); // show the user what is about to run
33
+ try {
34
+ const out = execSync(command, { encoding: "utf8", timeout: 30_000 }); // run it, 30s time limit
35
+ return out || "(command succeeded, no output)"; // the API rejects empty strings — always return something
36
+ }
37
+ catch (err) {
38
+ return `Command failed: ${err.message}`; // don't crash — feed the error back to the model
39
+ }
40
+ }
41
+ async function main() {
42
+ // Ask the user for a task, once, via stdin.
43
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); // wire up terminal I/O
44
+ const task = await rl.question("What should the AI do?\n> "); // block until the user answers
45
+ rl.close(); // release stdin so the process can exit cleanly later
46
+ // The conversation history. This array IS the model's entire memory —
47
+ // it re-reads the whole thing on every call and remembers nothing else.
48
+ const messages = [{ role: "user", content: task }];
49
+ // The essence of an agent is this loop: model → tool → model → … → final state.
50
+ // The hard cap is a safety net: never loop (and never burn money) forever.
51
+ for (let round = 1; round <= 10; round++) {
52
+ // One API call: send the full history plus the tool manual.
53
+ const res = await client.chat.completions.create({ model: "deepseek-chat", messages, tools });
54
+ const msg = res.choices[0].message; // the model's reply for this round
55
+ messages.push(msg); // the reply must go into history, or tool results would be orphaned
56
+ // No tool calls = the model considers the task done. That is the final state.
57
+ if (!msg.tool_calls?.length) {
58
+ console.log(`\n🤖 ${msg.content}`); // print the model's final answer
59
+ return; // normal exit
60
+ }
61
+ // Otherwise: execute every tool call and feed each result back.
62
+ for (const call of msg.tool_calls) {
63
+ if (call.type !== "function")
64
+ continue; // ignore non-function call types
65
+ const args = JSON.parse(call.function.arguments); // arguments arrive as a JSON string
66
+ messages.push({
67
+ role: "tool", // a special message role: "here is what your tool returned"
68
+ tool_call_id: call.id, // must match the call — the API enforces this pairing
69
+ content: runBash(args.command).slice(0, 4000), // crude cap so huge outputs don't blow up the context
70
+ });
71
+ }
72
+ }
73
+ console.log("\n⚠️ Hit the 10-round cap, stopping."); // loop guard: stop at the cap, tell the user why
74
+ }
75
+ main(); // kick everything off
package/dist/tools.js ADDED
@@ -0,0 +1,246 @@
1
+ import fs from "node:fs"; // filesystem reads and writes
2
+ import path from "node:path"; // path resolution and joining
3
+ import { execSync } from "node:child_process"; // runs a shell command and waits for it
4
+ // ============ Session state ============
5
+ // The foundation of "read before edit": which files has this session actually read?
6
+ // Files that were never read must not be edited — otherwise the model edits from
7
+ // (possibly stale) memory and wrecks the file.
8
+ // A Map instead of a Set since Day 5: the touch order ranks files for recovery
9
+ // after compaction (most recently touched = most likely needed next).
10
+ // A monotonic counter, not Date.now(): two reads in the same millisecond would
11
+ // get identical wall-clock stamps and the ordering would be arbitrary.
12
+ let touchCounter = 0; // increments on every read/write — strictly ordered
13
+ const readFiles = new Map(); // absolute path → touch sequence number
14
+ const TOOL_RESULT_LIMIT = 4000; // cap (chars) for a normal tool result entering the context
15
+ const READ_LIMIT = 16000; // read_file gets a larger cap of its own
16
+ const SEARCH_MAX_MATCHES = 50; // max matches returned by search
17
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); // directories search never enters
18
+ // Errors are not exceptions — they are text that tells the model what to do next.
19
+ const fail = (msg) => `[error] ${msg}`;
20
+ // Small helper to build a tool definition without repeating boilerplate.
21
+ function def(name, // tool name the model calls
22
+ description, // the manual: what / boundaries / preconditions / on error
23
+ properties, // parameter schema
24
+ required) {
25
+ return {
26
+ type: "function", // the only tool type the chat API supports
27
+ function: { name, description, parameters: { type: "object", properties, required } },
28
+ };
29
+ }
30
+ // ============ read_file ============
31
+ const readFile = {
32
+ definition: def("read_file", `Read the full content of a text file.
33
+ Boundaries: text files only; content beyond ${READ_LIMIT} chars is truncated; do not use it on binaries like images.
34
+ Precondition: before editing a file (edit_file) or overwriting an existing one (write_file), you MUST read it with this tool first.
35
+ On error: a missing file returns an error — check the path and retry.
36
+ ALWAYS use this tool to read files. NEVER run cat/head/tail via run_bash.`, { path: { type: "string", description: "File path (absolute or relative)" } }, ["path"]),
37
+ run: (args) => {
38
+ const p = path.resolve(args.path); // normalize to an absolute path
39
+ if (!fs.existsSync(p))
40
+ return fail(`File not found: ${p}. Check the path — you can locate files with search.`); // wrong path → tell the model how to find it
41
+ if (!fs.statSync(p).isFile())
42
+ return fail(`${p} is a directory, not a file.`); // directories are not readable
43
+ const content = fs.readFileSync(p, "utf8"); // read the whole file as text
44
+ readFiles.set(p, ++touchCounter); // remember: this session has read this file (unlocks edit/overwrite)
45
+ if (content.length > READ_LIMIT) {
46
+ return content.slice(0, READ_LIMIT) + `\n... (file is ${content.length} chars, truncated)`; // cap huge files, but say so
47
+ }
48
+ return content || "(empty file)"; // the API rejects empty strings — always return something
49
+ },
50
+ };
51
+ // ============ write_file ============
52
+ const writeFile = {
53
+ definition: def("write_file", `Create a new file, or fully overwrite an existing one.
54
+ Boundaries: the whole file is replaced by content; to change a small part of a file use edit_file instead — do not rewrite whole files.
55
+ Precondition: overwriting an existing file requires reading it with read_file first. Writes may require user approval.
56
+ On error: follow the error message — read the file first, then retry.`, {
57
+ path: { type: "string", description: "File path; parent directories are created automatically" },
58
+ content: { type: "string", description: "The complete new content of the file" },
59
+ }, ["path", "content"]),
60
+ run: (args) => {
61
+ const p = path.resolve(args.path); // normalize to an absolute path
62
+ if (fs.existsSync(p) && !readFiles.has(p)) {
63
+ return fail(`${p} already exists but you have not read it. Use read_file to check its current content before overwriting.`); // no blind overwrites
64
+ }
65
+ fs.mkdirSync(path.dirname(p), { recursive: true }); // create parent directories as needed
66
+ fs.writeFileSync(p, args.content, "utf8"); // write the new content
67
+ readFiles.set(p, ++touchCounter); // you wrote it, so you know its content — counts as read
68
+ return `Wrote ${p} (${args.content.length} chars)`; // confirm with the size as a sanity check
69
+ },
70
+ };
71
+ // ============ edit_file ============
72
+ const editFile = {
73
+ definition: def("edit_file", `Replace one exact string in a file: old_string → new_string.
74
+ Boundaries: old_string must match the file content exactly (including whitespace and indentation) and must be unique in the file; if it is not unique, include a few surrounding lines as context.
75
+ Precondition: you MUST have read the file with read_file first. Edits may require user approval.
76
+ On error: "not found" means old_string does not match the file — re-read and copy it exactly; "not unique" means include more surrounding context.
77
+ ALWAYS use this tool to edit files. NEVER use sed/awk or shell redirection via run_bash.`, {
78
+ path: { type: "string", description: "Path of the file to edit" },
79
+ old_string: { type: "string", description: "The exact text to replace (must be unique)" },
80
+ new_string: { type: "string", description: "The replacement text" },
81
+ }, ["path", "old_string", "new_string"]),
82
+ run: (args) => {
83
+ const p = path.resolve(args.path); // normalize to an absolute path
84
+ if (!fs.existsSync(p))
85
+ return fail(`File not found: ${p}`); // can't edit what isn't there
86
+ if (!readFiles.has(p)) {
87
+ return fail(`You have not read ${p} yet. Use read_file first and edit based on its real content.`); // the read-before-edit rule
88
+ }
89
+ if (args.old_string === args.new_string)
90
+ return fail(`old_string and new_string are identical — nothing to change.`); // pointless edit, don't waste a round
91
+ const content = fs.readFileSync(p, "utf8"); // current file content
92
+ const count = content.split(args.old_string).length - 1; // how many times old_string occurs
93
+ if (count === 0) {
94
+ return fail(`old_string not found in the file. It must match exactly (including whitespace and indentation) — re-read the file and copy it precisely.`); // model's memory is stale → re-read
95
+ }
96
+ if (count > 1) {
97
+ return fail(`old_string appears ${count} times — ambiguous. Include a few surrounding lines to make it unique.`); // refuse to guess which one
98
+ }
99
+ // Note: content.replace(old, newStr) would expand $& $' etc. in newStr as special
100
+ // replacement patterns — pass a function so the replacement is taken literally.
101
+ fs.writeFileSync(p, content.replace(args.old_string, () => args.new_string), "utf8"); // apply the single replacement
102
+ return `Edited ${p}: 1 replacement made.`; // confirm exactly what happened
103
+ },
104
+ };
105
+ // ============ search ============
106
+ // Convert a shell-style glob (*.ts) into a regex for file-name filtering.
107
+ function globToRegex(glob) {
108
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); // escape regex chars, then * → .*
109
+ return new RegExp(`^${escaped}$`); // anchor so the whole name must match
110
+ }
111
+ // Recursively yield every file under dir, skipping junk and hidden entries.
112
+ function* walk(dir) {
113
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
114
+ if (entry.isDirectory()) {
115
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith("."))
116
+ yield* walk(path.join(dir, entry.name)); // recurse, but never into node_modules/.git/hidden dirs
117
+ }
118
+ else if (entry.isFile() && !entry.name.startsWith(".")) {
119
+ // dotfiles (.env and friends) often hold secrets — search never surfaces them
120
+ yield path.join(dir, entry.name);
121
+ }
122
+ }
123
+ }
124
+ const search = {
125
+ definition: def("search", `Search file contents under a directory with a regex. Returns a list of "file:line: content" matches.
126
+ Boundaries: skips node_modules/.git/hidden dirs/binary files; returns at most ${SEARCH_MAX_MATCHES} matches; results are locations only — use read_file to see full content.
127
+ On error: an invalid regex returns an error; on "no matches", loosen the pattern or widen the directory and retry.
128
+ ALWAYS use this tool to search. NEVER run grep/rg/find via run_bash.`, {
129
+ pattern: { type: "string", description: "The regex to search for" },
130
+ path: { type: "string", description: "Directory to start from, defaults to the current directory" },
131
+ file_glob: { type: "string", description: "Only search files whose name matches, e.g. *.ts (optional)" },
132
+ }, ["pattern"]),
133
+ run: (args) => {
134
+ const dir = path.resolve(args.path || "."); // default to the current directory
135
+ if (!fs.existsSync(dir))
136
+ return fail(`Directory not found: ${dir}`); // bad starting point
137
+ let regex; // the compiled search pattern
138
+ try {
139
+ regex = new RegExp(args.pattern); // compile the model's pattern
140
+ }
141
+ catch (e) {
142
+ return fail(`Invalid regex: ${e.message}`); // bad pattern → tell the model to fix it
143
+ }
144
+ const nameFilter = args.file_glob ? globToRegex(args.file_glob) : null; // optional file-name filter
145
+ const matches = []; // accumulated "file:line: content" lines
146
+ for (const file of walk(dir)) {
147
+ if (nameFilter && !nameFilter.test(path.basename(file)))
148
+ continue; // skip files the glob excludes
149
+ if (fs.statSync(file).size > 1_000_000)
150
+ continue; // skip huge files (logs, bundles)
151
+ const text = fs.readFileSync(file, "utf8"); // read the candidate file
152
+ if (text.includes("\0"))
153
+ continue; // skip binaries (NUL byte = not text)
154
+ const lines = text.split("\n"); // search line by line
155
+ for (let i = 0; i < lines.length; i++) {
156
+ if (regex.test(lines[i])) {
157
+ matches.push(`${path.relative(dir, file)}:${i + 1}: ${lines[i].trim()}`); // record location + the matching line
158
+ if (matches.length >= SEARCH_MAX_MATCHES) {
159
+ return matches.join("\n") + `\n... (hit the ${SEARCH_MAX_MATCHES}-match cap, narrow your pattern)`; // stop early, tell the model why
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return matches.length ? matches.join("\n") : "No matches. Loosen the pattern or try another directory."; // empty result still guides the next step
165
+ },
166
+ };
167
+ // ============ run_bash ============
168
+ const runBash = {
169
+ definition: def("run_bash", `Run a bash command on the user's machine (30s timeout) and return its output.
170
+ Boundaries: only for things that truly need execution — installing dependencies, running tests/scripts, git operations.
171
+ To read files use read_file (not cat), to search use search (not grep/find), to edit files use edit_file (not sed/redirection).
172
+ Dangerous commands require user approval; some are denied outright. A [permission] result is a hard boundary — never try to work around it.
173
+ On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, { command: { type: "string", description: "The bash command to run" } }, ["command"]),
174
+ run: (args) => {
175
+ try {
176
+ // stderr is piped (not inherited): it belongs in the tool result for the
177
+ // model to read, not splattered onto the user's terminal.
178
+ const out = execSync(args.command, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
179
+ return out || "(command succeeded, no output)"; // never return an empty string
180
+ }
181
+ catch (err) {
182
+ const e = err; // execSync attaches the captured stderr on failure
183
+ return fail(`Command failed: ${e.message}${e.stderr ? `\nstderr: ${e.stderr.slice(0, 1000)}` : ""}`); // failure becomes guidance, not a crash
184
+ }
185
+ },
186
+ };
187
+ // ============ Registry & dispatch ============
188
+ // One place that knows every tool. Adding a tool = adding one entry here.
189
+ export const tools = {
190
+ read_file: readFile,
191
+ write_file: writeFile,
192
+ edit_file: editFile,
193
+ search,
194
+ run_bash: runBash,
195
+ };
196
+ // The list of manuals sent to the model on every API call.
197
+ export const toolDefinitions = Object.values(tools).map((t) => t.definition);
198
+ // Single entry point: every failure becomes text fed back to the model.
199
+ // It never throws — the main loop must never die because of a tool.
200
+ export function dispatch(name, argsJson) {
201
+ const tool = tools[name]; // look the tool up by name
202
+ if (!tool)
203
+ return fail(`Unknown tool: ${name}. Available tools: ${Object.keys(tools).join(", ")}`); // model hallucinated a tool → list the real ones
204
+ let args; // the parsed arguments
205
+ try {
206
+ args = JSON.parse(argsJson); // arguments arrive as a raw JSON string
207
+ }
208
+ catch {
209
+ return fail(`Arguments are not valid JSON: ${argsJson.slice(0, 200)}`); // malformed JSON → show what we got
210
+ }
211
+ try {
212
+ return tool.run(args).slice(0, name === "read_file" ? READ_LIMIT + 100 : TOOL_RESULT_LIMIT); // run it, cap the result size
213
+ }
214
+ catch (err) {
215
+ return fail(`Tool crashed: ${err.message}`); // even a crashing tool becomes a readable result
216
+ }
217
+ }
218
+ // ============ File-state queries (used by compaction recovery) ============
219
+ // The most recently touched files, newest first.
220
+ export function recentFiles(limit) {
221
+ return [...readFiles.entries()] // [path, sequence] pairs
222
+ .sort((a, b) => b[1] - a[1]) // newest first
223
+ .slice(0, limit) // top N only
224
+ .map(([p]) => p); // just the paths
225
+ }
226
+ // Evict everything except the given paths. After compaction, a file the model
227
+ // can no longer see must also lose its "already read" status — otherwise the
228
+ // model could edit it based on content that is no longer in the conversation.
229
+ export function forgetFilesExcept(keep) {
230
+ const keepSet = new Set(keep); // for O(1) lookup
231
+ for (const p of [...readFiles.keys()])
232
+ if (!keepSet.has(p))
233
+ readFiles.delete(p); // drop the rest
234
+ }
235
+ // Snapshot / restore the read-state around a sub-agent run. The sub-agent
236
+ // shares this module's state, but what IT read was never seen by the parent's
237
+ // conversation — restoring the snapshot keeps read-before-edit honest across
238
+ // agent boundaries.
239
+ export function snapshotFileState() {
240
+ return new Map(readFiles); // shallow copy is enough — values are numbers
241
+ }
242
+ export function restoreFileState(snapshot) {
243
+ readFiles.clear(); // drop whatever the sub-agent read or wrote
244
+ for (const [p, seq] of snapshot)
245
+ readFiles.set(p, seq); // bring back the parent's view
246
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "agent-from-zero",
3
+ "version": "0.1.0",
4
+ "description": "A Claude Code-style CLI agent built from scratch with the DeepSeek API — companion code for a 10-day tutorial",
5
+ "type": "module",
6
+ "bin": {
7
+ "mini-agent": "dist/agent.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "start": "tsx --env-file=.env src/agent.ts",
15
+ "typecheck": "tsc --noEmit",
16
+ "eval": "tsx --env-file=.env eval/run.ts",
17
+ "build": "tsc -p tsconfig.build.json",
18
+ "prepare": "npm run build"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.6"
22
+ },
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "chalk": "^5.6.2",
26
+ "openai": "^6.42.0",
27
+ "ora": "^9.4.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^25.9.2",
31
+ "tsx": "^4.22.4",
32
+ "typescript": "^6.0.3"
33
+ }
34
+ }