atom-agent 0.3.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.
@@ -0,0 +1,218 @@
1
+ // Kill-safe session persistence for the Ink chatbot.
2
+ // A killed/crashed/failed session must not lose everything: every COMPLETED
3
+ // turn (and clean exit) writes ~/.atom/session.json (ATOM_HOME override
4
+ // honored, 0600 POSIX perms like auth.json, best-effort Windows). Failed or
5
+ // cancelled turns are rolled back and NEVER touch the file, so a bad turn
6
+ // cannot corrupt or clobber the last good save.
7
+ //
8
+ // Shape: {version:1, savedAt, provider, model, effort, mode, usageTotals,
9
+ // history (full API history incl. system + tool pairs), turns (display
10
+ // transcript)}. Writes are atomic (temp file + rename) to survive kills
11
+ // mid-write. Loads never throw: missing -> "missing", anything malformed ->
12
+ // "corrupt" (caller shows a one-line notice and starts fresh).
13
+ //
14
+ // Privacy: the file can contain pasted secrets if the user typed them as
15
+ // chat. Never print its contents; never commit it (it lives under ~/.atom,
16
+ // outside the repo, so .gitignore needs no change).
17
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
18
+ import * as path from "node:path";
19
+ import { atomDir } from "./auth.js";
20
+ import { isProviderId } from "./providers.js";
21
+ import { EFFORT_OPTIONS, } from "./zen.js";
22
+ export const SESSION_VERSION = 1;
23
+ export const SESSION_FILENAME = "session.json";
24
+ export function sessionFilePath(home) {
25
+ return path.join(atomDir(home), SESSION_FILENAME);
26
+ }
27
+ export function sessionExists(home) {
28
+ try {
29
+ return existsSync(sessionFilePath(home));
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ // Atomic save: write temp + rename, 0600 POSIX (best-effort Windows).
36
+ // Never throws for missing dirs (mkdir -p); disk errors propagate to the
37
+ // caller, which ignores them (in-memory session still applies).
38
+ export function saveSession(snapshot, home) {
39
+ const dir = atomDir(home);
40
+ mkdirSync(dir, { recursive: true });
41
+ const finalPath = path.join(dir, SESSION_FILENAME);
42
+ const tmpPath = path.join(dir, `${SESSION_FILENAME}.tmp.${process.pid}`);
43
+ const payload = {
44
+ version: SESSION_VERSION,
45
+ savedAt: new Date().toISOString(),
46
+ provider: snapshot.provider,
47
+ model: snapshot.model,
48
+ effort: snapshot.effort,
49
+ mode: snapshot.mode,
50
+ usageTotals: snapshot.usageTotals,
51
+ history: snapshot.history.map((m) => ({ ...m })),
52
+ turns: snapshot.turns.map((t) => ({ ...t })),
53
+ };
54
+ writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
55
+ try {
56
+ chmodSync(tmpPath, 0o600);
57
+ }
58
+ catch {
59
+ // best-effort on Windows; ignore
60
+ }
61
+ renameSync(tmpPath, finalPath);
62
+ }
63
+ // Load the save file. Missing file -> "missing"; unreadable file,
64
+ // invalid JSON, or any shape violation -> "corrupt". Never throws.
65
+ export function loadSession(home) {
66
+ const p = sessionFilePath(home);
67
+ let raw;
68
+ try {
69
+ if (!existsSync(p))
70
+ return { status: "missing" };
71
+ raw = readFileSync(p, "utf8");
72
+ }
73
+ catch {
74
+ return { status: "corrupt" };
75
+ }
76
+ let data;
77
+ try {
78
+ data = JSON.parse(raw);
79
+ }
80
+ catch {
81
+ return { status: "corrupt" };
82
+ }
83
+ const session = validateSession(data);
84
+ return session ? { status: "ok", session } : { status: "corrupt" };
85
+ }
86
+ function isRecord(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value);
88
+ }
89
+ function isNonEmptyString(value) {
90
+ return typeof value === "string" && value.length > 0;
91
+ }
92
+ function validateUsageTotals(value) {
93
+ if (value === null || value === undefined)
94
+ return null;
95
+ if (!isRecord(value))
96
+ return null;
97
+ const out = {};
98
+ for (const key of ["prompt_tokens", "completion_tokens", "total_tokens"]) {
99
+ const v = value[key];
100
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
101
+ out[key] = Math.floor(v);
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ function validateToolCall(value) {
107
+ if (!isRecord(value))
108
+ return false;
109
+ if (!isNonEmptyString(value["id"]))
110
+ return false;
111
+ const fn = value["function"];
112
+ if (!isRecord(fn))
113
+ return false;
114
+ if (typeof fn["name"] !== "string" || fn["name"].length === 0)
115
+ return false;
116
+ if (typeof fn["arguments"] !== "string")
117
+ return false;
118
+ if (value["type"] !== undefined && typeof value["type"] !== "string")
119
+ return false;
120
+ return true;
121
+ }
122
+ function validateChatMessage(value) {
123
+ if (!isRecord(value))
124
+ return false;
125
+ const role = value["role"];
126
+ if (role === "system" || role === "user") {
127
+ return typeof value["content"] === "string";
128
+ }
129
+ if (role === "assistant") {
130
+ const content = value["content"];
131
+ if (content !== undefined &&
132
+ content !== null &&
133
+ typeof content !== "string") {
134
+ return false;
135
+ }
136
+ const calls = value["tool_calls"];
137
+ if (calls !== undefined) {
138
+ if (!Array.isArray(calls) || calls.length === 0)
139
+ return false;
140
+ for (const c of calls) {
141
+ if (!validateToolCall(c))
142
+ return false;
143
+ }
144
+ }
145
+ return true;
146
+ }
147
+ if (role === "tool") {
148
+ return (isNonEmptyString(value["tool_call_id"]) &&
149
+ typeof value["content"] === "string");
150
+ }
151
+ return false;
152
+ }
153
+ function validateTurn(value) {
154
+ if (!isRecord(value))
155
+ return false;
156
+ const role = value["role"];
157
+ if (role !== "user" && role !== "assistant" && role !== "tool")
158
+ return false;
159
+ if (typeof value["content"] !== "string")
160
+ return false;
161
+ if (value["error"] !== undefined && typeof value["error"] !== "boolean") {
162
+ return false;
163
+ }
164
+ return true;
165
+ }
166
+ function validateSession(data) {
167
+ if (!isRecord(data))
168
+ return null;
169
+ if (data["version"] !== SESSION_VERSION)
170
+ return null;
171
+ const savedAt = data["savedAt"];
172
+ if (typeof savedAt !== "string" || Number.isNaN(Date.parse(savedAt))) {
173
+ return null;
174
+ }
175
+ const provider = data["provider"];
176
+ if (typeof provider !== "string" || !isProviderId(provider))
177
+ return null;
178
+ const model = data["model"];
179
+ if (!isNonEmptyString(model))
180
+ return null;
181
+ const effort = data["effort"];
182
+ if (typeof effort !== "string" ||
183
+ !EFFORT_OPTIONS.includes(effort)) {
184
+ return null;
185
+ }
186
+ const mode = data["mode"];
187
+ // "plan" restores as plan (fail-closed: a saved read-only session resumes
188
+ // read-only; ticket 04). Additive — normal/yolo saves validate as before.
189
+ if (mode !== "normal" && mode !== "yolo" && mode !== "plan")
190
+ return null;
191
+ const history = data["history"];
192
+ if (!Array.isArray(history) || history.length === 0)
193
+ return null;
194
+ for (const m of history) {
195
+ if (!validateChatMessage(m))
196
+ return null;
197
+ }
198
+ if (history[0]?.role !== "system")
199
+ return null;
200
+ const turns = data["turns"];
201
+ if (!Array.isArray(turns))
202
+ return null;
203
+ for (const t of turns) {
204
+ if (!validateTurn(t))
205
+ return null;
206
+ }
207
+ return {
208
+ version: SESSION_VERSION,
209
+ savedAt,
210
+ provider,
211
+ model,
212
+ effort: effort,
213
+ mode,
214
+ usageTotals: validateUsageTotals(data["usageTotals"]),
215
+ history: history,
216
+ turns: turns,
217
+ };
218
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,283 @@
1
+ // Skill discovery + registry (Claude-Code-style SKILL.md adoption).
2
+ //
3
+ // Scans the project and global skill directories for SKILL.md files,
4
+ // parses the frontmatter trigger contract (name/description), and renders
5
+ // the TUI listing. Levels and precedence (ticket 05), invocation (tickets
6
+ // 03/04), and tool grants (ticket 06) build on this registry.
7
+ //
8
+ // Node builtins only. Discovery never throws: per-skill failures come back
9
+ // as warning strings, and a missing skills directory is normal (silent).
10
+ import { promises as fsp } from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ // Split `---` frontmatter from the body. The opening marker must be the
14
+ // file's first line; a block that never closes is malformed (warned and
15
+ // skipped by discovery), not body.
16
+ function splitFrontmatter(text) {
17
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
18
+ if (lines[0]?.trim() !== "---")
19
+ return { front: null, body: text, unclosed: false };
20
+ for (let i = 1; i < lines.length; i++) {
21
+ const t = lines[i].trim();
22
+ if (t === "---" || t === "...") {
23
+ return { front: lines.slice(1, i).join("\n"), body: lines.slice(i + 1).join("\n"), unclosed: false };
24
+ }
25
+ }
26
+ return { front: null, body: text, unclosed: true };
27
+ }
28
+ // Minimal frontmatter reader: single-line `key: value` (matching quotes
29
+ // stripped) plus folded (`>`, `>-`) and literal (`|`, `|-`) continuations.
30
+ // Anything richer is out of scope — skill authors keep parsing simple.
31
+ function frontField(front, key) {
32
+ const lines = front.split("\n");
33
+ for (let i = 0; i < lines.length; i++) {
34
+ const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
35
+ if (!m || m[1] !== key)
36
+ continue;
37
+ const rest = (m[2] ?? "").trim();
38
+ if (/^[>|][+-]?$/.test(rest)) {
39
+ const block = [];
40
+ for (let j = i + 1; j < lines.length; j++) {
41
+ const lj = lines[j];
42
+ if (lj.trim() === "" || /^[ \t]/.test(lj))
43
+ block.push(lj.trim());
44
+ else
45
+ break;
46
+ }
47
+ while (block.length > 0 && block[0] === "")
48
+ block.shift();
49
+ while (block.length > 0 && block[block.length - 1] === "")
50
+ block.pop();
51
+ return rest[0] === ">" ? block.join(" ") : block.join("\n");
52
+ }
53
+ const q = /^(['"])(.*)\1$/.exec(rest);
54
+ return q ? (q[2] ?? "") : rest;
55
+ }
56
+ return undefined;
57
+ }
58
+ function firstParagraph(body) {
59
+ const paras = body
60
+ .replace(/\r\n?/g, "\n")
61
+ .split(/\n\s*\n/)
62
+ .map((p) => p.replace(/\s+/g, " ").trim())
63
+ // Skip ATX headings: a bare "# Title" is a useless discovery string.
64
+ .filter((p) => p.length > 0 && !/^#{1,6}\s/.test(p));
65
+ return paras[0] ?? "";
66
+ }
67
+ // Boolean frontmatter in Claude's accepted forms (true/false plus
68
+ // yes/no/on/off/1/0, any case). Unrecognized values fall back to default.
69
+ function frontBool(front, key) {
70
+ const raw = frontField(front, key);
71
+ if (raw === undefined)
72
+ return undefined;
73
+ const v = raw.trim().toLowerCase();
74
+ if (v === "true" || v === "yes" || v === "on" || v === "1")
75
+ return true;
76
+ if (v === "false" || v === "no" || v === "off" || v === "0")
77
+ return false;
78
+ return undefined;
79
+ }
80
+ // `allowed-tools` grants: space- and/or comma-separated tool names,
81
+ // lowercased and deduped. Unknown names are kept verbatim — the approval
82
+ // hook matches against real tool names, so junk grants match nothing.
83
+ export function parseAllowedTools(raw) {
84
+ if (raw === undefined)
85
+ return [];
86
+ const seen = new Set();
87
+ for (const part of raw.split(/[\s,]+/)) {
88
+ const t = part.trim().toLowerCase();
89
+ if (t.length > 0)
90
+ seen.add(t);
91
+ }
92
+ return [...seen];
93
+ }
94
+ export async function discoverSkills(opts) {
95
+ const projectDir = opts?.projectDir ?? process.cwd();
96
+ const homeDir = opts?.homeDir ?? os.homedir();
97
+ const skills = [];
98
+ const warnings = [];
99
+ const seenBases = new Set();
100
+ const roots = [
101
+ { base: path.join(projectDir, ".claude", "skills"), source: "project" },
102
+ { base: path.join(homeDir, ".claude", "skills"), source: "global" },
103
+ ];
104
+ for (const { base, source } of roots) {
105
+ const resolved = path.resolve(base);
106
+ if (seenBases.has(resolved))
107
+ continue; // e.g. repo rooted at $HOME
108
+ seenBases.add(resolved);
109
+ let entries;
110
+ try {
111
+ entries = await fsp.readdir(base, { withFileTypes: true });
112
+ }
113
+ catch {
114
+ continue; // no skills installed here — normal, silent
115
+ }
116
+ const dirs = entries
117
+ .filter((e) => e.isDirectory())
118
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
119
+ for (const e of dirs) {
120
+ const dir = path.join(base, e.name);
121
+ let text;
122
+ try {
123
+ text = await fsp.readFile(path.join(dir, "SKILL.md"), "utf8");
124
+ }
125
+ catch {
126
+ warnings.push(`skill "${e.name}" (${source}): cannot read SKILL.md — skipped`);
127
+ continue;
128
+ }
129
+ const { front, body, unclosed } = splitFrontmatter(text);
130
+ if (unclosed) {
131
+ warnings.push(`skill "${e.name}" (${source}): unclosed frontmatter — skipped`);
132
+ continue;
133
+ }
134
+ const fmName = front ? frontField(front, "name") : undefined;
135
+ const fmDesc = front ? frontField(front, "description") : undefined;
136
+ const name = fmName && fmName.length > 0 ? fmName : e.name;
137
+ const description = fmDesc && fmDesc.length > 0 ? fmDesc : firstParagraph(body);
138
+ if (!description) {
139
+ warnings.push(`skill "${name}" (${source}): no description and empty body — skipped`);
140
+ continue;
141
+ }
142
+ const noModel = front ? frontBool(front, "disable-model-invocation") : undefined;
143
+ const userOnly = front ? frontBool(front, "user-invocable") : undefined;
144
+ skills.push({
145
+ name,
146
+ description,
147
+ dir,
148
+ source,
149
+ userInvocable: userOnly ?? true,
150
+ modelInvocable: !(noModel ?? false),
151
+ allowedTools: front ? parseAllowedTools(frontField(front, "allowed-tools")) : [],
152
+ });
153
+ }
154
+ }
155
+ return { skills, warnings };
156
+ }
157
+ // One-shot TUI listing for /skills. Name clashes resolve with personal
158
+ // (global) winning (resolveSkills); model-only skills show with an
159
+ // [auto-only] tag instead of hiding — discoverable, but not invocable.
160
+ // The header always renders so the command is self-explanatory when empty;
161
+ // warnings ride along visibly.
162
+ export async function skillsListText(projectDir, homeDir) {
163
+ const found = await discoverSkills({ projectDir, homeDir });
164
+ const { skills, notes } = resolveSkills(found.skills);
165
+ const out = [skills.length === 1 ? "Skills (1):" : `Skills (${skills.length}):`];
166
+ for (const s of skills) {
167
+ out.push(`/${s.name} — ${s.description} [${s.source}]${s.userInvocable ? "" : " [auto-only]"}`);
168
+ }
169
+ for (const n of notes)
170
+ out.push(`note: ${n}`);
171
+ for (const w of found.warnings)
172
+ out.push(`⚠ ${w}`);
173
+ if (skills.length === 0 && found.warnings.length === 0) {
174
+ out.push("(no skills installed — add SKILL.md skills under .claude/skills/ or ~/.claude/skills/)");
175
+ }
176
+ return out.join("\n");
177
+ }
178
+ const SKILL_FILE_CAP = 8 * 1024;
179
+ const SKILL_INCLUDE_MAX = 3;
180
+ // references/<path> + scripts/<path> mentions inside the skill body.
181
+ const SKILL_MENTION_RE = /\b(references|scripts)\/[A-Za-z0-9_.\-/@]+/g;
182
+ // Load a skill's body plus the support files its body references (on
183
+ // demand, capped — never the whole directory). Mentioned-but-missing files
184
+ // are skipped silently; path escapes outside the skill dir are dropped.
185
+ // Never throws: an unreadable SKILL.md yields empty text.
186
+ export async function loadSkillBody(skill) {
187
+ let raw;
188
+ try {
189
+ raw = await fsp.readFile(path.join(skill.dir, "SKILL.md"), "utf8");
190
+ }
191
+ catch {
192
+ return { info: skill, text: "", included: [] };
193
+ }
194
+ const { body } = splitFrontmatter(raw);
195
+ const seen = new Set();
196
+ const mentions = [];
197
+ for (const m of body.match(SKILL_MENTION_RE) ?? []) {
198
+ if (!seen.has(m)) {
199
+ seen.add(m);
200
+ mentions.push(m);
201
+ }
202
+ }
203
+ const included = [];
204
+ const parts = [];
205
+ for (const rel of mentions.slice(0, SKILL_INCLUDE_MAX)) {
206
+ const abs = path.resolve(skill.dir, rel);
207
+ if (abs !== skill.dir && !abs.startsWith(skill.dir + path.sep))
208
+ continue; // traversal guard
209
+ let content;
210
+ try {
211
+ content = await fsp.readFile(abs, "utf8");
212
+ }
213
+ catch {
214
+ continue; // dangling mention — the body already stands alone
215
+ }
216
+ included.push(rel);
217
+ const capped = content.length > SKILL_FILE_CAP
218
+ ? content.slice(0, SKILL_FILE_CAP) + `\n[truncated: ${rel} exceeded 8KB]`
219
+ : content;
220
+ parts.push(`--- ${rel} (referenced above, inlined) ---\n${capped}`);
221
+ }
222
+ return { info: skill, text: parts.length > 0 ? `${body}\n\n${parts.join("\n\n")}` : body, included };
223
+ }
224
+ const MATCH_STOPWORDS = new Set("a,an,the,and,or,for,with,from,that,this,these,those,into,onto,over,under,about,using,use,used,your,you,how,what,when,where,which,who,will,can,not,but,are,was,were,has,have,had,its,their,them,they,then,than,also,just,like,more,most,such,via,per,within,without,between".split(","));
225
+ function contentWords(s) {
226
+ const out = [];
227
+ for (const w of s.toLowerCase().split(/[^a-z0-9]+/)) {
228
+ if (w.length >= 3 && !MATCH_STOPWORDS.has(w) && !out.includes(w))
229
+ out.push(w);
230
+ }
231
+ return out;
232
+ }
233
+ // Deterministic description match for auto-invoke: distinct
234
+ // name+description words (len ≥ 3, stopwords dropped) hitting the
235
+ // message as substrings, at least minHits (default 2), best score first,
236
+ // capped at max (default 2) skills per turn. Skills with
237
+ // disable-model-invocation never match. Pure function — no I/O.
238
+ export function matchSkills(message, skills, opts) {
239
+ const max = opts?.max ?? 2;
240
+ const minHits = opts?.minHits ?? 2;
241
+ const text = message.toLowerCase();
242
+ const scored = [];
243
+ for (const s of skills) {
244
+ if (!s.modelInvocable)
245
+ continue;
246
+ let hits = 0;
247
+ for (const w of contentWords(`${s.name} ${s.description}`)) {
248
+ if (text.includes(w))
249
+ hits += 1;
250
+ }
251
+ if (hits >= minHits)
252
+ scored.push({ s, hits });
253
+ }
254
+ scored.sort((a, b) => b.hits - a.hits || (a.s.name < b.s.name ? -1 : a.s.name > b.s.name ? 1 : 0));
255
+ return scored.slice(0, Math.max(0, max)).map((e) => e.s);
256
+ }
257
+ // Level precedence (Claude rule): personal (global) wins over project on
258
+ // exact-name clashes, with a visible note of which one won. Same-level
259
+ // duplicates keep the first with a note. Pure function — no I/O.
260
+ // No cache anywhere in this module by design: every call re-scans disk,
261
+ // so adding/editing/removing skills takes effect without restarting.
262
+ export function resolveSkills(skills) {
263
+ const byName = new Map();
264
+ const order = [];
265
+ const notes = [];
266
+ for (const s of skills) {
267
+ const prev = byName.get(s.name);
268
+ if (!prev) {
269
+ byName.set(s.name, s);
270
+ order.push(s.name);
271
+ continue;
272
+ }
273
+ if (prev.source === s.source) {
274
+ notes.push(`skill "${s.name}": duplicate ${s.source} entry ignored (${s.dir})`);
275
+ continue;
276
+ }
277
+ const winner = s.source === "global" ? s : prev;
278
+ const loser = s.source === "global" ? prev : s;
279
+ byName.set(s.name, winner);
280
+ notes.push(`skill "${s.name}": ${winner.source} wins over ${loser.source}`);
281
+ }
282
+ return { skills: order.map((n) => byName.get(n)), notes };
283
+ }