agent-from-zero 0.5.0 → 0.27.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 +23 -2
- package/dist/agent.js +334 -18
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +167 -19
- package/dist/mcp.js +237 -0
- package/dist/memory.js +186 -0
- package/dist/mentions.js +62 -0
- package/dist/menu.js +154 -0
- package/dist/permissions.js +77 -5
- package/dist/prompt.js +16 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/tools.js +47 -4
- package/dist/tui.js +67 -0
- package/dist/ui.js +134 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
人定方向、做决策、验收,AI 写大部分代码——这既是本仓库的诞生方式,也是这门课教你的工作方式。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
课程共 35 天——主线 10 天搓出一个能用的 agent,进阶 25 天打磨成工业级 CLI(MCP / 权限裁判 / 长期记忆 / 成本核算 / 调用校验 / 计划模式 / diff 预览 / 撤销 / 模型分级 / 远程 MCP / 会话改动总览 / @file 引用 / 会话选择器 / 终端 UI / 方向键审批 / 多问题表单 / 模型切换 / 推理流 / Skill 系统 / 记忆深化 / Hook 深化);一天一个 commit、一晚一章的节奏跟做。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 天对应的完整代码。
|
|
8
8
|
|
|
9
9
|
> **设计有出处,不是凭感觉搭的**:蓝本是《Harness Engineering:从 Claude Code 看 AI 编码工程》提炼的 22 个工程模式——博主在公众号用 30+ 篇拆完了全书(本仓库就是那次拆解的实践篇)。书的收官章,作者用 800 行 Rust 写了一个代码审查 Agent,验证这些模式能跨语言、跨场景活下来;**这个仓库是同一套模式的又一次迁移**:场景换成 Claude Code 式的 CLI 编码 agent,语言换成 TypeScript——不跟着用 Rust,是因为新手教程的第一原则是别让语言难度挡路。
|
|
10
10
|
>
|
|
@@ -61,10 +61,31 @@ MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
61
61
|
| `day12` | 会话持久化(`--resume`,快照式 + 原子写)+ 本地遥测(事件 JSONL + `/stats`,绝不出本机,`MINI_AGENT_NO_TELEMETRY=1` 关闭) |
|
|
62
62
|
| `day13` | 异步执行:run_bash 换 spawn(不冻 UI、Ctrl+C 秒杀、30s 超时 SIGKILL)、超长输出落盘、只读工具并行;`npm test` 65 例 |
|
|
63
63
|
| `day14` | Hook 生命周期系统:PreToolUse(拦截工具)/PostToolUse(追加校验)/SessionStart(注入上下文)/Stop(测试驱动 AI),退出码协议、stderr 喂回模型;`npm test` 76 例 |
|
|
64
|
+
| `day15` | MCP 接入:stdio JSON-RPC 客户端(握手/行缓冲/id 配对),外部 server 工具注册成内置同款(同一 dispatch + 权限闸门,默认 ask);`npm test` 84 例 |
|
|
65
|
+
| `day16` | 可选 LLM 权限裁判:只看规则判 ask 的灰色地带、只能 ask→allow、prompt 铁死格式、解析 fail closed、自带熔断、默认关闭;`npm test` 98 例 |
|
|
66
|
+
| `day17` | 长期记忆:跨会话的 MEMORY.md(人类可读)+ remember 工具,作为「背景」注入(区别于 AGENT.md 的「命令」),有界 30 条/8KB 且自修复;`npm test` 114 例 |
|
|
67
|
+
| `day18` | 成本核算:从流里捕获 usage(include_usage)、抹平 DeepSeek/OpenAI 格式差异、纯函数算成本与缓存命中率、`/cost` 看花销(命中率即缓存击穿预警);`npm test` 130 例 |
|
|
68
|
+
| `day19` | 工具调用校验:够用就好的 schema 校验(必填/类型/形状)、钉在 dispatch 唯一入口、批量返回精确修复指引、自动覆盖内置+MCP 工具(送出前拦下坏参数);`npm test` 146 例 |
|
|
69
|
+
| `day20` | 计划模式:只读研究、想清楚再交计划由你批准;权限层一个外层过滤器(只收紧不放松、盖过预批准、保留禁飞区理由)+ `exit_plan_mode` 工具借用 ask 流程闭合审批 + `/plan` 开关;`npm test` 167 例 |
|
|
70
|
+
| `day21` | diff 预览:写文件前把真实改动(绿 +/红 -)摊给你看再批准;十几行 LCS 行级 diff、折叠未变动行、复现工具语义无副作用推演、永不抛异常 fail 到 null;在闸门后执行前打印;`npm test` 189 例 |
|
|
71
|
+
| `day22` | `/undo` 撤销上一次写入:写入前埋一行存「之前」快照(有界栈),`/undo` 还原内容或删掉新建文件、复用 Day 21 diff 画出撤销结果;`/clear` 同时清栈;`npm test` 204 例 |
|
|
72
|
+
| `day23` | 子 agent 模型分级:`subAgentModel` 配置让委托跑在不同档位(便宜模型干杂活 / 强模型当顾问审查),纯函数 `subAgentModelFor` 定回落规则,首次用假 client 端到端验证「编排→委托→回归」真换了模型;`npm test` 212 例 |
|
|
73
|
+
| `day24` | 远程 MCP(HTTP/SSE):把 Day 15 的传输从协议里剥成 Transport 接口,新增 HTTP 传输(POST→按 Content-Type 分 JSON/SSE 解析、回带 Mcp-Session-Id、AbortSignal 超时),配置 `url` 即走远程;协议层/发现/注册零改动;`npm test` 218 例 |
|
|
74
|
+
| `day25` | `/diff` 会话改动总览:撤销账本旁加一张「会话起点基线」表(每个文件首次被碰的样子,只记一次),`/diff` 与磁盘现状逐个比、复用 Day 21 渲染净改动、净零跳过,三态 created/modified/deleted;`npm test` 227 例 |
|
|
75
|
+
| `day26` | `@file` 引用:提问里 `@路径` 即把文件内容附进消息省一次 read_file 往返;认行首/空白后的 `@`、剥句尾标点;每个提及过 read_file 权限闸门——密钥文件(.env/*.pem)拒绝不读、被拒出声、没命中静默;`npm test` 244 例 |
|
|
76
|
+
| `day27` | `/resume` 会话选择器:`session.ts` 加 listSessions(摘要)/loadSession(按 id)/sessionTitle(首句当标题、剥 @file 附件),REPL 列表+选号+整体切换(重建宪法、换 id、清读态与撤销栈),会话目录改惰性求值以可测;`npm test` 256 例 |
|
|
77
|
+
| `day28` | 终端 UI:新增 `ui.ts` 收拢外观——圆角欢迎框、`❯` 提示符、`⏺`/`⎿` 活动标记(替 emoji)、走秒 spinner(搭车看门狗);讲透「颜色码污染宽度计算」要先按纯文本对齐再上色;`npm test` 277 例 |
|
|
78
|
+
| `day29` | 方向键审批菜单:审批从「打 y/N」换成 ↑↓ 选(`menu.ts` 借 stdin 进 raw 模式、原地重绘、选完归还),带编号+底部操作提示;中间项「本会话别再问」复用 allow 规则(run_bash 只升 ask 不碰硬 deny);非 TTY 回退打字;`npm test` 289 例 |
|
|
79
|
+
| `day30` | 多问题选择表单:给模型一个 `ask_user` 工具,需用户决策时弹表单(`form.ts` 纯状态机:扁平光标+reduce+提交校验+缺口跳转,`promptForm` 薄键盘层),收集成 question→answer 喂回;输入区改成 Claude Code 式「分隔线+❯」;`npm test` 309 例 |
|
|
80
|
+
| `day31` | 运行时切换模型:`/model` 从只读升级成可切——`/model <name>` 直接设,裸 `/model` 拉端点 `/models` 列表用 Day 29 菜单选;切换只改 `CONFIG.model`、靠主循环每轮重读立即生效;`npm test` 314 例 |
|
|
81
|
+
| `day32` | 让换模型看得见:流式循环多读 `reasoning_content`,把推理模型(R1)的思考灰着流出来(只渲染不进历史,绝不回传);spinner 显示当前模型作为切换生效的证明;`npm test` 324 例 |
|
|
82
|
+
| `day33` | Skill 系统(Markdown 即插件):`SKILL.md`(frontmatter+正文)=一个插件、执行者是模型;渐进披露(列表进上下文/正文按需加载)、`skill` 工具说明书即列表、`disableModelInvocation` 分模型可调/仅用户、`/skills` `/skill <name>`;`npm test` 360 例 |
|
|
83
|
+
| `day34` | 记忆深化:四类型(user/feedback/project/reference,feedback 最值钱)、每轮自动提取(便宜模型读对话存「用户纠正过的」,默认关、永不抛异常)、按类型分组注入、向后兼容旧格式;`npm test` 375 例 |
|
|
84
|
+
| `day35` | Hook 深化:4→10 个时机(加 UserPromptSubmit/SessionEnd/Pre·PostCompact/Subagent*),配置改开放 record、exit 2 语义按事件由调用方解读、SessionEnd 极短超时;**`PreToolUse` 可改写工具参数**(权限三态做不到的「岔道」,严格在安全检查之后);`npm test` 381 例 |
|
|
64
85
|
|
|
65
86
|
## 📖 配套教程(免费开源)
|
|
66
87
|
|
|
67
|
-
**[完整教程在 docs/ 目录](docs/README.md)
|
|
88
|
+
**[完整教程在 docs/ 目录](docs/README.md)**——主线 + 进阶共十几章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
|
|
68
89
|
|
|
69
90
|
想看更深的「为什么」:公众号的 **Harness Engineering 拆书系列**(30+ 篇)逐章拆了《Harness Engineering:从 Claude Code 看 AI 编码工程》——本仓库每个设计决策背后的原理、数据和事故案例都在那里。公众号 @(待填)。
|
|
70
91
|
|
package/dist/agent.js
CHANGED
|
@@ -3,14 +3,27 @@ import OpenAI from "openai"; // the API client
|
|
|
3
3
|
import chalk from "chalk"; // terminal colors
|
|
4
4
|
import readline from "node:readline/promises"; // promise-based terminal input
|
|
5
5
|
import { createRequire } from "node:module"; // to read package.json for --version
|
|
6
|
-
import { CONFIG, requireApiKey, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
7
|
-
import { runLoop, TerminateReason,
|
|
6
|
+
import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
7
|
+
import { runLoop, TerminateReason, MAX_RETRIES } from "./loop.js"; // the state machine
|
|
8
8
|
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
9
|
-
import { forgetFilesExcept } from "./tools.js"; //
|
|
10
|
-
import { compactHistory, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
|
-
import { newSessionId, saveSession, latestSession } from "./session.js"; // conversation persistence (project-local)
|
|
9
|
+
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
10
|
+
import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
|
+
import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
|
|
12
12
|
import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
|
|
13
13
|
import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
|
|
14
|
+
import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
|
|
15
|
+
import { Judge } from "./judge.js"; // optional LLM permission classifier
|
|
16
|
+
import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
|
|
17
|
+
import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
|
|
18
|
+
import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
|
|
19
|
+
import path from "node:path"; // shorten paths for the /diff summary
|
|
20
|
+
import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
|
|
21
|
+
import { banner, framedPrompt, formatModelChoices, statusLine, inputRule } from "./ui.js"; // welcome box, prompt, model labels, status line
|
|
22
|
+
import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
|
|
23
|
+
import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
|
|
24
|
+
import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
|
|
25
|
+
import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
|
|
26
|
+
import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
|
|
14
27
|
// package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
|
|
15
28
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
16
29
|
// ---- CLI arguments --------------------------------------------------------------
|
|
@@ -33,7 +46,6 @@ Configuration (optional):
|
|
|
33
46
|
In a session, type /help for the in-session commands.`;
|
|
34
47
|
// What we tell the user for every way a query can end. No raw stack traces.
|
|
35
48
|
const EXIT_NOTES = {
|
|
36
|
-
[TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
|
|
37
49
|
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
|
|
38
50
|
[TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
|
|
39
51
|
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
|
|
@@ -47,9 +59,20 @@ const SESSION_HELP = `commands:
|
|
|
47
59
|
/help this text
|
|
48
60
|
/clear wipe the conversation (and the file read-state) — fresh start
|
|
49
61
|
/compact compact the history into a summary right now (happens automatically near the limit)
|
|
50
|
-
/model
|
|
62
|
+
/model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
|
|
51
63
|
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
64
|
+
/memory show the durable facts the agent remembers about this project
|
|
65
|
+
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
66
|
+
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
67
|
+
/undo revert the most recent file write (write_file / edit_file) this session
|
|
68
|
+
/diff show every file changed this session, as a diff from where it started
|
|
69
|
+
/resume list recent sessions in this project and continue one of them
|
|
70
|
+
/skills list the reusable skills available in this project
|
|
71
|
+
/skill <name> run a skill yourself (works even for user-only skills)
|
|
52
72
|
exit leave (Ctrl+C at the prompt does the same)`;
|
|
73
|
+
// Injected when the user turns plan mode on, so the model knows the rules of the
|
|
74
|
+
// mode it now lives in (the permission gate enforces them regardless).
|
|
75
|
+
const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
|
|
53
76
|
async function main() {
|
|
54
77
|
// ---- Flag handling, before anything touches the network -----------------------
|
|
55
78
|
const argv = process.argv.slice(2); // everything after "mini-agent"
|
|
@@ -85,6 +108,7 @@ async function main() {
|
|
|
85
108
|
// -r / --resume picks up the most recent one. The system message is NOT
|
|
86
109
|
// restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
|
|
87
110
|
let sessionId = newSessionId(); // this session's identity (and file name)
|
|
111
|
+
const sessionStartedAt = Date.now(); // for the status line's elapsed clock
|
|
88
112
|
if (argv.includes("-r") || argv.includes("--resume")) {
|
|
89
113
|
const prev = latestSession(); // newest snapshot in this directory, if any
|
|
90
114
|
if (prev) {
|
|
@@ -98,6 +122,36 @@ async function main() {
|
|
|
98
122
|
}
|
|
99
123
|
initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
|
|
100
124
|
emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
|
|
125
|
+
// Connect to any configured MCP servers and register their tools. This must
|
|
126
|
+
// finish before the first model call so the tools appear in the manual. A
|
|
127
|
+
// server that fails to start is skipped, never fatal.
|
|
128
|
+
const disconnectMcp = await connectMcpServers();
|
|
129
|
+
process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
|
|
130
|
+
// The optional LLM permission judge, built once if a settings file enabled it.
|
|
131
|
+
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
132
|
+
if (judge)
|
|
133
|
+
console.log(chalk.dim(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`));
|
|
134
|
+
// The remember tool: let the model save durable facts to long-term memory.
|
|
135
|
+
// Registered like any tool, so it flows through the same permission gate.
|
|
136
|
+
registerExternalTool(rememberTool);
|
|
137
|
+
const memCount = readMemory().length;
|
|
138
|
+
if (memCount)
|
|
139
|
+
console.log(chalk.dim(`(long-term memory: ${memCount} facts loaded)`));
|
|
140
|
+
// Skills: reusable Markdown procedures. The model-invocable ones are exposed
|
|
141
|
+
// through a single `skill` tool whose description lists them (progressive
|
|
142
|
+
// disclosure); user-only skills are reachable via /skill <name>.
|
|
143
|
+
const skills = loadSkills();
|
|
144
|
+
if (skills.some((s) => !s.disableModelInvocation))
|
|
145
|
+
registerExternalTool(buildSkillTool(skills));
|
|
146
|
+
if (skills.length)
|
|
147
|
+
console.log(chalk.dim(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`));
|
|
148
|
+
// Token/cost meter for this session. Prices come from settings, falling back
|
|
149
|
+
// to the defaults; the loop records usage into it from every stream.
|
|
150
|
+
const costMeter = initCostMeter({
|
|
151
|
+
inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
|
|
152
|
+
cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
|
|
153
|
+
outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
|
|
154
|
+
});
|
|
101
155
|
// SessionStart hooks run once, here. Their stdout is injected as context the
|
|
102
156
|
// model sees on its first turn — e.g. inject the current git branch, an
|
|
103
157
|
// on-call rota, today's deploy freeze status. (exit 2 here is ignored: there
|
|
@@ -125,6 +179,8 @@ async function main() {
|
|
|
125
179
|
model: CONFIG.model,
|
|
126
180
|
signal: controller.signal,
|
|
127
181
|
isInterrupted: () => interrupted,
|
|
182
|
+
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
183
|
+
judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
|
|
128
184
|
// Print mode never prompts: unattended means nobody can say yes.
|
|
129
185
|
// MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
|
|
130
186
|
// were enforced in permissions.ts long before this runs.
|
|
@@ -146,9 +202,8 @@ async function main() {
|
|
|
146
202
|
return; // the answer itself already streamed to stdout
|
|
147
203
|
}
|
|
148
204
|
// ---- Interactive session (REPL) -------------------------------------------------
|
|
149
|
-
// The
|
|
150
|
-
console.log(
|
|
151
|
-
chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | /help for commands · Ctrl+C interrupts a running task`));
|
|
205
|
+
// The welcome box: who am I, which model and host am I on, and the basics.
|
|
206
|
+
console.log(banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host));
|
|
152
207
|
// ONE readline interface for the whole session — the task prompt and the
|
|
153
208
|
// permission prompts share it. Two interfaces on one stdin fight each other.
|
|
154
209
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -202,8 +257,8 @@ async function main() {
|
|
|
202
257
|
// Fail closed: in a non-interactive session nobody can say yes, so the
|
|
203
258
|
// answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
|
|
204
259
|
// denies were already enforced in permissions.ts before we get here.
|
|
205
|
-
const confirm = async (question) => {
|
|
206
|
-
console.log(chalk.yellow(`\n
|
|
260
|
+
const confirm = async (question, toolName) => {
|
|
261
|
+
console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
|
|
207
262
|
if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
208
263
|
console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
|
|
209
264
|
return true; // approve without asking
|
|
@@ -212,26 +267,234 @@ async function main() {
|
|
|
212
267
|
console.log(chalk.dim(" non-interactive session — denied by default (fail closed)")); // nobody can answer — refuse
|
|
213
268
|
return false; // fail closed
|
|
214
269
|
}
|
|
215
|
-
|
|
216
|
-
|
|
270
|
+
// Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
|
|
271
|
+
// option lets the user stop being asked about this tool for the session.
|
|
272
|
+
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
273
|
+
const choice = await promptSelect(rl, ["Yes", allowLabel, "No — let me tell the agent what to do instead"]);
|
|
274
|
+
const approved = choice === 0 || choice === 1;
|
|
275
|
+
if (choice === 1 && toolName) {
|
|
276
|
+
CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
|
|
277
|
+
console.log(chalk.dim(` won't ask again for ${toolName} this session`));
|
|
278
|
+
}
|
|
279
|
+
console.log(approved ? chalk.green(" ✓ approved") : chalk.yellow(" ✗ declined"));
|
|
280
|
+
return approved;
|
|
281
|
+
};
|
|
282
|
+
// The model's handle on the user: present a multi-question form and return the
|
|
283
|
+
// selections. Null in a non-interactive session — the model is told to ask in
|
|
284
|
+
// plain text instead.
|
|
285
|
+
const askUser = async (questions) => {
|
|
286
|
+
if (!process.stdin.isTTY)
|
|
287
|
+
return null;
|
|
288
|
+
return promptForm(rl, questions);
|
|
289
|
+
};
|
|
290
|
+
// Ask the endpoint what models it serves (the OpenAI-compatible /models API).
|
|
291
|
+
// Empty list on any failure — not every endpoint implements it, and a failed
|
|
292
|
+
// listing should never break the command.
|
|
293
|
+
const listModels = async () => {
|
|
294
|
+
try {
|
|
295
|
+
const page = await client.models.list();
|
|
296
|
+
const ids = (page.data ?? []).map((m) => m.id).filter(Boolean);
|
|
297
|
+
return [...new Set(ids)].sort();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
// /model — show the current model, or switch. `/model <name>` sets it directly;
|
|
304
|
+
// bare `/model` lists the endpoint's models and lets you pick one (Day 29 menu).
|
|
305
|
+
// The switch takes effect next turn: the loop reads CONFIG.model each turn.
|
|
306
|
+
const handleModelCommand = async (arg) => {
|
|
307
|
+
// Switching is SESSION-ONLY by default — a quick "/model <name>" to try a
|
|
308
|
+
// model must never permanently change your default. "/model save <name>"
|
|
309
|
+
// (or save after picking) writes it to the global settings file.
|
|
310
|
+
const save = arg === "save" || arg.startsWith("save ");
|
|
311
|
+
const name = save ? arg.slice(4).trim() : arg;
|
|
312
|
+
const apply = (next) => {
|
|
313
|
+
const prev = CONFIG.model;
|
|
314
|
+
CONFIG.model = next; // any string — the API errors on an unknown one, and the error layer reports it
|
|
315
|
+
if (save) {
|
|
316
|
+
saveGlobalSetting("model", next); // persist as the new default
|
|
317
|
+
console.log(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
console.log(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
if (name) {
|
|
324
|
+
apply(name);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// No name: show the current model and a picker.
|
|
328
|
+
console.log(chalk.dim(`model: ${CONFIG.model}\n` +
|
|
329
|
+
(CONFIG.subAgentModel ? `sub-agent model: ${CONFIG.subAgentModel} (delegated task work)\n` : "") +
|
|
330
|
+
`endpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
|
|
331
|
+
if (!process.stdin.isTTY)
|
|
332
|
+
return; // no picker without a TTY — switch with: /model <name>
|
|
333
|
+
const models = await listModels();
|
|
334
|
+
if (!models.length) {
|
|
335
|
+
console.log(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
console.log(chalk.dim("switch model (this session):"));
|
|
339
|
+
const choice = await promptSelect(rl, formatModelChoices(models, CONFIG.model));
|
|
340
|
+
if (choice < 0 || models[choice] === CONFIG.model) {
|
|
341
|
+
console.log(chalk.dim("(model unchanged)"));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
|
|
217
345
|
};
|
|
218
346
|
// Handle a /slash command. Returns true if the line was a command.
|
|
219
347
|
const handleCommand = async (line) => {
|
|
348
|
+
// /model takes an optional argument (/model <name>), so it can't be a plain
|
|
349
|
+
// switch case — handle it before the exact-match switch.
|
|
350
|
+
if (line === "/model" || line.startsWith("/model ")) {
|
|
351
|
+
await handleModelCommand(line.slice("/model".length).trim());
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
// /skill <name> runs a skill on the user's behalf — inject its instructions
|
|
355
|
+
// as a turn so the model follows them. Works for user-only skills too.
|
|
356
|
+
if (line === "/skill" || line.startsWith("/skill ")) {
|
|
357
|
+
const name = line.slice("/skill".length).trim();
|
|
358
|
+
if (!name) {
|
|
359
|
+
console.log(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
const s = findSkill(skills, name);
|
|
363
|
+
if (!s) {
|
|
364
|
+
console.log(chalk.yellow(`(no skill named "${name}")`));
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
|
|
368
|
+
console.log(chalk.dim(`(running skill: ${s.name})`));
|
|
369
|
+
running = true;
|
|
370
|
+
interrupted = false;
|
|
371
|
+
controller = new AbortController();
|
|
372
|
+
const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, subAgentModel: CONFIG.subAgentModel, judge });
|
|
373
|
+
running = false;
|
|
374
|
+
saveSession(sessionId, CONFIG.model, messages);
|
|
375
|
+
if (r.reason !== TerminateReason.Done)
|
|
376
|
+
console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
220
379
|
switch (line) {
|
|
380
|
+
case "/skills": {
|
|
381
|
+
if (!skills.length) {
|
|
382
|
+
console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
console.log(chalk.dim(`skills in this project:`));
|
|
386
|
+
for (const s of skills) {
|
|
387
|
+
const who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
|
|
388
|
+
console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
|
|
389
|
+
}
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
221
392
|
case "/help":
|
|
222
393
|
console.log(chalk.dim(SESSION_HELP)); // the command reference
|
|
223
394
|
return true;
|
|
224
395
|
case "/clear":
|
|
225
396
|
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
226
397
|
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
398
|
+
clearUndo(); // a fresh conversation should not undo the previous one's writes
|
|
227
399
|
sessionId = newSessionId(); // a fresh conversation is a fresh session file
|
|
228
400
|
console.log(chalk.dim("(history cleared)")); // confirm the reset
|
|
229
401
|
return true;
|
|
230
402
|
case "/stats":
|
|
231
403
|
console.log(chalk.dim(statsReport())); // local counters, busiest first
|
|
232
404
|
return true;
|
|
233
|
-
case "/
|
|
234
|
-
|
|
405
|
+
case "/memory": {
|
|
406
|
+
const entries = readMemoryTyped(); // the durable, typed facts carried across sessions
|
|
407
|
+
console.log(chalk.dim(entries.length
|
|
408
|
+
? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
|
|
409
|
+
: `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`));
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
case "/cost":
|
|
413
|
+
console.log(chalk.dim(costMeter.report())); // tokens, cache hit rate, estimated spend (local)
|
|
414
|
+
return true;
|
|
415
|
+
case "/undo": {
|
|
416
|
+
// Revert the most recent write. This is a USER action on the filesystem,
|
|
417
|
+
// not a turn for the model — the conversation is untouched. If the model
|
|
418
|
+
// later edits a file it thinks it changed, edit_file's read-before-edit +
|
|
419
|
+
// exact-match guards will catch the staleness and ask it to re-read.
|
|
420
|
+
const undone = undoLast();
|
|
421
|
+
if (!undone) {
|
|
422
|
+
console.log(chalk.dim("(nothing to undo — no file writes recorded this session)"));
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
console.log(chalk.dim(`↩ ${undone.summary}`));
|
|
426
|
+
// Show the diff of what was put back (from the current state to the
|
|
427
|
+
// restored one), reusing the Day 21 renderer.
|
|
428
|
+
console.log(renderDiff(undone.after ?? "", undone.before ?? ""));
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
case "/resume": {
|
|
432
|
+
// List recent sessions in this project and switch to the chosen one.
|
|
433
|
+
// Swapping mid-conversation is fine: we rebuild from the constitution +
|
|
434
|
+
// the saved messages, and reset the per-conversation state (read-state,
|
|
435
|
+
// undo history) so the resumed session behaves like a fresh start of it.
|
|
436
|
+
const sessions = listSessions(10);
|
|
437
|
+
if (!sessions.length) {
|
|
438
|
+
console.log(chalk.dim("(no saved sessions in this project yet)"));
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
console.log(chalk.dim("recent sessions in this project:"));
|
|
442
|
+
sessions.forEach((s, i) => {
|
|
443
|
+
const when = s.savedAt.slice(0, 16).replace("T", " "); // 2026-06-15 10:30
|
|
444
|
+
console.log(chalk.dim(` ${i + 1}. ${when} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`));
|
|
445
|
+
});
|
|
446
|
+
const answer = (await readUserLine(" resume which? [number, or Enter to cancel] ")).trim();
|
|
447
|
+
if (!answer) {
|
|
448
|
+
console.log(chalk.dim("(cancelled)"));
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
const idx = Number(answer) - 1;
|
|
452
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= sessions.length) {
|
|
453
|
+
console.log(chalk.dim("(not a valid choice — cancelled)"));
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
const chosen = loadSession(sessions[idx].id);
|
|
457
|
+
if (!chosen) {
|
|
458
|
+
console.log(chalk.yellow("(could not load that session — it may be corrupt)"));
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
|
|
462
|
+
sessionId = chosen.id; // keep appending to the resumed session's file
|
|
463
|
+
forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
|
|
464
|
+
clearUndo(); // the previous session's writes are not ours to undo
|
|
465
|
+
console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
case "/diff": {
|
|
469
|
+
// A read-only review of the net effect of this session on the
|
|
470
|
+
// filesystem — every file whose content differs from where it started,
|
|
471
|
+
// shown as a diff from baseline → now. Reuses the Day 21 renderer.
|
|
472
|
+
const changes = sessionChanges();
|
|
473
|
+
if (!changes.length) {
|
|
474
|
+
console.log(chalk.dim("(no file changes this session)"));
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
|
|
478
|
+
console.log(chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`));
|
|
479
|
+
for (const c of changes) {
|
|
480
|
+
const short = path.relative(process.cwd(), c.path) || c.path;
|
|
481
|
+
console.log(`\n${verb[c.status]} ${short}`);
|
|
482
|
+
console.log(renderDiff(c.baseline, c.current));
|
|
483
|
+
}
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
case "/plan":
|
|
487
|
+
// A toggle. Turning it on injects the rules of the mode as a user turn so
|
|
488
|
+
// the model adopts them; the gate (permissions.ts) enforces them either way.
|
|
489
|
+
if (isPlanMode()) {
|
|
490
|
+
setPlanMode(false);
|
|
491
|
+
console.log(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
setPlanMode(true);
|
|
495
|
+
messages.push({ role: "user", content: PLAN_MODE_NOTICE });
|
|
496
|
+
console.log(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
|
|
497
|
+
}
|
|
235
498
|
return true;
|
|
236
499
|
case "/compact": {
|
|
237
500
|
if (messages.length <= 1) {
|
|
@@ -256,15 +519,50 @@ async function main() {
|
|
|
256
519
|
};
|
|
257
520
|
// The session loop: ask → run → render → ask again. This is what makes it
|
|
258
521
|
// a conversation instead of a one-shot command.
|
|
522
|
+
// Listen for Tab key on raw stdin — toggles expand/collapse of hidden tool output.
|
|
523
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
524
|
+
process.stdin.on("data", (buf) => {
|
|
525
|
+
if (buf[0] === 0x09) { // Tab
|
|
526
|
+
if (!running)
|
|
527
|
+
toggleLastCollapsed();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
259
531
|
while (true) {
|
|
260
|
-
|
|
532
|
+
// Before each prompt, a status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
|
|
533
|
+
// then a separator rule, then the "❯". Only in a TTY; piped input is clean.
|
|
534
|
+
if (process.stdin.isTTY) {
|
|
535
|
+
const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
|
|
536
|
+
console.log("\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt));
|
|
537
|
+
console.log(inputRule());
|
|
538
|
+
}
|
|
539
|
+
const line = (await readUserLine(framedPrompt(isPlanMode()))).trim(); // next input, or "exit" on EOF
|
|
261
540
|
if (!line)
|
|
262
541
|
continue; // empty line — just re-prompt
|
|
263
542
|
if (line === "exit" || line === "quit")
|
|
264
543
|
break; // explicit goodbye
|
|
265
544
|
if (await handleCommand(line))
|
|
266
545
|
continue; // slash commands never reach the model
|
|
267
|
-
|
|
546
|
+
// UserPromptSubmit hook: a chance to validate/inject before the model sees
|
|
547
|
+
// the prompt. exit 2 DROPS the prompt entirely (e.g. block a forbidden ask);
|
|
548
|
+
// stdout becomes extra context for this turn (e.g. inject the current ticket).
|
|
549
|
+
const submit = await runHooks("UserPromptSubmit", { prompt: line });
|
|
550
|
+
if (submit.block) {
|
|
551
|
+
console.log(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${submit.feedback.slice(0, 150)})`));
|
|
552
|
+
continue; // the prompt is erased — never reaches the model
|
|
553
|
+
}
|
|
554
|
+
const injected = submit.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${submit.stdout}` : "";
|
|
555
|
+
// @file mentions: if the line references files (@src/loop.ts), attach their
|
|
556
|
+
// content to the message so the model sees them directly. Secret files are
|
|
557
|
+
// refused here, exactly as read_file would refuse them.
|
|
558
|
+
const { augmented, mentions } = expandMentions(line);
|
|
559
|
+
const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
|
|
560
|
+
const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
|
|
561
|
+
if (attached.length)
|
|
562
|
+
console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
563
|
+
if (refused.length)
|
|
564
|
+
console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
565
|
+
messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
|
|
268
566
|
running = true; // Ctrl+C now means "interrupt the task"
|
|
269
567
|
interrupted = false; // fresh interrupt state for this task
|
|
270
568
|
controller = new AbortController(); // fresh abort signal for this task
|
|
@@ -274,9 +572,25 @@ async function main() {
|
|
|
274
572
|
signal: controller.signal, // for aborting requests
|
|
275
573
|
isInterrupted: () => interrupted, // for stopping between steps
|
|
276
574
|
confirm, // for permission prompts
|
|
575
|
+
askUser, // the multi-question form the model can pop
|
|
576
|
+
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
577
|
+
judge, // optional LLM classifier for the "ask" middle ground
|
|
277
578
|
});
|
|
278
579
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
279
580
|
saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
|
|
581
|
+
// Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
|
|
582
|
+
// that saves durable facts — especially user corrections — so memory
|
|
583
|
+
// captures itself instead of relying on the model to call `remember`.
|
|
584
|
+
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
585
|
+
try {
|
|
586
|
+
const got = await extractMemories(client, CONFIG.subAgentModel || CONFIG.model, messages);
|
|
587
|
+
if (got.length)
|
|
588
|
+
console.log(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* memory extraction must never break the session */
|
|
592
|
+
}
|
|
593
|
+
}
|
|
280
594
|
// Done already streamed its answer to the screen; everything else gets a
|
|
281
595
|
// one-line human explanation. The session continues either way — except a
|
|
282
596
|
// fatal API error, which no amount of chatting will fix.
|
|
@@ -288,8 +602,10 @@ async function main() {
|
|
|
288
602
|
break; // a bad key won't fix itself mid-session
|
|
289
603
|
}
|
|
290
604
|
}
|
|
605
|
+
await runHooks("SessionEnd", {}); // cleanup hooks (tiny 1.5s budget — the user may be leaving via Ctrl+C)
|
|
291
606
|
emit("agent_session_end"); // close the books
|
|
292
607
|
rl.close(); // release stdin
|
|
608
|
+
tuiCleanup(); // restore terminal
|
|
293
609
|
console.log(chalk.dim("bye.")); // a clean goodbye
|
|
294
610
|
}
|
|
295
611
|
main(); // kick everything off
|
package/dist/config.js
CHANGED
|
@@ -52,6 +52,10 @@ export const CONFIG = {
|
|
|
52
52
|
process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
|
|
53
53
|
"", // empty = missing; requireApiKey() turns that into a helpful error
|
|
54
54
|
model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
|
|
55
|
+
// Sub-agents (the task tool) can run on a DIFFERENT model than the orchestrator:
|
|
56
|
+
// a cheap/fast one for grunt work (reading many files, broad search), or a
|
|
57
|
+
// strong one as an "advisor" to double-check. undefined → use the main model.
|
|
58
|
+
subAgentModel: process.env.MINI_AGENT_SUBAGENT_MODEL || projectSettings.subAgentModel || globalSettings.subAgentModel || undefined,
|
|
55
59
|
contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
|
|
56
60
|
projectSettings.contextWindow || // then files
|
|
57
61
|
globalSettings.contextWindow ||
|
|
@@ -62,14 +66,44 @@ export const CONFIG = {
|
|
|
62
66
|
allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
|
|
63
67
|
deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
|
|
64
68
|
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Hooks for every event, merged across layers (global first, then project) by
|
|
70
|
+
// a generic union so adding a new event needs no edit here.
|
|
71
|
+
hooks: (() => {
|
|
72
|
+
const merged = {};
|
|
73
|
+
for (const src of [globalSettings.hooks, projectSettings.hooks]) {
|
|
74
|
+
for (const [event, defs] of Object.entries(src ?? {}))
|
|
75
|
+
merged[event] = [...(merged[event] ?? []), ...(defs ?? [])];
|
|
76
|
+
}
|
|
77
|
+
return merged;
|
|
78
|
+
})(),
|
|
79
|
+
// MCP servers: project entries override global ones with the same name.
|
|
80
|
+
mcpServers: { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) },
|
|
81
|
+
// LLM permission judge: off unless a settings file turns it on. Project wins.
|
|
82
|
+
judge: {
|
|
83
|
+
enabled: projectSettings.judge?.enabled ?? globalSettings.judge?.enabled ?? false,
|
|
84
|
+
model: projectSettings.judge?.model || globalSettings.judge?.model || undefined, // undefined → use the main model
|
|
85
|
+
},
|
|
86
|
+
// Token prices for the /cost estimate (defaults applied in cost.ts). Project
|
|
87
|
+
// overrides global; either may set just the fields it cares about.
|
|
88
|
+
pricing: { ...(globalSettings.pricing ?? {}), ...(projectSettings.pricing ?? {}) },
|
|
89
|
+
// Long-term memory: auto-extract durable facts after each turn. Off by default
|
|
90
|
+
// — it costs an extra (cheap) model call per turn. Project overrides global.
|
|
91
|
+
memory: {
|
|
92
|
+
autoExtract: projectSettings.memory?.autoExtract ?? globalSettings.memory?.autoExtract ?? false,
|
|
71
93
|
},
|
|
72
94
|
};
|
|
95
|
+
// Persist a single key to the global settings file, preserving all other keys.
|
|
96
|
+
// Used by /model to remember the user's last choice across sessions.
|
|
97
|
+
export function saveGlobalSetting(key, value) {
|
|
98
|
+
const dir = path.dirname(GLOBAL_SETTINGS_PATH);
|
|
99
|
+
if (!fs.existsSync(dir))
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const current = fs.existsSync(GLOBAL_SETTINGS_PATH)
|
|
102
|
+
? JSON.parse(fs.readFileSync(GLOBAL_SETTINGS_PATH, "utf8"))
|
|
103
|
+
: {};
|
|
104
|
+
current[key] = value;
|
|
105
|
+
fs.writeFileSync(GLOBAL_SETTINGS_PATH, JSON.stringify(current, null, 2) + "\n");
|
|
106
|
+
}
|
|
73
107
|
// No key, no point: fail with instructions instead of a stack trace later.
|
|
74
108
|
export function requireApiKey() {
|
|
75
109
|
if (CONFIG.apiKey)
|