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/permissions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path"; // used to resolve and split file paths
|
|
2
|
+
import { CONFIG } from "./config.js"; // user-configured allow/deny rules from settings files
|
|
2
3
|
// ---- The no-fly zone --------------------------------------------------------
|
|
3
4
|
// Writes here are never OK, in any mode. .git can destroy history, shell rc
|
|
4
5
|
// files inject code into every future terminal, .ssh/.env leak credentials.
|
|
@@ -43,13 +44,26 @@ const BASH_ASK = [
|
|
|
43
44
|
// Single, read-only-ish commands that run without asking.
|
|
44
45
|
const BASH_ALLOW = new Set(["ls", "pwd", "echo", "wc", "which", "date", "node", "npm", "npx", "cat", "head", "tail"]);
|
|
45
46
|
const GIT_READONLY = new Set(["status", "log", "diff", "show", "branch", "remote", "tag"]); // git subcommands that only read
|
|
47
|
+
// User rules from settings files, split by kind. Computed per call (not cached)
|
|
48
|
+
// so the test suite can mutate CONFIG.permissions and see the effect.
|
|
49
|
+
function userBashRules() {
|
|
50
|
+
return {
|
|
51
|
+
allow: CONFIG.permissions.allow.filter((r) => !r.startsWith("tool:")), // bash first-words to trust
|
|
52
|
+
deny: CONFIG.permissions.deny.filter((r) => !r.startsWith("tool:")), // command substrings to block
|
|
53
|
+
};
|
|
54
|
+
}
|
|
46
55
|
// Decide what a bash command deserves: deny, ask, or allow.
|
|
47
56
|
function checkBash(command) {
|
|
48
57
|
const summary = command.trim(); // what the user will see in the prompt
|
|
49
|
-
|
|
58
|
+
const user = userBashRules(); // the user's configured additions
|
|
59
|
+
// Order is the security model: built-in deny, then user deny, then ask,
|
|
60
|
+
// then allow. A user allow can never jump this queue — allow is checked last.
|
|
50
61
|
for (const [re, why] of BASH_DENY)
|
|
51
62
|
if (re.test(command))
|
|
52
63
|
return { decision: "deny", reason: why, summary };
|
|
64
|
+
for (const d of user.deny)
|
|
65
|
+
if (command.toLowerCase().includes(d.toLowerCase()))
|
|
66
|
+
return { decision: "deny", reason: `denied by your settings ("${d}")`, summary };
|
|
53
67
|
for (const [re, why] of BASH_ASK)
|
|
54
68
|
if (re.test(command))
|
|
55
69
|
return { decision: "ask", reason: why, summary };
|
|
@@ -61,12 +75,60 @@ function checkBash(command) {
|
|
|
61
75
|
return { decision: "allow", reason: "read-only git", summary }; // safe git subcommands
|
|
62
76
|
if (BASH_ALLOW.has(words[0]))
|
|
63
77
|
return { decision: "allow", reason: "safe command", summary }; // known-harmless single command
|
|
78
|
+
if (user.allow.includes(words[0]))
|
|
79
|
+
return { decision: "allow", reason: "allowed by your settings", summary }; // the user vouched for this command
|
|
64
80
|
// Fail closed: a command we don't recognize is a question for the human.
|
|
65
81
|
return { decision: "ask", reason: "unrecognized command", summary };
|
|
66
82
|
}
|
|
83
|
+
// ---- Plan mode (Day 20) ------------------------------------------------------
|
|
84
|
+
// A research-only mode. While it is on, the agent may observe — read files,
|
|
85
|
+
// search, run safe read-only shell — but every tool that mutates the world is
|
|
86
|
+
// blocked, until it presents a plan and the user approves it. The state lives
|
|
87
|
+
// here because the gate is what enforces it; the REPL toggles it with /plan, and
|
|
88
|
+
// the exit_plan_mode tool turns it off once the user approves a plan.
|
|
89
|
+
let planMode = false; // off by default — most sessions never touch it
|
|
90
|
+
export const isPlanMode = () => planMode; // the REPL reads this to mark its prompt
|
|
91
|
+
export const setPlanMode = (on) => { planMode = on; }; // /plan and exit_plan_mode flip it
|
|
92
|
+
// In plan mode, is this call safe — i.e. does it only observe, never mutate?
|
|
93
|
+
// run_bash is decided by its own classifier: a command the gate already rated
|
|
94
|
+
// "allow" (ls, cat, git status) observes; "ask"/"deny" ones (rm, sudo) mutate.
|
|
95
|
+
function planSafe(toolName, verdict) {
|
|
96
|
+
switch (toolName) {
|
|
97
|
+
case "read_file":
|
|
98
|
+
case "search":
|
|
99
|
+
case "task": // the sub-agent's own calls hit this same gate, still in plan mode
|
|
100
|
+
case "ask_user": // asking the user a question is safe in plan mode — it doesn't change anything
|
|
101
|
+
case "skill": // loading a skill's instructions is read-only; what it does is gated per-call
|
|
102
|
+
case "exit_plan_mode": // the way OUT of plan mode must never be blocked by plan mode
|
|
103
|
+
return true;
|
|
104
|
+
case "run_bash":
|
|
105
|
+
return verdict.decision === "allow"; // only the read-only commands the classifier cleared
|
|
106
|
+
default:
|
|
107
|
+
return false; // write_file, edit_file, MCP and unknown tools: all mutate-or-unknown
|
|
108
|
+
}
|
|
109
|
+
}
|
|
67
110
|
// ---- The single entry point ---------------------------------------------------
|
|
68
|
-
// Called by the loop for EVERY tool call, before anything executes.
|
|
111
|
+
// Called by the loop for EVERY tool call, before anything executes. Plan mode is
|
|
112
|
+
// applied as an outer filter: it can only TIGHTEN the base decision (downgrade a
|
|
113
|
+
// mutating allow/ask to deny), never loosen one — a base "deny" keeps its more
|
|
114
|
+
// specific reason, because deny always wins.
|
|
69
115
|
export function checkPermission(toolName, argsJson) {
|
|
116
|
+
const base = basePermission(toolName, argsJson);
|
|
117
|
+
if (planMode && base.decision !== "deny" && !planSafe(toolName, base)) {
|
|
118
|
+
return {
|
|
119
|
+
decision: "deny",
|
|
120
|
+
reason: "plan mode is on — investigate with read-only tools, then call exit_plan_mode to present a plan for the user to approve before you change anything",
|
|
121
|
+
summary: base.summary,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return base;
|
|
125
|
+
}
|
|
126
|
+
// The underlying rules, plan-mode-agnostic. Wrapped by checkPermission above.
|
|
127
|
+
function basePermission(toolName, argsJson) {
|
|
128
|
+
// A user-configured tool block beats everything, including built-in allows.
|
|
129
|
+
if (CONFIG.permissions.deny.includes(`tool:${toolName}`)) {
|
|
130
|
+
return { decision: "deny", reason: `tool blocked by your settings ("tool:${toolName}")`, summary: toolName };
|
|
131
|
+
}
|
|
70
132
|
let args; // the parsed tool arguments
|
|
71
133
|
try {
|
|
72
134
|
args = JSON.parse(argsJson); // arguments arrive as a raw JSON string from the model
|
|
@@ -82,6 +144,14 @@ export function checkPermission(toolName, argsJson) {
|
|
|
82
144
|
// Spawning a sub-agent is orchestration, not action: every tool the
|
|
83
145
|
// sub-agent uses goes through this same gate individually.
|
|
84
146
|
return { decision: "allow", reason: "sub-agent tools are gated individually", summary: "task" };
|
|
147
|
+
case "ask_user":
|
|
148
|
+
// Asking the user a question has no side effects — it's the safest thing
|
|
149
|
+
// the model can do. Never gate it behind an approval prompt.
|
|
150
|
+
return { decision: "allow", reason: "asks the user, no side effects", summary: "ask_user" };
|
|
151
|
+
case "skill":
|
|
152
|
+
// Loading a skill just returns instructions; whatever the skill then DOES
|
|
153
|
+
// goes through this same gate, call by call. Loading itself is free.
|
|
154
|
+
return { decision: "allow", reason: "loads instructions, no side effects", summary: "skill" };
|
|
85
155
|
case "read_file": {
|
|
86
156
|
const p = args.path ?? ""; // the file the model wants to read
|
|
87
157
|
if (SECRET_FILE_RE.test(path.resolve(p))) {
|
|
@@ -95,12 +165,40 @@ export function checkPermission(toolName, argsJson) {
|
|
|
95
165
|
const hit = noFlyHit(p); // is it in the no-fly zone?
|
|
96
166
|
if (hit)
|
|
97
167
|
return { decision: "deny", reason: hit, summary: p }; // hard stop — deny always wins
|
|
168
|
+
// The user may pre-approve write tools ("tool:edit_file") to skip the
|
|
169
|
+
// prompt — note this runs AFTER the no-fly check, so it widens "ask",
|
|
170
|
+
// never "deny".
|
|
171
|
+
if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
|
|
172
|
+
return { decision: "allow", reason: "pre-approved by your settings", summary: p };
|
|
173
|
+
}
|
|
98
174
|
return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
|
|
99
175
|
}
|
|
100
|
-
case "run_bash":
|
|
101
|
-
|
|
176
|
+
case "run_bash": {
|
|
177
|
+
const verdict = checkBash(args.command ?? ""); // bash gets input-aware analysis
|
|
178
|
+
// "Don't ask again for run_bash this session" (chosen in the approval menu)
|
|
179
|
+
// upgrades an ASK to ALLOW — but a hard DENY (rm -rf /, .git, .env…) always
|
|
180
|
+
// stands. Convenience never overrides the no-fly rules.
|
|
181
|
+
if (verdict.decision === "ask" && CONFIG.permissions.allow.includes("tool:run_bash")) {
|
|
182
|
+
return { decision: "allow", reason: "bash pre-approved for this session", summary: verdict.summary };
|
|
183
|
+
}
|
|
184
|
+
return verdict;
|
|
185
|
+
}
|
|
186
|
+
case "exit_plan_mode": {
|
|
187
|
+
// Outside plan mode this tool does nothing — let it through silently. In
|
|
188
|
+
// plan mode it is the approval gate: ask the human, showing the plan, and
|
|
189
|
+
// only on yes does the tool run and flip plan mode off.
|
|
190
|
+
if (!planMode)
|
|
191
|
+
return { decision: "allow", reason: "not in plan mode (no-op)", summary: "exit_plan_mode" };
|
|
192
|
+
return { decision: "ask", reason: "leave plan mode and start implementing", summary: args.plan ?? "(no plan provided)" };
|
|
193
|
+
}
|
|
102
194
|
default:
|
|
103
|
-
//
|
|
104
|
-
|
|
195
|
+
// Everything else — unknown tools and MCP tools (mcp__server__tool) —
|
|
196
|
+
// gets the most suspicious treatment, not the least: ask. The user can
|
|
197
|
+
// pre-approve a trusted MCP tool with "tool:mcp__server__tool" in
|
|
198
|
+
// settings, or block one with "tool:..." in deny (checked at the top).
|
|
199
|
+
if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
|
|
200
|
+
return { decision: "allow", reason: "pre-approved by your settings", summary: toolName };
|
|
201
|
+
}
|
|
202
|
+
return { decision: "ask", reason: toolName.startsWith("mcp__") ? "external MCP tool" : "unknown tool", summary: toolName };
|
|
105
203
|
}
|
|
106
204
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs"; // to load the optional AGENT.md project memory
|
|
2
2
|
import path from "node:path"; // to resolve it from the current directory
|
|
3
|
+
import { memoryContext } from "./memory.js"; // long-term cross-session memory block
|
|
3
4
|
// The static constitution: identical for every session, every user, every day.
|
|
4
5
|
// NOTHING session-specific belongs here — DeepSeek caches by prefix, and a
|
|
5
6
|
// single changed byte (a date, a username, a cwd) would break the cache for
|
|
@@ -37,17 +38,21 @@ Rules:
|
|
|
37
38
|
// equivalent of Claude Code's CLAUDE.md. Loaded once per session: stable
|
|
38
39
|
// within a session (cache-friendly), different across projects (by design).
|
|
39
40
|
const MEMORY_CAP_CHARS = 8000; // hard cap — a runaway memory file dilutes attention
|
|
40
|
-
// Build the complete system message for this session
|
|
41
|
+
// Build the complete system message for this session: the constitution, then
|
|
42
|
+
// optional AGENT.md instructions, then optional long-term memory. All three are
|
|
43
|
+
// resolved once per session for a stable, cacheable prefix.
|
|
41
44
|
export function buildSystemMessage() {
|
|
45
|
+
let msg = SYSTEM_PROMPT;
|
|
42
46
|
const p = path.join(process.cwd(), "AGENT.md"); // only the current directory — V1 keeps lookup simple
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
if (fs.existsSync(p)) {
|
|
48
|
+
const agentMd = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
|
|
49
|
+
// The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
|
|
50
|
+
// compliance; imperative wrappers measurably do better.
|
|
51
|
+
msg += `\n\nThe user's project instructions from AGENT.md. These OVERRIDE any default behavior and you MUST follow them exactly as written:\n\n${agentMd}`;
|
|
52
|
+
}
|
|
53
|
+
// Long-term memory is BACKGROUND, not an override: facts you established
|
|
54
|
+
// earlier, to be verified against the code — not hard rules. The different
|
|
55
|
+
// framing matters (see memory.ts).
|
|
56
|
+
msg += memoryContext();
|
|
57
|
+
return msg;
|
|
53
58
|
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "node:fs"; // session files live on disk
|
|
2
|
+
import path from "node:path"; // path joining
|
|
3
|
+
// Sessions are project-local: .mini-agent/sessions/<id>.json under the cwd.
|
|
4
|
+
// Project-local (not global) because a conversation is about a codebase —
|
|
5
|
+
// resuming a refactor of project A from inside project B makes no sense.
|
|
6
|
+
// Resolved lazily (a function, not a const) so it always reflects the current
|
|
7
|
+
// working directory — and so tests can chdir into a scratch dir.
|
|
8
|
+
const sessionsDir = () => path.resolve(".mini-agent", "sessions");
|
|
9
|
+
const KEEP_SESSIONS = 20; // how many session files survive pruning
|
|
10
|
+
// A fresh, filename-safe session id derived from the wall clock.
|
|
11
|
+
export function newSessionId() {
|
|
12
|
+
return new Date().toISOString().replace(/[:.]/g, "-"); // 2026-06-12T10-30-00-000Z
|
|
13
|
+
}
|
|
14
|
+
// Persist the conversation. Full snapshot, atomically (tmp + rename): the
|
|
15
|
+
// history is not append-only — compaction rewrites it — so a snapshot is the
|
|
16
|
+
// only representation that is always correct. Crash mid-write leaves the
|
|
17
|
+
// previous snapshot intact.
|
|
18
|
+
export function saveSession(id, model, messages) {
|
|
19
|
+
const dir = sessionsDir();
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true }); // first save creates the directory
|
|
21
|
+
const file = path.join(dir, `${id}.json`); // the real destination
|
|
22
|
+
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt
|
|
23
|
+
const data = {
|
|
24
|
+
id,
|
|
25
|
+
startedAt: existing?.startedAt ?? new Date().toISOString(), // first save stamps it, later saves keep it
|
|
26
|
+
savedAt: new Date().toISOString(), // always refreshed
|
|
27
|
+
model, // informational — resume does not force the same model
|
|
28
|
+
messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
|
|
29
|
+
};
|
|
30
|
+
const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
|
|
31
|
+
fs.writeFileSync(tmp, JSON.stringify(data)); // the new snapshot
|
|
32
|
+
fs.renameSync(tmp, file); // atomic swap
|
|
33
|
+
prune(); // keep the directory bounded
|
|
34
|
+
}
|
|
35
|
+
// Session files on disk, newest (most recently saved) first. Shared by every
|
|
36
|
+
// reader below. Skips tmp files and strangers.
|
|
37
|
+
function sessionFilesNewestFirst() {
|
|
38
|
+
const dir = sessionsDir();
|
|
39
|
+
if (!fs.existsSync(dir))
|
|
40
|
+
return []; // nothing ever saved here
|
|
41
|
+
return fs
|
|
42
|
+
.readdirSync(dir)
|
|
43
|
+
.filter((f) => f.endsWith(".json")) // ignore tmp files and strangers
|
|
44
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs })) // newest by modification time
|
|
45
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
46
|
+
}
|
|
47
|
+
// Load the most recent session, or null if there is none (or it is unreadable —
|
|
48
|
+
// a corrupt session file should not kill the CLI, just start fresh).
|
|
49
|
+
export function latestSession() {
|
|
50
|
+
const files = sessionFilesNewestFirst();
|
|
51
|
+
if (!files.length)
|
|
52
|
+
return null; // empty directory
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir(), files[0].f), "utf8")); // the newest snapshot
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null; // unreadable — treat as absent, never crash on resume
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Load one session by id, or null if missing/corrupt.
|
|
61
|
+
export function loadSession(id) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir(), `${id}.json`), "utf8"));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// A one-line, human-readable title for a session: the first thing the user
|
|
70
|
+
// actually typed, with any @file attachment block stripped off and the text
|
|
71
|
+
// flattened to a single trimmed line. This is what makes a session list
|
|
72
|
+
// pickable instead of a wall of timestamps.
|
|
73
|
+
export function sessionTitle(messages) {
|
|
74
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
75
|
+
let text = typeof firstUser?.content === "string" ? firstUser.content : "";
|
|
76
|
+
text = text.split("\n\n[Referenced files")[0]; // drop the @file attachment the REPL appended (Day 26)
|
|
77
|
+
const line = text.replace(/\s+/g, " ").trim(); // flatten to one line
|
|
78
|
+
return line || "(no prompt)";
|
|
79
|
+
}
|
|
80
|
+
// Recent sessions, newest first, summarized for a picker. Unreadable files are
|
|
81
|
+
// skipped, never fatal.
|
|
82
|
+
export function listSessions(limit = 10) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const { f } of sessionFilesNewestFirst()) {
|
|
85
|
+
if (out.length >= limit)
|
|
86
|
+
break;
|
|
87
|
+
try {
|
|
88
|
+
const s = JSON.parse(fs.readFileSync(path.join(sessionsDir(), f), "utf8"));
|
|
89
|
+
out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: sessionTitle(s.messages) });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* skip a corrupt file */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
// Delete everything beyond the newest KEEP_SESSIONS files. A CLI that quietly
|
|
98
|
+
// accumulates unbounded state in every project directory is not industrial.
|
|
99
|
+
function prune() {
|
|
100
|
+
const dir = sessionsDir();
|
|
101
|
+
for (const { f } of sessionFilesNewestFirst().slice(KEEP_SESSIONS))
|
|
102
|
+
fs.rmSync(path.join(dir, f)); // drop the tail beyond KEEP_SESSIONS
|
|
103
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs"; // discover and read SKILL.md files
|
|
2
|
+
import os from "node:os"; // the global skills dir
|
|
3
|
+
import path from "node:path"; // path resolution
|
|
4
|
+
// Project skills win over global ones with the same name.
|
|
5
|
+
function skillDirs() {
|
|
6
|
+
return [path.resolve(".mini-agent", "skills"), path.join(os.homedir(), ".config", "mini-agent", "skills")];
|
|
7
|
+
}
|
|
8
|
+
// Parse one SKILL.md into a Skill. Pure — no filesystem — so the (fiddly)
|
|
9
|
+
// frontmatter parsing is unit-testable. A tiny YAML subset: `key: value` lines.
|
|
10
|
+
export function parseSkill(raw, fallbackName, filePath = "") {
|
|
11
|
+
const fm = {};
|
|
12
|
+
let body = raw.trim();
|
|
13
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); // frontmatter between --- fences
|
|
14
|
+
if (m) {
|
|
15
|
+
body = m[2].trim();
|
|
16
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
17
|
+
const i = line.indexOf(":");
|
|
18
|
+
if (i < 0)
|
|
19
|
+
continue;
|
|
20
|
+
const k = line.slice(0, i).trim();
|
|
21
|
+
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, ""); // strip surrounding quotes
|
|
22
|
+
if (k)
|
|
23
|
+
fm[k] = v;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
name: fm.name || fallbackName,
|
|
28
|
+
description: fm.description || "",
|
|
29
|
+
whenToUse: fm.when_to_use || fm.whenToUse || fm.description || "",
|
|
30
|
+
allowedTools: (fm["allowed-tools"] || fm.allowedTools || "")
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean),
|
|
34
|
+
disableModelInvocation: /^true$/i.test(fm.disableModelInvocation || fm["disable-model-invocation"] || ""),
|
|
35
|
+
body,
|
|
36
|
+
path: filePath,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// Discover skills under the skill dirs (project first, then global). A bad file
|
|
40
|
+
// is skipped, never fatal. `dirs` is injectable for tests.
|
|
41
|
+
export function loadSkills(dirs = skillDirs()) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const dir of dirs) {
|
|
45
|
+
let entries = [];
|
|
46
|
+
try {
|
|
47
|
+
entries = fs.readdirSync(dir);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
continue; // dir doesn't exist — fine
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const file = path.join(dir, entry, "SKILL.md");
|
|
54
|
+
try {
|
|
55
|
+
const skill = parseSkill(fs.readFileSync(file, "utf8"), entry, file);
|
|
56
|
+
if (seen.has(skill.name))
|
|
57
|
+
continue; // first wins → project overrides global
|
|
58
|
+
seen.add(skill.name);
|
|
59
|
+
out.push(skill);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* not a skill folder — skip */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
export function findSkill(skills, name) {
|
|
69
|
+
const n = name.trim().toLowerCase();
|
|
70
|
+
return skills.find((s) => s.name.toLowerCase() === n);
|
|
71
|
+
}
|
|
72
|
+
const MAX_DESC = 250; // cap each skill's listing line (handbook MAX_LISTING_DESC_CHARS)
|
|
73
|
+
const LIST_BUDGET = 8000; // cap the whole listing (handbook DEFAULT_CHAR_BUDGET)
|
|
74
|
+
// The one-line-per-skill listing the model sees (name + when-to-use), budget
|
|
75
|
+
// capped. This is the "progressive disclosure" surface: enough to decide whether
|
|
76
|
+
// to invoke, not the full body.
|
|
77
|
+
export function skillListing(skills) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
let used = 0;
|
|
80
|
+
for (const s of skills) {
|
|
81
|
+
const desc = (s.whenToUse || s.description).replace(/\s+/g, " ").slice(0, MAX_DESC);
|
|
82
|
+
const line = `- ${s.name}: ${desc}`;
|
|
83
|
+
if (used + line.length > LIST_BUDGET)
|
|
84
|
+
break; // out of budget — stop listing
|
|
85
|
+
used += line.length;
|
|
86
|
+
lines.push(line);
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
// What gets handed back when a skill is invoked: the body, plus a reminder of
|
|
91
|
+
// its allowed tools (least-privilege is advisory here — the permission gate
|
|
92
|
+
// still governs every actual call).
|
|
93
|
+
export function skillInstructions(s) {
|
|
94
|
+
const tools = s.allowedTools.length ? `\n\n[This skill should use only these tools: ${s.allowedTools.join(", ")}. Other actions still go through the normal permission gate.]` : "";
|
|
95
|
+
return `# Skill: ${s.name}\n\nFollow these steps:\n\n${s.body}${tools}`;
|
|
96
|
+
}
|
|
97
|
+
// Build the `skill` tool from the model-invocable skills. The tool's description
|
|
98
|
+
// IS the listing, so the model learns what skills exist and when to use them;
|
|
99
|
+
// calling it with a name returns that skill's full instructions.
|
|
100
|
+
export function buildSkillTool(skills) {
|
|
101
|
+
const invokable = skills.filter((s) => !s.disableModelInvocation);
|
|
102
|
+
const listing = skillListing(invokable) || "(none)";
|
|
103
|
+
return {
|
|
104
|
+
definition: {
|
|
105
|
+
type: "function",
|
|
106
|
+
function: {
|
|
107
|
+
name: "skill",
|
|
108
|
+
description: `Load and follow a reusable skill — a saved, named procedure. Call this when the user's request matches one of the skills below; it returns step-by-step instructions you then carry out yourself.
|
|
109
|
+
Available skills:
|
|
110
|
+
${listing}
|
|
111
|
+
Pass the exact skill name. If nothing matches, do not call this — just answer normally.`,
|
|
112
|
+
parameters: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: { name: { type: "string", description: "The exact skill name to load" } },
|
|
115
|
+
required: ["name"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
run: (args) => {
|
|
120
|
+
const s = findSkill(skills, args.name ?? "");
|
|
121
|
+
if (!s)
|
|
122
|
+
return `[error] No skill named "${args.name}". Available: ${invokable.map((x) => x.name).join(", ") || "none"}.`;
|
|
123
|
+
if (s.disableModelInvocation)
|
|
124
|
+
return `[error] Skill "${s.name}" can only be invoked by the user, not by you.`;
|
|
125
|
+
return skillInstructions(s);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs"; // the event log lives on disk
|
|
2
|
+
import path from "node:path"; // path joining
|
|
3
|
+
// LOCAL-ONLY telemetry. Events go to .mini-agent/telemetry.jsonl in the
|
|
4
|
+
// project directory and NOWHERE else — nothing leaves the machine, ever.
|
|
5
|
+
// It exists because "no data" means every optimization is a guess: which
|
|
6
|
+
// errors actually happen, how often compaction fires, which tools get denied.
|
|
7
|
+
// Opt out entirely with MINI_AGENT_NO_TELEMETRY=1.
|
|
8
|
+
//
|
|
9
|
+
// Naming follows the unified-prefix convention (agent_<domain>_<action>) so
|
|
10
|
+
// the log stays greppable: `grep agent_api_error telemetry.jsonl | wc -l`.
|
|
11
|
+
const TELEMETRY_FILE = path.resolve(".mini-agent", "telemetry.jsonl");
|
|
12
|
+
let sessionId = null; // set by initTelemetry; emit() is a no-op before that
|
|
13
|
+
const tallies = new Map(); // per-session event counts, for /stats
|
|
14
|
+
// Arm telemetry for this session. Tests and the eval harness never call this,
|
|
15
|
+
// so library use stays silent — only the real CLI writes events.
|
|
16
|
+
export function initTelemetry(id) {
|
|
17
|
+
if (process.env.MINI_AGENT_NO_TELEMETRY === "1")
|
|
18
|
+
return; // the user opted out — stay disarmed
|
|
19
|
+
sessionId = id; // armed
|
|
20
|
+
}
|
|
21
|
+
// Record one event. Never throws: a telemetry failure must never break work.
|
|
22
|
+
export function emit(event, fields = {}) {
|
|
23
|
+
const key = event + (fields.tool ? `:${fields.tool}` : ""); // tool events tally per tool name
|
|
24
|
+
tallies.set(key, (tallies.get(key) ?? 0) + 1); // /stats counts even when disarmed
|
|
25
|
+
if (!sessionId)
|
|
26
|
+
return; // disarmed — counters only, no file
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(path.dirname(TELEMETRY_FILE), { recursive: true }); // ensure the directory
|
|
29
|
+
fs.appendFileSync(TELEMETRY_FILE, JSON.stringify({ ts: new Date().toISOString(), session: sessionId, event, ...fields }) + "\n"); // one JSON object per line
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* a full disk or a read-only checkout is not our problem to escalate */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// The per-session tallies, sorted by count — the data behind /stats.
|
|
36
|
+
export function statsReport() {
|
|
37
|
+
if (!tallies.size)
|
|
38
|
+
return "(no events this session yet)"; // nothing happened
|
|
39
|
+
const lines = [...tallies.entries()]
|
|
40
|
+
.sort((a, b) => b[1] - a[1]) // busiest first
|
|
41
|
+
.map(([k, n]) => ` ${String(n).padStart(5)} ${k}`); // aligned counts
|
|
42
|
+
return ["events this session:", ...lines, `\nfull log: ${TELEMETRY_FILE} (local only — nothing leaves this machine)`].join("\n");
|
|
43
|
+
}
|