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.
@@ -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
- // Order is the security model: deny first, ask second, allow last.
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
- return checkBash(args.command ?? ""); // bash gets input-aware analysis
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
- // A tool we don't know gets the most suspicious treatment, not the least.
104
- return { decision: "ask", reason: "unknown tool", summary: toolName };
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 (!fs.existsSync(p))
44
- return SYSTEM_PROMPT; // no project memory — constitution only
45
- const memory = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
46
- // The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
47
- // compliance; imperative wrappers measurably do better.
48
- return `${SYSTEM_PROMPT}
49
-
50
- The user's project instructions from AGENT.md. These OVERRIDE any default behavior and you MUST follow them exactly as written:
51
-
52
- ${memory}`;
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
  }
@@ -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
+ }