agent-from-zero 0.1.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 +37 -3
- package/dist/agent.js +500 -34
- package/dist/config.js +88 -12
- package/dist/context.js +4 -3
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +92 -0
- package/dist/judge.js +77 -0
- package/dist/loop.js +267 -39
- 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 +104 -6
- package/dist/prompt.js +16 -11
- package/dist/session.js +103 -0
- package/dist/skills.js +128 -0
- package/dist/telemetry.js +43 -0
- package/dist/tools.js +120 -18
- 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 +4 -3
package/dist/config.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import fs from "node:fs"; //
|
|
1
|
+
import fs from "node:fs"; // reading .env and settings files
|
|
2
|
+
import os from "node:os"; // home directory for the global settings path
|
|
3
|
+
import path from "node:path"; // path joining
|
|
2
4
|
import chalk from "chalk"; // colored error output
|
|
3
5
|
// Minimal .env loader: lets `npx mini-agent` work in any directory that has a
|
|
4
6
|
// .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
7
|
try {
|
|
7
8
|
for (const line of fs.readFileSync(".env", "utf8").split("\n")) {
|
|
8
9
|
const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/); // KEY=value, nothing fancier
|
|
@@ -13,21 +14,96 @@ try {
|
|
|
13
14
|
catch {
|
|
14
15
|
/* no .env here — the variables may come from the shell instead */
|
|
15
16
|
}
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// ---- Settings files ------------------------------------------------------------
|
|
18
|
+
// Two layers, both optional:
|
|
19
|
+
// global: ~/.config/mini-agent/settings.json (your defaults, every project)
|
|
20
|
+
// project: ./.mini-agent/settings.json (this repo's rules, commit it if you like)
|
|
21
|
+
// Scalars (model, baseURL, contextWindow): project wins over global.
|
|
22
|
+
// Permission lists: BOTH layers apply — a deny from either layer is a deny.
|
|
23
|
+
// API keys never live in settings files — secrets stay in the environment.
|
|
24
|
+
export const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), ".config", "mini-agent", "settings.json");
|
|
25
|
+
export const PROJECT_SETTINGS_PATH = path.resolve(".mini-agent", "settings.json");
|
|
26
|
+
// Read one settings file. A broken settings file is a HARD error, not a warning:
|
|
27
|
+
// silently ignoring it would silently drop the user's deny rules — fail closed.
|
|
28
|
+
function readSettings(p) {
|
|
29
|
+
if (!fs.existsSync(p))
|
|
30
|
+
return {}; // absent is fine
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(p, "utf8")); // parse or throw
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
console.error(chalk.red(`Could not parse settings file: ${p}`));
|
|
36
|
+
console.error(chalk.dim(` ${err.message}`));
|
|
37
|
+
console.error("Fix or delete the file, then run again. (Ignoring it could drop your deny rules.)");
|
|
38
|
+
process.exit(1); // fail closed — never run with half-loaded security config
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const globalSettings = readSettings(GLOBAL_SETTINGS_PATH); // the user's defaults
|
|
42
|
+
const projectSettings = readSettings(PROJECT_SETTINGS_PATH); // this project's rules
|
|
43
|
+
// Provider-agnostic configuration. Resolution order, most specific wins:
|
|
44
|
+
// env MINI_AGENT_* > project settings > global settings > legacy DEEPSEEK_* env > defaults
|
|
22
45
|
export const CONFIG = {
|
|
23
|
-
baseURL: process.env.MINI_AGENT_BASE_URL || //
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
baseURL: process.env.MINI_AGENT_BASE_URL || // explicit env wins...
|
|
47
|
+
projectSettings.baseURL || // ...then the project file...
|
|
48
|
+
globalSettings.baseURL || // ...then the user's global file...
|
|
49
|
+
process.env.DEEPSEEK_BASE_URL || // ...then the legacy override (kept for back-compat and tests)
|
|
50
|
+
"https://api.deepseek.com", // ...and finally the default provider
|
|
26
51
|
apiKey: process.env.MINI_AGENT_API_KEY || // generic key wins...
|
|
27
52
|
process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
|
|
28
53
|
"", // empty = missing; requireApiKey() turns that into a helpful error
|
|
29
|
-
model: process.env.MINI_AGENT_MODEL || "deepseek-chat", // must support function calling
|
|
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,
|
|
59
|
+
contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
|
|
60
|
+
projectSettings.contextWindow || // then files
|
|
61
|
+
globalSettings.contextWindow ||
|
|
62
|
+
1_048_565, // DeepSeek's real window, learned from an actual API error
|
|
63
|
+
permissions: {
|
|
64
|
+
// Both layers apply: global rules + project rules. Mutable on purpose —
|
|
65
|
+
// the test suite injects rules here instead of writing temp files.
|
|
66
|
+
allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
|
|
67
|
+
deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
|
|
68
|
+
},
|
|
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,
|
|
93
|
+
},
|
|
30
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
|
+
}
|
|
31
107
|
// No key, no point: fail with instructions instead of a stack trace later.
|
|
32
108
|
export function requireApiKey() {
|
|
33
109
|
if (CONFIG.apiKey)
|
package/dist/context.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs"; // re-reading files from disk during recovery
|
|
2
2
|
import chalk from "chalk"; // status lines
|
|
3
|
+
import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
|
|
3
4
|
import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
|
|
4
5
|
// ---- The context window ------------------------------------------------------
|
|
5
|
-
// 1,048,565 came from a real API error message, not from
|
|
6
|
+
// The default (1,048,565) came from a real API error message, not from docs.
|
|
6
7
|
// Trust what the API tells you over what you remember reading.
|
|
7
|
-
export const CONTEXT_WINDOW =
|
|
8
|
+
export const CONTEXT_WINDOW = CONFIG.contextWindow; // resolved in config.ts: env > project file > global file > default
|
|
8
9
|
// Compact well before the hard limit: estimation is approximate and the summary
|
|
9
10
|
// 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)); //
|
|
11
|
+
export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // env override kept so tests can trigger compaction cheaply
|
|
11
12
|
// How many times one query may compact / fail to compact before we give up.
|
|
12
13
|
export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
|
|
13
14
|
export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
|
package/dist/cost.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Token & cost accounting. The most-emphasized lesson of the Harness book is
|
|
2
|
+
// token economics: "cache breaks are silent", "90% of the bill is re-sending
|
|
3
|
+
// the same prompt". You cannot manage what you cannot see — so every model
|
|
4
|
+
// call's usage is metered, and /cost shows tokens, the cache hit rate, and an
|
|
5
|
+
// estimated spend. Nothing here leaves the machine; it is just arithmetic over
|
|
6
|
+
// the `usage` the API already returns.
|
|
7
|
+
export const DEFAULT_PRICING = {
|
|
8
|
+
inputPerM: 0.27, // $ / 1M — check your provider; override in settings.pricing
|
|
9
|
+
cachedInputPerM: 0.07, // cache hits are ~4x cheaper — this is why hit rate matters
|
|
10
|
+
outputPerM: 1.1, // output is the pricey one — don't let the model ramble
|
|
11
|
+
};
|
|
12
|
+
// Normalize a provider `usage` object into our four fields. Handles DeepSeek
|
|
13
|
+
// (prompt_cache_hit_tokens / prompt_cache_miss_tokens) and OpenAI
|
|
14
|
+
// (prompt_tokens_details.cached_tokens) shapes; falls back gracefully.
|
|
15
|
+
export function normalizeUsage(usage) {
|
|
16
|
+
if (!usage)
|
|
17
|
+
return { inputUncached: 0, inputCached: 0, output: 0 };
|
|
18
|
+
const prompt = Number(usage.prompt_tokens ?? 0); // total input tokens
|
|
19
|
+
const output = Number(usage.completion_tokens ?? 0); // total output tokens
|
|
20
|
+
// DeepSeek reports the split directly:
|
|
21
|
+
const dsHit = Number(usage.prompt_cache_hit_tokens ?? NaN);
|
|
22
|
+
const dsMiss = Number(usage.prompt_cache_miss_tokens ?? NaN);
|
|
23
|
+
if (!Number.isNaN(dsHit) && !Number.isNaN(dsMiss)) {
|
|
24
|
+
return { inputUncached: dsMiss, inputCached: dsHit, output };
|
|
25
|
+
}
|
|
26
|
+
// OpenAI reports only the cached count, nested:
|
|
27
|
+
const details = usage.prompt_tokens_details;
|
|
28
|
+
const cached = Number(details?.cached_tokens ?? 0);
|
|
29
|
+
return { inputUncached: Math.max(0, prompt - cached), inputCached: cached, output };
|
|
30
|
+
}
|
|
31
|
+
// Estimated dollar cost of some usage under some pricing. Pure — easy to test.
|
|
32
|
+
export function costOf(u, p) {
|
|
33
|
+
return (u.inputUncached * p.inputPerM + u.inputCached * p.cachedInputPerM + u.output * p.outputPerM) / 1_000_000;
|
|
34
|
+
}
|
|
35
|
+
// Cache hit rate over input tokens, 0..1. The single most useful number for
|
|
36
|
+
// spotting a silent cache break: if it drops, you are paying full price for a
|
|
37
|
+
// prefix that should have been free.
|
|
38
|
+
export function cacheHitRate(u) {
|
|
39
|
+
const input = u.inputUncached + u.inputCached;
|
|
40
|
+
return input === 0 ? 0 : u.inputCached / input;
|
|
41
|
+
}
|
|
42
|
+
// The process-wide meter. The loop records into it from every stream; the CLI
|
|
43
|
+
// builds it once (with the configured prices) and /cost reads it. A module
|
|
44
|
+
// singleton because metering is ambient — threading it through every call would
|
|
45
|
+
// add noise for a cross-cutting concern. Tests construct their own CostMeter.
|
|
46
|
+
let sessionMeter = null;
|
|
47
|
+
export function initCostMeter(pricing) {
|
|
48
|
+
sessionMeter = new CostMeter(pricing);
|
|
49
|
+
return sessionMeter;
|
|
50
|
+
}
|
|
51
|
+
export function recordUsage(usage) {
|
|
52
|
+
sessionMeter?.record(usage); // no-op until the CLI initializes it (tests stay silent)
|
|
53
|
+
}
|
|
54
|
+
// Accumulates usage across a session and formats /cost.
|
|
55
|
+
export class CostMeter {
|
|
56
|
+
pricing;
|
|
57
|
+
total = { inputUncached: 0, inputCached: 0, output: 0 };
|
|
58
|
+
calls = 0; // how many model calls were metered
|
|
59
|
+
constructor(pricing) {
|
|
60
|
+
this.pricing = pricing;
|
|
61
|
+
}
|
|
62
|
+
// Fold one response's usage into the running total.
|
|
63
|
+
record(usage) {
|
|
64
|
+
const u = normalizeUsage(usage);
|
|
65
|
+
if (u.inputUncached + u.inputCached + u.output === 0)
|
|
66
|
+
return; // a chunk with no usage — ignore
|
|
67
|
+
this.total.inputUncached += u.inputUncached;
|
|
68
|
+
this.total.inputCached += u.inputCached;
|
|
69
|
+
this.total.output += u.output;
|
|
70
|
+
this.calls++;
|
|
71
|
+
}
|
|
72
|
+
// Numbers for the status line: estimated spend so far and total tokens.
|
|
73
|
+
cost() {
|
|
74
|
+
return costOf(this.total, this.pricing);
|
|
75
|
+
}
|
|
76
|
+
inputTokens() {
|
|
77
|
+
return this.total.inputUncached + this.total.inputCached;
|
|
78
|
+
}
|
|
79
|
+
outputTokens() {
|
|
80
|
+
return this.total.output;
|
|
81
|
+
}
|
|
82
|
+
// The /cost report. Estimated, local-only.
|
|
83
|
+
report() {
|
|
84
|
+
const t = this.total;
|
|
85
|
+
const input = t.inputUncached + t.inputCached;
|
|
86
|
+
return [
|
|
87
|
+
`tokens this session (${this.calls} model calls):`,
|
|
88
|
+
` input: ${input} (${t.inputCached} cached, ${(cacheHitRate(t) * 100).toFixed(0)}% hit rate)`,
|
|
89
|
+
` output: ${t.output}`,
|
|
90
|
+
` estimated cost: $${costOf(t, this.pricing).toFixed(4)} (rough — prices set in settings.pricing)`,
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs"; // read the target file to diff against
|
|
2
|
+
import path from "node:path"; // resolve the file path
|
|
3
|
+
import chalk from "chalk"; // color the +/- lines (auto-disabled when output isn't a TTY)
|
|
4
|
+
// Longest-common-subsequence over lines → an ordered list of ops. Classic DP:
|
|
5
|
+
// lcs[i][j] = length of the LCS of a[i:] and b[j:]. Walking it forwards turns the
|
|
6
|
+
// table into the smallest set of removals + additions that turns a into b.
|
|
7
|
+
export function diffLines(oldText, newText) {
|
|
8
|
+
const a = oldText.length ? oldText.split("\n") : []; // empty string = no lines (not [""])
|
|
9
|
+
const b = newText.length ? newText.split("\n") : [];
|
|
10
|
+
const n = a.length;
|
|
11
|
+
const m = b.length;
|
|
12
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); // (n+1)×(m+1), last row/col are zeros
|
|
13
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
14
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
15
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); // match extends the subsequence; else take the better side
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const ops = [];
|
|
19
|
+
let i = 0;
|
|
20
|
+
let j = 0;
|
|
21
|
+
while (i < n && j < m) {
|
|
22
|
+
if (a[i] === b[j])
|
|
23
|
+
ops.push({ kind: " ", line: a[i++] }), j++; // common line — keep it as context
|
|
24
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1])
|
|
25
|
+
ops.push({ kind: "-", line: a[i++] }); // dropping a[i] keeps more of the LCS → it was removed
|
|
26
|
+
else
|
|
27
|
+
ops.push({ kind: "+", line: b[j++] }); // otherwise b[j] is an addition
|
|
28
|
+
}
|
|
29
|
+
while (i < n)
|
|
30
|
+
ops.push({ kind: "-", line: a[i++] }); // leftover old lines were all removed
|
|
31
|
+
while (j < m)
|
|
32
|
+
ops.push({ kind: "+", line: b[j++] }); // leftover new lines were all added
|
|
33
|
+
return ops;
|
|
34
|
+
}
|
|
35
|
+
const CONTEXT = 3; // unchanged lines to keep around each change, for readability
|
|
36
|
+
const MAX_LINES = 60; // hard cap on rendered lines — a huge diff must not flood the prompt
|
|
37
|
+
// Paint one line by its kind. chalk no-ops when colors are off, so the plain
|
|
38
|
+
// text is always present (and assertable in tests).
|
|
39
|
+
function paint(kind, text) {
|
|
40
|
+
if (kind === "+")
|
|
41
|
+
return chalk.green(text);
|
|
42
|
+
if (kind === "-")
|
|
43
|
+
return chalk.red(text);
|
|
44
|
+
return chalk.dim(text);
|
|
45
|
+
}
|
|
46
|
+
// Render ops as a compact unified diff: a "+A -B" summary header, the changed
|
|
47
|
+
// lines with a few lines of context, and long runs of untouched lines collapsed
|
|
48
|
+
// to a "⋮ (N unchanged lines)" marker so the reader sees the change, not the file.
|
|
49
|
+
export function renderDiff(oldText, newText, color = true) {
|
|
50
|
+
const ops = diffLines(oldText, newText);
|
|
51
|
+
const added = ops.filter((o) => o.kind === "+").length;
|
|
52
|
+
const removed = ops.filter((o) => o.kind === "-").length;
|
|
53
|
+
// Keep any unchanged line within CONTEXT of a change; drop the rest.
|
|
54
|
+
const keep = new Array(ops.length).fill(false);
|
|
55
|
+
ops.forEach((op, idx) => {
|
|
56
|
+
if (op.kind === " ")
|
|
57
|
+
return;
|
|
58
|
+
for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(ops.length - 1, idx + CONTEXT); k++)
|
|
59
|
+
keep[k] = true;
|
|
60
|
+
});
|
|
61
|
+
const out = [];
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < ops.length) {
|
|
64
|
+
if (keep[i]) {
|
|
65
|
+
out.push(color ? paint(ops[i].kind, `${ops[i].kind} ${ops[i].line}`) : `${ops[i].kind} ${ops[i].line}`);
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
let skipped = 0;
|
|
70
|
+
while (i < ops.length && !keep[i])
|
|
71
|
+
skipped++, i++; // collapse the whole untouched run at once
|
|
72
|
+
const note = ` ⋮ (${skipped} unchanged line${skipped === 1 ? "" : "s"})`;
|
|
73
|
+
out.push(color ? chalk.dim(note) : note);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const body = out.length > MAX_LINES ? [...out.slice(0, MAX_LINES), ` … (${out.length - MAX_LINES} more diff lines)`] : out;
|
|
77
|
+
const header = ` +${added} -${removed}`;
|
|
78
|
+
return [color ? chalk.dim(header) : header, ...body].join("\n");
|
|
79
|
+
}
|
|
80
|
+
const MAX_PREVIEW_BYTES = 200_000; // don't read+diff an enormous file just to preview it
|
|
81
|
+
// Read a file for diffing, or null if it shouldn't be diffed (missing, too big,
|
|
82
|
+
// or binary). A preview must never break a tool call, so every failure is null.
|
|
83
|
+
function safeRead(p) {
|
|
84
|
+
try {
|
|
85
|
+
if (fs.statSync(p).size > MAX_PREVIEW_BYTES)
|
|
86
|
+
return null; // too big to preview cheaply
|
|
87
|
+
const text = fs.readFileSync(p, "utf8");
|
|
88
|
+
return text.includes("\0") ? null : text; // a NUL byte means binary — don't diff it
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null; // unreadable / missing
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Build the diff preview shown before a write/edit runs. Returns null when there
|
|
95
|
+
// is nothing useful to show (not a write tool, unparseable args, binary file).
|
|
96
|
+
// NEVER throws.
|
|
97
|
+
export function previewChange(toolName, argsJson) {
|
|
98
|
+
let args;
|
|
99
|
+
try {
|
|
100
|
+
args = JSON.parse(argsJson);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null; // dispatch will report the JSON error; nothing to preview
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (toolName === "write_file") {
|
|
107
|
+
const p = path.resolve(args.path ?? "");
|
|
108
|
+
const exists = fs.existsSync(p);
|
|
109
|
+
const oldText = exists ? safeRead(p) : ""; // new file → empty "before"
|
|
110
|
+
if (oldText === null)
|
|
111
|
+
return null; // existing but binary/oversized
|
|
112
|
+
return ` ${exists ? "overwrite" : "create"} ${args.path}\n${renderDiff(oldText, String(args.content ?? ""))}`;
|
|
113
|
+
}
|
|
114
|
+
if (toolName === "edit_file") {
|
|
115
|
+
const p = path.resolve(args.path ?? "");
|
|
116
|
+
if (!fs.existsSync(p))
|
|
117
|
+
return null; // edit of a missing file → the tool will error
|
|
118
|
+
const oldText = safeRead(p);
|
|
119
|
+
if (oldText === null)
|
|
120
|
+
return null;
|
|
121
|
+
const occurrences = args.old_string ? oldText.split(args.old_string).length - 1 : 0;
|
|
122
|
+
// The tool only applies a unique match. If it's unique, diff the real
|
|
123
|
+
// before/after; otherwise just show the intended swap (the tool will
|
|
124
|
+
// reject a 0/ambiguous match anyway, but the human still sees the intent).
|
|
125
|
+
const newText = occurrences === 1 ? oldText.split(args.old_string).join(args.new_string ?? "") : null;
|
|
126
|
+
return newText !== null
|
|
127
|
+
? ` edit ${args.path}\n${renderDiff(oldText, newText)}`
|
|
128
|
+
: ` edit ${args.path}\n${renderDiff(String(args.old_string ?? ""), String(args.new_string ?? ""))}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null; // anything unexpected → no preview, never a crash
|
|
133
|
+
}
|
|
134
|
+
return null; // not a file-changing tool
|
|
135
|
+
}
|
package/dist/form.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import chalk from "chalk"; // rendering only
|
|
2
|
+
// The rows are a flat list: every option of every question, then one submit row.
|
|
3
|
+
// rowCount = total options + 1. The cursor walks this flat list.
|
|
4
|
+
export function formRowCount(questions) {
|
|
5
|
+
return questions.reduce((n, q) => n + q.options.length, 0) + 1; // +1 for "Submit"
|
|
6
|
+
}
|
|
7
|
+
// Map a flat cursor to either a question/option, or the submit row.
|
|
8
|
+
export function rowAt(questions, cursor) {
|
|
9
|
+
let i = cursor;
|
|
10
|
+
for (let q = 0; q < questions.length; q++) {
|
|
11
|
+
if (i < questions[q].options.length)
|
|
12
|
+
return { q, opt: i };
|
|
13
|
+
i -= questions[q].options.length;
|
|
14
|
+
}
|
|
15
|
+
return { submit: true };
|
|
16
|
+
}
|
|
17
|
+
// The flat cursor position of a question's first option — used to jump the
|
|
18
|
+
// cursor to the first unanswered question when the user tries to submit early.
|
|
19
|
+
function cursorForQuestion(questions, q) {
|
|
20
|
+
let c = 0;
|
|
21
|
+
for (let i = 0; i < q; i++)
|
|
22
|
+
c += questions[i].options.length;
|
|
23
|
+
return c;
|
|
24
|
+
}
|
|
25
|
+
export function initFormState(questions) {
|
|
26
|
+
return { cursor: 0, selections: questions.map(() => -1) }; // nothing chosen yet
|
|
27
|
+
}
|
|
28
|
+
// The one transition function. Returns the next state, and `done: true` only
|
|
29
|
+
// when the user submits with every question answered.
|
|
30
|
+
export function reduceForm(questions, state, action) {
|
|
31
|
+
const rows = formRowCount(questions);
|
|
32
|
+
if (action === "up")
|
|
33
|
+
return { state: { ...state, cursor: Math.max(0, state.cursor - 1) } };
|
|
34
|
+
if (action === "down")
|
|
35
|
+
return { state: { ...state, cursor: Math.min(rows - 1, state.cursor + 1) } };
|
|
36
|
+
// select (Enter / Space)
|
|
37
|
+
const row = rowAt(questions, state.cursor);
|
|
38
|
+
if ("submit" in row) {
|
|
39
|
+
const firstUnanswered = state.selections.findIndex((s) => s < 0);
|
|
40
|
+
if (firstUnanswered < 0)
|
|
41
|
+
return { state, done: true }; // all answered → submit for real
|
|
42
|
+
return { state: { ...state, cursor: cursorForQuestion(questions, firstUnanswered) } }; // nudge to the gap
|
|
43
|
+
}
|
|
44
|
+
const selections = state.selections.slice();
|
|
45
|
+
selections[row.q] = row.opt; // set this question's answer (single-select)
|
|
46
|
+
return { state: { ...state, selections } };
|
|
47
|
+
}
|
|
48
|
+
// Turn a completed form into question→answer pairs for the model.
|
|
49
|
+
export function collectAnswers(questions, state) {
|
|
50
|
+
return questions.map((q, i) => ({ question: q.question, answer: q.options[state.selections[i]] ?? "(unanswered)" }));
|
|
51
|
+
}
|
|
52
|
+
const FORM_HINT = chalk.dim("↑↓ to move · Enter to choose / submit · Esc to cancel");
|
|
53
|
+
// Render the whole form: each question with radio-style options, the cursor on
|
|
54
|
+
// one row, then a submit row. Line count is constant for a given question set
|
|
55
|
+
// (selections don't add/remove lines), which is what lets the driver redraw in
|
|
56
|
+
// place by moving the cursor up a fixed number of lines.
|
|
57
|
+
export function renderForm(questions, state) {
|
|
58
|
+
const lines = ["The agent needs your input:", ""];
|
|
59
|
+
let flat = 0;
|
|
60
|
+
questions.forEach((q, qi) => {
|
|
61
|
+
lines.push(chalk.bold(`${qi + 1}. ${q.question}`));
|
|
62
|
+
q.options.forEach((opt, oi) => {
|
|
63
|
+
const onCursor = state.cursor === flat;
|
|
64
|
+
const chosen = state.selections[qi] === oi;
|
|
65
|
+
const text = `${chosen ? "●" : "○"} ${opt}`; // filled radio = chosen
|
|
66
|
+
lines.push(onCursor ? chalk.cyan.bold(`❯ ${text}`) : chosen ? chalk.cyan(` ${text}`) : chalk.dim(` ${text}`));
|
|
67
|
+
flat++;
|
|
68
|
+
});
|
|
69
|
+
lines.push("");
|
|
70
|
+
});
|
|
71
|
+
const onSubmit = state.cursor === flat; // the submit row is last
|
|
72
|
+
const allAnswered = state.selections.every((s) => s >= 0);
|
|
73
|
+
const submitText = allAnswered ? "▶ Submit answers" : "▶ Submit answers (answer every question first)";
|
|
74
|
+
lines.push(onSubmit ? chalk.green.bold(`❯ ${submitText}`) : chalk.dim(` ${submitText}`));
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push(FORM_HINT);
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"; // hooks are external programs
|
|
2
|
+
import chalk from "chalk"; // status lines
|
|
3
|
+
import { CONFIG } from "./config.js"; // user-configured hooks
|
|
4
|
+
import { emit } from "./telemetry.js"; // count hook activity
|
|
5
|
+
// Hooks let users extend the agent without touching its source: an external
|
|
6
|
+
// program runs at a lifecycle moment and can observe — or block — what happens.
|
|
7
|
+
// The contract is deliberately language-agnostic (a shell command, JSON on
|
|
8
|
+
// stdin, the exit code as the verdict) so a hook can be a bash one-liner, a
|
|
9
|
+
// Python script, anything. This is the "keep the core small, bolt business
|
|
10
|
+
// logic on via hooks" architecture.
|
|
11
|
+
//
|
|
12
|
+
// Communication protocol (per event):
|
|
13
|
+
// exit 0 → allow. stdout is captured (SessionStart/UserPromptSubmit inject it
|
|
14
|
+
// as context; a PreToolUse hook may emit {"toolInput":{…}} to REWRITE
|
|
15
|
+
// the tool's arguments — something the 3-state permission gate can't).
|
|
16
|
+
// exit 2 → block. stderr is fed back to the model. The MEANING of "block"
|
|
17
|
+
// differs by event and is the caller's to interpret (PreToolUse →
|
|
18
|
+
// don't run; Stop → keep going; UserPromptSubmit → drop the prompt;
|
|
19
|
+
// observational events just ignore it).
|
|
20
|
+
// other → the hook itself errored; we log and treat it as allow (a broken
|
|
21
|
+
// hook must not wedge the agent — but it is loud, not silent).
|
|
22
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 10_000; // most hooks get 10s, then we kill it
|
|
23
|
+
// Differentiated timeouts: SessionEnd fires when the user is leaving (often via
|
|
24
|
+
// Ctrl+C) — it MUST return almost instantly, so it gets a tiny budget.
|
|
25
|
+
const EVENT_TIMEOUT_MS = { SessionEnd: 1_500 };
|
|
26
|
+
// Run one hook process, feeding it the event payload on stdin.
|
|
27
|
+
function runOne(hook, payload, timeoutMs) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const child = spawn(hook.command, { shell: true, stdio: ["pipe", "pipe", "pipe"] }); // shell so users can write one-liners
|
|
30
|
+
let stdout = "", stderr = "", settled = false; // accumulate; resolve once
|
|
31
|
+
const done = (code) => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
37
|
+
};
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
child.kill("SIGKILL"); // a hung hook is killed, never hangs the agent
|
|
40
|
+
done(124); // 124 = timed out (treated as "other" → allow, logged)
|
|
41
|
+
}, hook.timeoutMs ?? timeoutMs);
|
|
42
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
43
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
44
|
+
child.on("error", () => done(127)); // could not start (e.g. command not found)
|
|
45
|
+
child.on("close", (code) => done(code ?? 0));
|
|
46
|
+
child.stdin.end(JSON.stringify(payload)); // the event, as JSON, on stdin
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// Run every hook registered for an event, in order, and combine their verdicts.
|
|
50
|
+
// `match` (when present) filters by tool name so a hook can target one tool.
|
|
51
|
+
export async function runHooks(event, payload) {
|
|
52
|
+
const hooks = (CONFIG.hooks[event] ?? []).filter((h) => !h.match || h.match === payload.tool); // applicable hooks only
|
|
53
|
+
if (!hooks.length)
|
|
54
|
+
return { block: false, feedback: "", stdout: "" }; // the common case: nothing registered
|
|
55
|
+
const feedback = []; // stderr from blocking hooks
|
|
56
|
+
const stdout = []; // stdout from passing hooks
|
|
57
|
+
let block = false; // did any hook exit 2?
|
|
58
|
+
let rewrite; // a PreToolUse hook's replacement args
|
|
59
|
+
const timeout = EVENT_TIMEOUT_MS[event] ?? DEFAULT_HOOK_TIMEOUT_MS; // SessionEnd gets a tiny budget
|
|
60
|
+
for (const hook of hooks) {
|
|
61
|
+
const { code, stdout: out, stderr: err } = await runOne(hook, { event, ...payload }, timeout); // run it
|
|
62
|
+
emit("agent_hook_run", { event, code }); // observability for hooks themselves
|
|
63
|
+
if (code === 0) {
|
|
64
|
+
if (out) {
|
|
65
|
+
stdout.push(out); // passing-hook output (used by SessionStart / UserPromptSubmit)
|
|
66
|
+
// A PreToolUse hook can REWRITE the tool's arguments by printing
|
|
67
|
+
// {"toolInput": {...}} — the "side road" the permission gate can't take
|
|
68
|
+
// (it only allows/denies). Classic use: rewrite `git commit` to add a
|
|
69
|
+
// trailer. Last writer wins if several hooks rewrite.
|
|
70
|
+
if (event === "PreToolUse") {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(out);
|
|
73
|
+
if (parsed && parsed.toolInput && typeof parsed.toolInput === "object")
|
|
74
|
+
rewrite = JSON.stringify(parsed.toolInput);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* not control JSON — just plain stdout */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (code === 2) {
|
|
83
|
+
block = true; // a blocking verdict
|
|
84
|
+
feedback.push(err || `(${event} hook blocked with no message)`); // the reason, for the model
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Any other code: the hook is broken. Be loud, but do not wedge the agent.
|
|
88
|
+
console.error(chalk.yellow(` [hook] ${event} command exited ${code} (ignored): ${(err || out).slice(0, 120)}`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { block, feedback: feedback.join("\n"), stdout: stdout.join("\n"), rewrite };
|
|
92
|
+
}
|
package/dist/judge.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { emit } from "./telemetry.js"; // observe the judge's decisions
|
|
2
|
+
// An OPTIONAL second layer over the rule-based permission gate. The rules
|
|
3
|
+
// (permissions.ts) own deny and the obvious allows; whatever they mark "ask"
|
|
4
|
+
// is the ambiguous middle — an unfamiliar-but-maybe-harmless command like
|
|
5
|
+
// `ffmpeg ...`. Asking the human about every one of those is approval fatigue.
|
|
6
|
+
//
|
|
7
|
+
// The judge looks ONLY at those "ask" verdicts and answers one question: is
|
|
8
|
+
// this clearly safe? If yes, we skip the prompt; if no (or it is unsure, or it
|
|
9
|
+
// errors), we keep asking. Two invariants make this safe to add:
|
|
10
|
+
// 1. The judge NEVER sees a deny — deny always wins, the rules decide it.
|
|
11
|
+
// 2. The judge can only DOWNGRADE ask→allow for the clearly safe. It can
|
|
12
|
+
// never create an allow the rules denied, and any doubt stays "ask".
|
|
13
|
+
// In other words: the judge reduces friction, it never reduces safety.
|
|
14
|
+
// The judge prompt is deliberately blunt and asks for a single rigid token, so
|
|
15
|
+
// parsing is unambiguous. "Err on the side of NO" is the load-bearing line.
|
|
16
|
+
const JUDGE_SYSTEM = `You are a security classifier for a coding agent. You are shown ONE shell command that the agent wants to run. Decide whether it is CLEARLY SAFE to run without asking a human.
|
|
17
|
+
|
|
18
|
+
CLEARLY SAFE = read-only or trivially reversible, no data loss, no network exfiltration, no privilege change, no writes outside the project, no credential access.
|
|
19
|
+
NOT clearly safe = anything that deletes, overwrites, installs, pushes, sends data out, changes permissions, or that you are unsure about.
|
|
20
|
+
|
|
21
|
+
Err strongly on the side of NO. When in doubt, answer no.
|
|
22
|
+
Respond with EXACTLY one line and nothing else: <safe>yes</safe> or <safe>no</safe>`;
|
|
23
|
+
const JUDGE_MAX_TOKENS = 16; // we only need one tiny tagged token — keep it cheap and fast
|
|
24
|
+
const MAX_CONSECUTIVE_JUDGE_ERRORS = 3; // after this many failures, disable the judge for the session
|
|
25
|
+
// Parse the judge's reply. PURE function (no I/O) so the safety logic is unit
|
|
26
|
+
// testable without the network. Fail closed: anything that is not an
|
|
27
|
+
// unambiguous "yes" means "ask".
|
|
28
|
+
export function interpretJudgeOutput(text) {
|
|
29
|
+
const m = text.toLowerCase().match(/<safe>\s*(yes|no)\s*<\/safe>/); // strict tag, nothing else trusted
|
|
30
|
+
if (!m)
|
|
31
|
+
return "ask"; // no clean verdict → ask (fail closed)
|
|
32
|
+
return m[1] === "yes" ? "allow" : "ask"; // only a clear yes downgrades
|
|
33
|
+
}
|
|
34
|
+
// Tracks the judge's health across a session so a flaky/misconfigured judge
|
|
35
|
+
// degrades gracefully to "always ask" instead of silently failing on every call.
|
|
36
|
+
export class Judge {
|
|
37
|
+
client;
|
|
38
|
+
model;
|
|
39
|
+
consecutiveErrors = 0; // resets on any successful classification
|
|
40
|
+
disabled = false; // tripped after too many consecutive errors
|
|
41
|
+
constructor(client, // same client as the loop
|
|
42
|
+
model) {
|
|
43
|
+
this.client = client;
|
|
44
|
+
this.model = model;
|
|
45
|
+
}
|
|
46
|
+
// Classify one "ask" command. Returns "allow" only when the judge is
|
|
47
|
+
// confident it is safe; "ask" in every other case (unsure, error, disabled).
|
|
48
|
+
async classify(command) {
|
|
49
|
+
if (this.disabled)
|
|
50
|
+
return "ask"; // circuit breaker tripped — back to asking
|
|
51
|
+
try {
|
|
52
|
+
const res = await this.client.chat.completions.create({
|
|
53
|
+
model: this.model,
|
|
54
|
+
max_tokens: JUDGE_MAX_TOKENS, // tiny — this must be cheap
|
|
55
|
+
temperature: 0, // deterministic classification
|
|
56
|
+
messages: [
|
|
57
|
+
{ role: "system", content: JUDGE_SYSTEM },
|
|
58
|
+
{ role: "user", content: `Command:\n${command}` }, // ONLY the command — no chat history to be injected through
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
this.consecutiveErrors = 0; // a clean call resets the breaker
|
|
62
|
+
const verdict = interpretJudgeOutput(res.choices[0]?.message?.content ?? "");
|
|
63
|
+
emit("agent_judge_verdict", { verdict }); // allow / ask, for tuning
|
|
64
|
+
return verdict;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A judge that errors must never accidentally allow something. Count the
|
|
68
|
+
// failure, and after a few in a row, stop trying (degrade to asking).
|
|
69
|
+
this.consecutiveErrors++;
|
|
70
|
+
if (this.consecutiveErrors >= MAX_CONSECUTIVE_JUDGE_ERRORS) {
|
|
71
|
+
this.disabled = true;
|
|
72
|
+
emit("agent_judge_disabled", {}); // we gave up on the judge this session
|
|
73
|
+
}
|
|
74
|
+
return "ask"; // fail closed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|