atom-agent 0.3.0 → 1.1.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/CHANGELOG.md +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Effect-aware tool scheduler: data-driven parallelism without Promise.all-
|
|
2
|
+
// ing every tool.
|
|
3
|
+
//
|
|
4
|
+
// The conservative contract (unchanged): batchable reads run concurrently,
|
|
5
|
+
// mutations stay serialized, results commit in original call order, cancel
|
|
6
|
+
// stops between batches, approval happens per call inside runOneTool. This
|
|
7
|
+
// module only PLANS batches; execution (zen.ts runLoopWithChat) is untouched.
|
|
8
|
+
//
|
|
9
|
+
// How it reasons (per tool, from the TOOL_EFFECTS table — the single
|
|
10
|
+
// coupling point; the algorithm below has no per-tool branches):
|
|
11
|
+
// - missing metadata → serial singleton (new tools fail safe, like the old
|
|
12
|
+
// allowlist-miss; current safe behavior is preserved when metadata is
|
|
13
|
+
// absent, not degraded).
|
|
14
|
+
// - unknown name / malformed JSON / failed validation / empty target →
|
|
15
|
+
// serial singleton (never batch what you cannot see; validation failures
|
|
16
|
+
// become inline-error singletons downstream, exactly as before).
|
|
17
|
+
// - interactive (ask_question) or exclusive (shared ambient state the effect
|
|
18
|
+
// model cannot see: todowrite/todo_update/todo_get) → serial singleton.
|
|
19
|
+
// - any filesystem/network WRITE, or any process SPAWN → serial singleton.
|
|
20
|
+
// Writes conflict GLOBALLY (not just same-target): a write splits the
|
|
21
|
+
// block and read-after-write stays ordered. Target-scoped write batching
|
|
22
|
+
// is a deliberate non-goal — correctness over theoretical parallelism.
|
|
23
|
+
// - reads (filesystem or network) batch with pairwise-disjoint keys, where
|
|
24
|
+
// the key is tool + target. Same tool + same target serializes (the old
|
|
25
|
+
// overlap rule, kept verbatim: e.g. two reads of one path). Reads never
|
|
26
|
+
// conflict across keys — an all-read batch cannot race a writer, because
|
|
27
|
+
// writers never join batches.
|
|
28
|
+
// - deterministic is declared per tool; its current enforcement is the
|
|
29
|
+
// same-key rule (a re-poll of the same background task, whose output can
|
|
30
|
+
// grow, never runs concurrently with itself).
|
|
31
|
+
//
|
|
32
|
+
// Pure module except the shared arg validators (same imports zen.ts already
|
|
33
|
+
// carries — no new coupling class). Covered by tests/scheduler.test.ts; the
|
|
34
|
+
// end-to-end ordering/cancel behavior stays pinned by
|
|
35
|
+
// tests/parallel-calls.test.ts.
|
|
36
|
+
import { toolNames, validateToolArgs } from "./tools.js";
|
|
37
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
38
|
+
const targetOf = (key) => (args) => {
|
|
39
|
+
const t = str(args[key]);
|
|
40
|
+
return t.length > 0 ? t : null;
|
|
41
|
+
};
|
|
42
|
+
/** Effect metadata for every known tool. Missing name ⇒ serial singleton. */
|
|
43
|
+
export const TOOL_EFFECTS = {
|
|
44
|
+
read: {
|
|
45
|
+
filesystem: "read",
|
|
46
|
+
network: "none",
|
|
47
|
+
process: "none",
|
|
48
|
+
interactive: false,
|
|
49
|
+
exclusive: false,
|
|
50
|
+
deterministic: true,
|
|
51
|
+
target: targetOf("path"),
|
|
52
|
+
},
|
|
53
|
+
grep: {
|
|
54
|
+
filesystem: "read",
|
|
55
|
+
network: "none",
|
|
56
|
+
process: "none",
|
|
57
|
+
interactive: false,
|
|
58
|
+
exclusive: false,
|
|
59
|
+
deterministic: true,
|
|
60
|
+
target: targetOf("pattern"),
|
|
61
|
+
},
|
|
62
|
+
glob: {
|
|
63
|
+
filesystem: "read",
|
|
64
|
+
network: "none",
|
|
65
|
+
process: "none",
|
|
66
|
+
interactive: false,
|
|
67
|
+
exclusive: false,
|
|
68
|
+
deterministic: true,
|
|
69
|
+
target: targetOf("pattern"),
|
|
70
|
+
},
|
|
71
|
+
webfetch: {
|
|
72
|
+
filesystem: "none",
|
|
73
|
+
network: "read",
|
|
74
|
+
process: "none",
|
|
75
|
+
interactive: false,
|
|
76
|
+
exclusive: false,
|
|
77
|
+
deterministic: true,
|
|
78
|
+
target: targetOf("url"),
|
|
79
|
+
},
|
|
80
|
+
websearch: {
|
|
81
|
+
filesystem: "none",
|
|
82
|
+
network: "read",
|
|
83
|
+
process: "none",
|
|
84
|
+
interactive: false,
|
|
85
|
+
exclusive: false,
|
|
86
|
+
// Live search results vary call to call; same-query conflict is still
|
|
87
|
+
// serialized by the same-key rule.
|
|
88
|
+
deterministic: false,
|
|
89
|
+
target: targetOf("query"),
|
|
90
|
+
},
|
|
91
|
+
bash_output: {
|
|
92
|
+
// Reads background-task temp files scoped by taskId (not the repo).
|
|
93
|
+
filesystem: "read",
|
|
94
|
+
network: "none",
|
|
95
|
+
process: "none",
|
|
96
|
+
interactive: false,
|
|
97
|
+
exclusive: false,
|
|
98
|
+
// A running task's output grows between polls — same-task polls
|
|
99
|
+
// serialize via the same-key rule.
|
|
100
|
+
deterministic: false,
|
|
101
|
+
target: targetOf("taskId"),
|
|
102
|
+
},
|
|
103
|
+
write: {
|
|
104
|
+
filesystem: "write",
|
|
105
|
+
network: "none",
|
|
106
|
+
process: "none",
|
|
107
|
+
interactive: false,
|
|
108
|
+
exclusive: false,
|
|
109
|
+
deterministic: true,
|
|
110
|
+
target: targetOf("path"),
|
|
111
|
+
},
|
|
112
|
+
edit: {
|
|
113
|
+
filesystem: "write",
|
|
114
|
+
network: "none",
|
|
115
|
+
process: "none",
|
|
116
|
+
interactive: false,
|
|
117
|
+
exclusive: false,
|
|
118
|
+
deterministic: true,
|
|
119
|
+
target: targetOf("path"),
|
|
120
|
+
},
|
|
121
|
+
bash: {
|
|
122
|
+
// A command can touch anything (files, network, processes) with no
|
|
123
|
+
// statically visible footprint: global conflict, always singleton.
|
|
124
|
+
filesystem: "write",
|
|
125
|
+
network: "write",
|
|
126
|
+
process: "spawn",
|
|
127
|
+
interactive: false,
|
|
128
|
+
exclusive: false,
|
|
129
|
+
deterministic: false,
|
|
130
|
+
target: targetOf("command"),
|
|
131
|
+
},
|
|
132
|
+
ask_question: {
|
|
133
|
+
filesystem: "none",
|
|
134
|
+
network: "none",
|
|
135
|
+
process: "none",
|
|
136
|
+
interactive: true,
|
|
137
|
+
exclusive: false,
|
|
138
|
+
deterministic: false,
|
|
139
|
+
target: () => null,
|
|
140
|
+
},
|
|
141
|
+
todowrite: {
|
|
142
|
+
filesystem: "none",
|
|
143
|
+
network: "none",
|
|
144
|
+
process: "none",
|
|
145
|
+
interactive: false,
|
|
146
|
+
exclusive: true,
|
|
147
|
+
deterministic: false,
|
|
148
|
+
target: () => null,
|
|
149
|
+
},
|
|
150
|
+
todo_update: {
|
|
151
|
+
filesystem: "none",
|
|
152
|
+
network: "none",
|
|
153
|
+
process: "none",
|
|
154
|
+
interactive: false,
|
|
155
|
+
exclusive: true,
|
|
156
|
+
deterministic: false,
|
|
157
|
+
target: () => null,
|
|
158
|
+
},
|
|
159
|
+
todo_get: {
|
|
160
|
+
filesystem: "none",
|
|
161
|
+
network: "none",
|
|
162
|
+
process: "none",
|
|
163
|
+
interactive: false,
|
|
164
|
+
exclusive: true,
|
|
165
|
+
deterministic: true,
|
|
166
|
+
target: () => null,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
// Partition one assistant message's tool_calls into commit batches,
|
|
170
|
+
// preserving program order: consecutive batchable calls with pairwise
|
|
171
|
+
// disjoint keys form one batch; any serial-only call — and any call whose
|
|
172
|
+
// key already appears in the open batch — closes the batch and runs as a
|
|
173
|
+
// strict serial singleton. A later batch never moves ahead of an earlier
|
|
174
|
+
// serial call (read-after-write stays ordered), and batches never span the
|
|
175
|
+
// block boundary.
|
|
176
|
+
export function planBatches(calls) {
|
|
177
|
+
const batches = [];
|
|
178
|
+
let open = [];
|
|
179
|
+
const keys = new Set();
|
|
180
|
+
const flush = () => {
|
|
181
|
+
if (open.length > 0) {
|
|
182
|
+
batches.push(open);
|
|
183
|
+
open = [];
|
|
184
|
+
keys.clear();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const singleton = (call, parsed) => {
|
|
188
|
+
flush();
|
|
189
|
+
batches.push([{ call, parsed, parallelKey: null }]);
|
|
190
|
+
};
|
|
191
|
+
for (const call of calls) {
|
|
192
|
+
let parsed;
|
|
193
|
+
let malformed = false;
|
|
194
|
+
try {
|
|
195
|
+
const raw = call?.function?.arguments ?? "{}";
|
|
196
|
+
const v = JSON.parse(typeof raw === "string" ? raw : "{}");
|
|
197
|
+
parsed = typeof v === "object" && v !== null ? v : {};
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
parsed = {};
|
|
201
|
+
malformed = true;
|
|
202
|
+
}
|
|
203
|
+
const name = typeof call?.function?.name === "string" ? call.function.name : "(unknown)";
|
|
204
|
+
// Missing metadata fails safe to serial (never batch the unknown).
|
|
205
|
+
const meta = TOOL_EFFECTS[name];
|
|
206
|
+
if (malformed || !meta || !toolNames().includes(name)) {
|
|
207
|
+
singleton(call, parsed);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// Failed validation becomes an inline-error singleton downstream.
|
|
211
|
+
if (validateToolArgs(name, parsed)) {
|
|
212
|
+
singleton(call, parsed);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Interactive, ambient-state, mutating, or spawning calls serialize.
|
|
216
|
+
if (meta.interactive ||
|
|
217
|
+
meta.exclusive ||
|
|
218
|
+
meta.filesystem === "write" ||
|
|
219
|
+
meta.network === "write" ||
|
|
220
|
+
meta.process !== "none") {
|
|
221
|
+
singleton(call, parsed);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Reads batch on disjoint tool+target keys; empty target = unknown
|
|
225
|
+
// footprint = serial.
|
|
226
|
+
let target = null;
|
|
227
|
+
try {
|
|
228
|
+
target = meta.target(parsed);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
target = null;
|
|
232
|
+
}
|
|
233
|
+
if (!target) {
|
|
234
|
+
singleton(call, parsed);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const key = `${name} ${target}`;
|
|
238
|
+
if (keys.has(key)) {
|
|
239
|
+
singleton(call, parsed);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
keys.add(key);
|
|
243
|
+
open.push({ call, parsed, parallelKey: key });
|
|
244
|
+
}
|
|
245
|
+
flush();
|
|
246
|
+
return batches;
|
|
247
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
// outside the repo, so .gitignore needs no change).
|
|
17
17
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
-
import { atomDir } from "./auth.js";
|
|
20
|
-
import { isProviderId } from "./providers.js";
|
|
19
|
+
import { atomDir, getStoredBaseURL, loadAuth, resolveApiKey } from "./auth.js";
|
|
20
|
+
import { chatEndpointFor, isProviderId, openaiCompatibleChatEndpoint, } from "./providers.js";
|
|
21
21
|
import { EFFORT_OPTIONS, } from "./zen.js";
|
|
22
22
|
export const SESSION_VERSION = 1;
|
|
23
23
|
export const SESSION_FILENAME = "session.json";
|
|
@@ -32,6 +32,32 @@ export function sessionExists(home) {
|
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
export function loadPrefs(home, zenEndpoint) {
|
|
36
|
+
try {
|
|
37
|
+
const loaded = loadSession(home);
|
|
38
|
+
if (loaded.status !== "ok")
|
|
39
|
+
return null;
|
|
40
|
+
const s = loaded.session;
|
|
41
|
+
const auth = loadAuth(home);
|
|
42
|
+
const key = resolveApiKey(s.provider, auth);
|
|
43
|
+
// Kilo serves anonymous free models, so a saved kilo session restores
|
|
44
|
+
// keyless; every other keyed provider still needs a resolvable key.
|
|
45
|
+
if (!key && s.provider !== "kilo")
|
|
46
|
+
return null;
|
|
47
|
+
const baseURL = getStoredBaseURL(auth, s.provider);
|
|
48
|
+
if (s.provider === "openai-compatible" && !baseURL)
|
|
49
|
+
return null;
|
|
50
|
+
const endpoint = s.provider === "opencode-zen"
|
|
51
|
+
? zenEndpoint
|
|
52
|
+
: s.provider === "openai-compatible"
|
|
53
|
+
? openaiCompatibleChatEndpoint(baseURL)
|
|
54
|
+
: chatEndpointFor(s.provider, baseURL);
|
|
55
|
+
return { provider: s.provider, model: s.model, effort: s.effort, apiKey: key, endpoint };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
35
61
|
// Atomic save: write temp + rename, 0600 POSIX (best-effort Windows).
|
|
36
62
|
// Never throws for missing dirs (mkdir -p); disk errors propagate to the
|
|
37
63
|
// caller, which ignores them (in-memory session still applies).
|
|
@@ -95,7 +121,13 @@ function validateUsageTotals(value) {
|
|
|
95
121
|
if (!isRecord(value))
|
|
96
122
|
return null;
|
|
97
123
|
const out = {};
|
|
98
|
-
for (const key of [
|
|
124
|
+
for (const key of [
|
|
125
|
+
"prompt_tokens",
|
|
126
|
+
"completion_tokens",
|
|
127
|
+
"total_tokens",
|
|
128
|
+
"cacheReadTokens",
|
|
129
|
+
"cacheWriteTokens",
|
|
130
|
+
]) {
|
|
99
131
|
const v = value[key];
|
|
100
132
|
if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
101
133
|
out[key] = Math.floor(v);
|
package/dist/skills.js
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
// the TUI listing. Levels and precedence (ticket 05), invocation (tickets
|
|
6
6
|
// 03/04), and tool grants (ticket 06) build on this registry.
|
|
7
7
|
//
|
|
8
|
+
// Roots (scanned in order, every call — no cache): project .claude/skills,
|
|
9
|
+
// project .agents/skills (skills.sh installs here), global ~/.claude/skills,
|
|
10
|
+
// global ~/.agents/skills. Same-level name clashes keep the first with a
|
|
11
|
+
// note (see resolveSkills); .claude sorts before .agents so a skill present
|
|
12
|
+
// in both keeps its .claude copy.
|
|
13
|
+
//
|
|
8
14
|
// Node builtins only. Discovery never throws: per-skill failures come back
|
|
9
15
|
// as warning strings, and a missing skills directory is normal (silent).
|
|
10
16
|
import { promises as fsp } from "node:fs";
|
|
@@ -99,7 +105,9 @@ export async function discoverSkills(opts) {
|
|
|
99
105
|
const seenBases = new Set();
|
|
100
106
|
const roots = [
|
|
101
107
|
{ base: path.join(projectDir, ".claude", "skills"), source: "project" },
|
|
108
|
+
{ base: path.join(projectDir, ".agents", "skills"), source: "project" },
|
|
102
109
|
{ base: path.join(homeDir, ".claude", "skills"), source: "global" },
|
|
110
|
+
{ base: path.join(homeDir, ".agents", "skills"), source: "global" },
|
|
103
111
|
];
|
|
104
112
|
for (const { base, source } of roots) {
|
|
105
113
|
const resolved = path.resolve(base);
|
|
@@ -117,73 +125,102 @@ export async function discoverSkills(opts) {
|
|
|
117
125
|
.filter((e) => e.isDirectory())
|
|
118
126
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
119
127
|
for (const e of dirs) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
});
|
|
128
|
+
const parsed = await parseSkillDir(base, source, e.name);
|
|
129
|
+
if (parsed.info)
|
|
130
|
+
skills.push(parsed.info);
|
|
131
|
+
else if (parsed.warning)
|
|
132
|
+
warnings.push(parsed.warning);
|
|
153
133
|
}
|
|
154
134
|
}
|
|
155
135
|
return { skills, warnings };
|
|
156
136
|
}
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
137
|
+
// Parse one skill directory's SKILL.md into metadata (Tier 1) or a warning.
|
|
138
|
+
// Shared by the uncached discoverSkills above and the SkillRegistry below so
|
|
139
|
+
// both paths parse byte-identically. Reads exactly one file; bodies and
|
|
140
|
+
// references stay lazy (see loadSkillBody).
|
|
141
|
+
async function parseSkillDir(base, source, dirname) {
|
|
142
|
+
const dir = path.join(base, dirname);
|
|
143
|
+
let text;
|
|
144
|
+
try {
|
|
145
|
+
text = await fsp.readFile(path.join(dir, "SKILL.md"), "utf8");
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { info: null, warning: `skill "${dirname}" (${source}): cannot read SKILL.md — skipped` };
|
|
149
|
+
}
|
|
150
|
+
const { front, body, unclosed } = splitFrontmatter(text);
|
|
151
|
+
if (unclosed) {
|
|
152
|
+
return { info: null, warning: `skill "${dirname}" (${source}): unclosed frontmatter — skipped` };
|
|
153
|
+
}
|
|
154
|
+
const fmName = front ? frontField(front, "name") : undefined;
|
|
155
|
+
const fmDesc = front ? frontField(front, "description") : undefined;
|
|
156
|
+
const name = fmName && fmName.length > 0 ? fmName : dirname;
|
|
157
|
+
const description = fmDesc && fmDesc.length > 0 ? fmDesc : firstParagraph(body);
|
|
158
|
+
if (!description) {
|
|
159
|
+
return { info: null, warning: `skill "${name}" (${source}): no description and empty body — skipped` };
|
|
160
|
+
}
|
|
161
|
+
const noModel = front ? frontBool(front, "disable-model-invocation") : undefined;
|
|
162
|
+
const userOnly = front ? frontBool(front, "user-invocable") : undefined;
|
|
163
|
+
return {
|
|
164
|
+
info: {
|
|
165
|
+
name,
|
|
166
|
+
description,
|
|
167
|
+
dir,
|
|
168
|
+
source,
|
|
169
|
+
userInvocable: userOnly ?? true,
|
|
170
|
+
modelInvocable: !(noModel ?? false),
|
|
171
|
+
allowedTools: front ? parseAllowedTools(frontField(front, "allowed-tools")) : [],
|
|
172
|
+
},
|
|
173
|
+
warning: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// One-shot listing for copy/paste and headless use. Rows are names only
|
|
177
|
+
// (`/skill:name`, directly runnable) plus source — descriptions stay out of
|
|
178
|
+
// the TUI; the picker and descriptions live in SKILL.md files. Name clashes
|
|
179
|
+
// resolve with personal (global) winning (resolveSkills); model-only skills
|
|
180
|
+
// show with an [auto-only] tag instead of hiding. The header always renders
|
|
181
|
+
// so the command is self-explanatory when empty; warnings ride along visibly.
|
|
162
182
|
export async function skillsListText(projectDir, homeDir) {
|
|
163
183
|
const found = await discoverSkills({ projectDir, homeDir });
|
|
164
184
|
const { skills, notes } = resolveSkills(found.skills);
|
|
165
185
|
const out = [skills.length === 1 ? "Skills (1):" : `Skills (${skills.length}):`];
|
|
166
186
|
for (const s of skills) {
|
|
167
|
-
out.push(
|
|
187
|
+
out.push(`/skill:${s.name} [${s.source}]${s.userInvocable ? "" : " [auto-only]"}`);
|
|
168
188
|
}
|
|
169
189
|
for (const n of notes)
|
|
170
190
|
out.push(`note: ${n}`);
|
|
171
191
|
for (const w of found.warnings)
|
|
172
192
|
out.push(`⚠ ${w}`);
|
|
173
193
|
if (skills.length === 0 && found.warnings.length === 0) {
|
|
174
|
-
out.push("(no skills installed — add SKILL.md skills under .claude/skills/ or ~/.
|
|
194
|
+
out.push("(no skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, ~/.claude/skills/, or ~/.agents/skills/)");
|
|
175
195
|
}
|
|
176
196
|
return out.join("\n");
|
|
177
197
|
}
|
|
178
198
|
const SKILL_FILE_CAP = 8 * 1024;
|
|
179
199
|
const SKILL_INCLUDE_MAX = 3;
|
|
200
|
+
// Auto-invoke context cap (Tier 2): an auto-loaded skill body is truncated
|
|
201
|
+
// here with a pointer the model can follow via read — auto-activation must
|
|
202
|
+
// never flood the window the way an explicit manual load may.
|
|
203
|
+
export const AUTO_SKILL_BODY_CAP = 12 * 1024;
|
|
204
|
+
export function capSkillBodyForAuto(text, skillDir) {
|
|
205
|
+
if (text.length <= AUTO_SKILL_BODY_CAP)
|
|
206
|
+
return text;
|
|
207
|
+
return (text.slice(0, AUTO_SKILL_BODY_CAP) +
|
|
208
|
+
`\n[truncated: auto-loaded skill body exceeded 12KB — read ${skillDir}/SKILL.md for the rest]`);
|
|
209
|
+
}
|
|
180
210
|
// references/<path> + scripts/<path> mentions inside the skill body.
|
|
181
211
|
const SKILL_MENTION_RE = /\b(references|scripts)\/[A-Za-z0-9_.\-/@]+/g;
|
|
182
212
|
// Load a skill's body plus the support files its body references (on
|
|
183
213
|
// demand, capped — never the whole directory). Mentioned-but-missing files
|
|
184
214
|
// are skipped silently; path escapes outside the skill dir are dropped.
|
|
185
215
|
// Never throws: an unreadable SKILL.md yields empty text.
|
|
186
|
-
|
|
216
|
+
//
|
|
217
|
+
// Progressive-disclosure tiers (Claude-Code-style): pass { inlineRefs: false }
|
|
218
|
+
// to load Tier 2 only (body, no inlined references — the model reads
|
|
219
|
+
// references/<…> via the read tool when it actually needs them). Tier 3
|
|
220
|
+
// (references) stays on demand. Manual invocation keeps the default
|
|
221
|
+
// (inline) since the user explicitly asked for the whole skill.
|
|
222
|
+
export async function loadSkillBody(skill, opts) {
|
|
223
|
+
const inlineRefs = opts?.inlineRefs ?? true;
|
|
187
224
|
let raw;
|
|
188
225
|
try {
|
|
189
226
|
raw = await fsp.readFile(path.join(skill.dir, "SKILL.md"), "utf8");
|
|
@@ -192,6 +229,8 @@ export async function loadSkillBody(skill) {
|
|
|
192
229
|
return { info: skill, text: "", included: [] };
|
|
193
230
|
}
|
|
194
231
|
const { body } = splitFrontmatter(raw);
|
|
232
|
+
if (!inlineRefs)
|
|
233
|
+
return { info: skill, text: body, included: [] };
|
|
195
234
|
const seen = new Set();
|
|
196
235
|
const mentions = [];
|
|
197
236
|
for (const m of body.match(SKILL_MENTION_RE) ?? []) {
|
|
@@ -232,20 +271,31 @@ function contentWords(s) {
|
|
|
232
271
|
}
|
|
233
272
|
// Deterministic description match for auto-invoke: distinct
|
|
234
273
|
// name+description words (len ≥ 3, stopwords dropped) hitting the
|
|
235
|
-
// message
|
|
274
|
+
// message, at least minHits (default 2), best score first,
|
|
236
275
|
// capped at max (default 2) skills per turn. Skills with
|
|
237
276
|
// disable-model-invocation never match. Pure function — no I/O.
|
|
277
|
+
//
|
|
278
|
+
// Matching modes: substring (default — `test` hits `testing`, kept for
|
|
279
|
+
// backward compatibility) or wholeWords (a skill word must appear as a whole
|
|
280
|
+
// message word — kills the biggest false-positive class (`test` inside
|
|
281
|
+
// `latest`, `commit` inside `committed`). The App's auto-invoke path uses
|
|
282
|
+
// wholeWords with a higher bar; the defaults stay for manual tooling.
|
|
238
283
|
export function matchSkills(message, skills, opts) {
|
|
239
284
|
const max = opts?.max ?? 2;
|
|
240
285
|
const minHits = opts?.minHits ?? 2;
|
|
286
|
+
const wholeWords = opts?.wholeWords ?? false;
|
|
241
287
|
const text = message.toLowerCase();
|
|
288
|
+
const messageWords = wholeWords ?
|
|
289
|
+
new Set(text.split(/[^a-z0-9]+/).filter((w) => w.length > 0))
|
|
290
|
+
: null;
|
|
242
291
|
const scored = [];
|
|
243
292
|
for (const s of skills) {
|
|
244
293
|
if (!s.modelInvocable)
|
|
245
294
|
continue;
|
|
246
295
|
let hits = 0;
|
|
247
296
|
for (const w of contentWords(`${s.name} ${s.description}`)) {
|
|
248
|
-
|
|
297
|
+
const hit = messageWords ? messageWords.has(w) : text.includes(w);
|
|
298
|
+
if (hit)
|
|
249
299
|
hits += 1;
|
|
250
300
|
}
|
|
251
301
|
if (hits >= minHits)
|
|
@@ -281,3 +331,124 @@ export function resolveSkills(skills) {
|
|
|
281
331
|
}
|
|
282
332
|
return { skills: order.map((n) => byName.get(n)), notes };
|
|
283
333
|
}
|
|
334
|
+
export function createSkillRegistry(opts) {
|
|
335
|
+
const projectDir = opts?.projectDir ?? process.cwd();
|
|
336
|
+
const homeDir = opts?.homeDir ?? os.homedir();
|
|
337
|
+
// Resolved root base -> dirname -> cached parse. Instance-local: no
|
|
338
|
+
// cross-talk between Apps, tests, or headless users.
|
|
339
|
+
const rootState = new Map();
|
|
340
|
+
let last = { skills: [], warnings: [] };
|
|
341
|
+
function roots() {
|
|
342
|
+
return [
|
|
343
|
+
{ base: path.join(projectDir, ".claude", "skills"), source: "project" },
|
|
344
|
+
{ base: path.join(projectDir, ".agents", "skills"), source: "project" },
|
|
345
|
+
{ base: path.join(homeDir, ".claude", "skills"), source: "global" },
|
|
346
|
+
{ base: path.join(homeDir, ".agents", "skills"), source: "global" },
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
async function refresh() {
|
|
350
|
+
const skills = [];
|
|
351
|
+
const warnings = [];
|
|
352
|
+
const stats = {
|
|
353
|
+
roots: 0,
|
|
354
|
+
entries: 0,
|
|
355
|
+
reused: 0,
|
|
356
|
+
reloaded: 0,
|
|
357
|
+
added: 0,
|
|
358
|
+
removed: 0,
|
|
359
|
+
};
|
|
360
|
+
const seenBases = new Set();
|
|
361
|
+
for (const { base, source } of roots()) {
|
|
362
|
+
const resolved = path.resolve(base);
|
|
363
|
+
if (seenBases.has(resolved))
|
|
364
|
+
continue; // e.g. repo rooted at $HOME
|
|
365
|
+
seenBases.add(resolved);
|
|
366
|
+
let entries;
|
|
367
|
+
try {
|
|
368
|
+
entries = await fsp.readdir(base, { withFileTypes: true });
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Root vanished: drop its cached entries silently (same as discovery).
|
|
372
|
+
const prev = rootState.get(resolved);
|
|
373
|
+
if (prev) {
|
|
374
|
+
stats.removed += prev.size;
|
|
375
|
+
rootState.delete(resolved);
|
|
376
|
+
}
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
stats.roots += 1;
|
|
380
|
+
const dirs = entries
|
|
381
|
+
.filter((e) => e.isDirectory())
|
|
382
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
383
|
+
const prev = rootState.get(resolved) ?? new Map();
|
|
384
|
+
const next = new Map();
|
|
385
|
+
for (const e of dirs) {
|
|
386
|
+
stats.entries += 1;
|
|
387
|
+
const skillFile = path.join(base, e.name, "SKILL.md");
|
|
388
|
+
let fingerprint = null;
|
|
389
|
+
try {
|
|
390
|
+
const st = await fsp.stat(skillFile);
|
|
391
|
+
if (st.isFile())
|
|
392
|
+
fingerprint = { mtimeMs: st.mtimeMs, size: st.size };
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
fingerprint = null;
|
|
396
|
+
}
|
|
397
|
+
const cached = prev.get(e.name);
|
|
398
|
+
// Reuse on fingerprint match. Entries that previously failed to stat
|
|
399
|
+
// carry the (0, 0) sentinel and can never match a real file, so a
|
|
400
|
+
// vanished file that reappears is always re-parsed — never stuck.
|
|
401
|
+
if (cached &&
|
|
402
|
+
fingerprint &&
|
|
403
|
+
cached.mtimeMs > 0 &&
|
|
404
|
+
fingerprint.mtimeMs === cached.mtimeMs &&
|
|
405
|
+
fingerprint.size === cached.size) {
|
|
406
|
+
next.set(e.name, cached);
|
|
407
|
+
stats.reused += 1;
|
|
408
|
+
if (cached.info)
|
|
409
|
+
skills.push(cached.info);
|
|
410
|
+
else if (cached.warning)
|
|
411
|
+
warnings.push(cached.warning);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (fingerprint === null) {
|
|
415
|
+
// Matches discovery's read-failure warning byte-for-byte. Re-checked
|
|
416
|
+
// (not reloaded) every refresh — a reappearing file is re-parsed via
|
|
417
|
+
// the path below, never stuck warned.
|
|
418
|
+
const warning = `skill "${e.name}" (${source}): cannot read SKILL.md — skipped`;
|
|
419
|
+
next.set(e.name, { mtimeMs: 0, size: 0, info: null, warning });
|
|
420
|
+
warnings.push(warning);
|
|
421
|
+
if (!cached)
|
|
422
|
+
stats.added += 1;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const parsed = await parseSkillDir(base, source, e.name);
|
|
426
|
+
next.set(e.name, {
|
|
427
|
+
mtimeMs: fingerprint.mtimeMs,
|
|
428
|
+
size: fingerprint.size,
|
|
429
|
+
info: parsed.info,
|
|
430
|
+
warning: parsed.warning,
|
|
431
|
+
});
|
|
432
|
+
if (parsed.info)
|
|
433
|
+
skills.push(parsed.info);
|
|
434
|
+
else if (parsed.warning)
|
|
435
|
+
warnings.push(parsed.warning);
|
|
436
|
+
if (cached)
|
|
437
|
+
stats.reloaded += 1;
|
|
438
|
+
else
|
|
439
|
+
stats.added += 1;
|
|
440
|
+
}
|
|
441
|
+
for (const name of prev.keys()) {
|
|
442
|
+
if (!next.has(name))
|
|
443
|
+
stats.removed += 1;
|
|
444
|
+
}
|
|
445
|
+
rootState.set(resolved, next);
|
|
446
|
+
}
|
|
447
|
+
last = { skills, warnings };
|
|
448
|
+
return { skills, warnings, stats };
|
|
449
|
+
}
|
|
450
|
+
function snapshot() {
|
|
451
|
+
return { skills: [...last.skills], warnings: [...last.warnings] };
|
|
452
|
+
}
|
|
453
|
+
return { refresh, snapshot };
|
|
454
|
+
}
|