s-app-scoder-agent 0.5.0 → 0.7.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -435
- package/dist/agent.js +829 -168
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +216 -45
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +148 -11
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +537 -24
- package/dist/tui/app.js +615 -0
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +222 -0
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +342 -0
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/ui.js +43 -3
- package/dist/watch.js +52 -0
- package/package.json +18 -5
package/dist/session.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session persistence:
|
|
3
|
-
*
|
|
2
|
+
* Session persistence v2: durable event log + projections.
|
|
3
|
+
*
|
|
4
|
+
* Source: anomalyco/opencode (packages/core/src/session/stored.ts, run-coordinator.ts;
|
|
5
|
+
* specs/v2/session.md) — pattern adapted: event-sourced JSONL log as the durable
|
|
6
|
+
* source, in-memory projection for messages, write-ahead execution claim for
|
|
7
|
+
* crash recovery. Falls back to reading v1 JSON snapshots transparently.
|
|
8
|
+
*
|
|
9
|
+
* Layout:
|
|
10
|
+
* <workdir>/.scoder/sessions/<id>.json — v1 snapshot (legacy, read-only)
|
|
11
|
+
* <workdir>/.scoder/sessions/<id>/events.jsonl — v2 durable event log
|
|
12
|
+
* <workdir>/.scoder/sessions/<id>/manifest.json — projection summary
|
|
4
13
|
*/
|
|
5
|
-
import { mkdirSync, writeFileSync, renameSync, readFileSync, readdirSync, existsSync, rmSync, statSync } from "node:fs";
|
|
14
|
+
import { mkdirSync, writeFileSync, appendFileSync, renameSync, readFileSync, readdirSync, existsSync, rmSync, statSync } from "node:fs";
|
|
6
15
|
import { join, resolve } from "node:path";
|
|
7
16
|
const SESSIONS_DIR = ".scoder/sessions";
|
|
8
17
|
export function newSessionId(now = new Date()) {
|
|
@@ -19,12 +28,192 @@ export function newSessionId(now = new Date()) {
|
|
|
19
28
|
function sessionsDir(workdir) {
|
|
20
29
|
return resolve(workdir, SESSIONS_DIR);
|
|
21
30
|
}
|
|
31
|
+
function eventLogPath(workdir, id) {
|
|
32
|
+
return join(sessionsDir(workdir), id, "events.jsonl");
|
|
33
|
+
}
|
|
34
|
+
function manifestPath(workdir, id) {
|
|
35
|
+
return join(sessionsDir(workdir), id, "manifest.json");
|
|
36
|
+
}
|
|
22
37
|
/** Atomic write: write to .tmp then rename. */
|
|
23
38
|
function atomicWrite(path, content) {
|
|
39
|
+
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
24
40
|
const tmp = `${path}.tmp`;
|
|
25
41
|
writeFileSync(tmp, content, "utf8");
|
|
26
42
|
renameSync(tmp, path);
|
|
27
43
|
}
|
|
44
|
+
/** ---------------------------------------------------------------------
|
|
45
|
+
* Event-sourced store (v2)
|
|
46
|
+
* ------------------------------------------------------------------- */
|
|
47
|
+
export class EventSessionStore {
|
|
48
|
+
id;
|
|
49
|
+
enabled;
|
|
50
|
+
_workdir;
|
|
51
|
+
seq = 0;
|
|
52
|
+
createdAt;
|
|
53
|
+
logPath;
|
|
54
|
+
/** In-memory projection: message history (append-only). */
|
|
55
|
+
_messages = [];
|
|
56
|
+
_task = "";
|
|
57
|
+
_steps = 0;
|
|
58
|
+
_lastBuild = null;
|
|
59
|
+
_usage = undefined;
|
|
60
|
+
_finished = null;
|
|
61
|
+
constructor(workdir, enabled, id = newSessionId()) {
|
|
62
|
+
this.id = id;
|
|
63
|
+
this._workdir = workdir;
|
|
64
|
+
this.enabled = enabled;
|
|
65
|
+
this.createdAt = new Date().toISOString();
|
|
66
|
+
this.logPath = eventLogPath(workdir, id);
|
|
67
|
+
if (enabled) {
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(resolve(this.logPath, ".."), { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// Never crash the agent because of a persistence failure
|
|
73
|
+
console.error(`⚠ session store init failed: ${err.message}`);
|
|
74
|
+
this.enabled = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
get workdir() {
|
|
79
|
+
return this._workdir;
|
|
80
|
+
}
|
|
81
|
+
/** Append one durable event (fsync-free appendFileSync is atomic enough per line on POSIX). */
|
|
82
|
+
append(event) {
|
|
83
|
+
if (!this.enabled)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const full = {
|
|
87
|
+
ts: event.ts ?? new Date().toISOString(),
|
|
88
|
+
v: 1,
|
|
89
|
+
seq: ++this.seq,
|
|
90
|
+
...event,
|
|
91
|
+
};
|
|
92
|
+
appendFileSync(this.logPath, JSON.stringify(full) + "\n", "utf8");
|
|
93
|
+
this.project(full);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error(`⚠ event append failed: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Update the in-memory projection from an event. */
|
|
100
|
+
project(ev) {
|
|
101
|
+
switch (ev.type) {
|
|
102
|
+
case "run.started":
|
|
103
|
+
this._task = String(ev.task ?? "");
|
|
104
|
+
break;
|
|
105
|
+
case "message.added":
|
|
106
|
+
this._messages.push(ev.message);
|
|
107
|
+
break;
|
|
108
|
+
case "build.checked":
|
|
109
|
+
this._lastBuild = {
|
|
110
|
+
success: Boolean(ev.success),
|
|
111
|
+
command: String(ev.command ?? ""),
|
|
112
|
+
output: String(ev.output ?? "").slice(0, 10_000),
|
|
113
|
+
};
|
|
114
|
+
break;
|
|
115
|
+
case "finish":
|
|
116
|
+
this._finished = { success: Boolean(ev.success), summary: String(ev.summary ?? "") };
|
|
117
|
+
break;
|
|
118
|
+
default:
|
|
119
|
+
break; // tool/guardrail/etc. events are queryable from the raw log
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Materialized message history (projection). */
|
|
123
|
+
get messages() {
|
|
124
|
+
return this._messages;
|
|
125
|
+
}
|
|
126
|
+
/** Write-ahead execution claim — stamped when a step begins executing. */
|
|
127
|
+
claimExecution(attempt) {
|
|
128
|
+
this.append({
|
|
129
|
+
type: "execution.claimed",
|
|
130
|
+
attempt,
|
|
131
|
+
pid: process.pid,
|
|
132
|
+
host: process.env.HOSTNAME ?? "localhost",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
releaseExecution(attempt) {
|
|
136
|
+
this.append({ type: "execution.released", attempt });
|
|
137
|
+
}
|
|
138
|
+
/** Crash recovery: sessions with a stale claim (no matching release). */
|
|
139
|
+
static hasStaleClaim(workdir, id) {
|
|
140
|
+
const events = readEventLog(workdir, id);
|
|
141
|
+
let claimed = 0;
|
|
142
|
+
let released = 0;
|
|
143
|
+
for (const ev of events) {
|
|
144
|
+
if (ev.type === "execution.claimed")
|
|
145
|
+
claimed++;
|
|
146
|
+
if (ev.type === "execution.released")
|
|
147
|
+
released++;
|
|
148
|
+
}
|
|
149
|
+
return claimed > released;
|
|
150
|
+
}
|
|
151
|
+
/** Full projection → the v1 SessionFile shape (resume/read compatibility). */
|
|
152
|
+
toSessionFile() {
|
|
153
|
+
return {
|
|
154
|
+
id: this.id,
|
|
155
|
+
createdAt: this.createdAt,
|
|
156
|
+
updatedAt: new Date().toISOString(),
|
|
157
|
+
task: this._task,
|
|
158
|
+
model: "",
|
|
159
|
+
provider: "",
|
|
160
|
+
buildCmd: null,
|
|
161
|
+
steps: this._steps,
|
|
162
|
+
maxSteps: 0,
|
|
163
|
+
messages: this._messages,
|
|
164
|
+
lastBuild: this._lastBuild,
|
|
165
|
+
usage: this._usage,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
get finished() {
|
|
169
|
+
return this._finished;
|
|
170
|
+
}
|
|
171
|
+
/** Persist a manifest (cheap summary for /sessions listing). */
|
|
172
|
+
writeManifest(meta) {
|
|
173
|
+
if (!this.enabled)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
atomicWrite(manifestPath(this._workdir, this.id), JSON.stringify({
|
|
177
|
+
id: this.id,
|
|
178
|
+
createdAt: this.createdAt,
|
|
179
|
+
updatedAt: new Date().toISOString(),
|
|
180
|
+
lastBuild: this._lastBuild,
|
|
181
|
+
finished: this._finished,
|
|
182
|
+
eventCount: this.seq,
|
|
183
|
+
...meta,
|
|
184
|
+
}, null, 2));
|
|
185
|
+
this._steps = meta.steps;
|
|
186
|
+
this._usage = meta.usage;
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
console.error(`⚠ manifest write failed: ${err.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Read the raw event log of a session (empty array when missing/malformed lines). */
|
|
194
|
+
export function readEventLog(workdir, id) {
|
|
195
|
+
const p = eventLogPath(workdir, id);
|
|
196
|
+
if (!existsSync(p))
|
|
197
|
+
return [];
|
|
198
|
+
const out = [];
|
|
199
|
+
try {
|
|
200
|
+
const text = readFileSync(p, "utf8");
|
|
201
|
+
for (const line of text.split("\n")) {
|
|
202
|
+
if (!line.trim())
|
|
203
|
+
continue;
|
|
204
|
+
try {
|
|
205
|
+
out.push(JSON.parse(line));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// skip malformed line (torn write on crash) — remaining lines stay valid
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
28
217
|
export class SessionStore {
|
|
29
218
|
id;
|
|
30
219
|
filePath;
|
|
@@ -60,6 +249,11 @@ export class SessionStore {
|
|
|
60
249
|
// Load / list / prune
|
|
61
250
|
// ---------------------------------------------------------------------------
|
|
62
251
|
export function loadSession(workdir, id) {
|
|
252
|
+
// v2 event-sourced session directory first
|
|
253
|
+
const v2Dir = join(sessionsDir(workdir), id);
|
|
254
|
+
if (existsSync(v2Dir) && statSync(v2Dir).isDirectory()) {
|
|
255
|
+
return sessionFromEventLog(workdir, id);
|
|
256
|
+
}
|
|
63
257
|
const path = join(sessionsDir(workdir), `${id}.json`);
|
|
64
258
|
if (!existsSync(path))
|
|
65
259
|
return null;
|
|
@@ -70,11 +264,69 @@ export function loadSession(workdir, id) {
|
|
|
70
264
|
return null;
|
|
71
265
|
}
|
|
72
266
|
}
|
|
267
|
+
/** Rebuild a SessionFile projection from a v2 event log (null when no log). */
|
|
268
|
+
export function sessionFromEventLog(workdir, id) {
|
|
269
|
+
const events = readEventLog(workdir, id);
|
|
270
|
+
if (events.length === 0)
|
|
271
|
+
return null;
|
|
272
|
+
const messages = [];
|
|
273
|
+
let task = "";
|
|
274
|
+
let lastBuild = null;
|
|
275
|
+
let createdAt = "";
|
|
276
|
+
let updatedAt = "";
|
|
277
|
+
for (const ev of events) {
|
|
278
|
+
createdAt = createdAt || ev.ts;
|
|
279
|
+
updatedAt = ev.ts;
|
|
280
|
+
if (ev.type === "run.started")
|
|
281
|
+
task = String(ev.task ?? "");
|
|
282
|
+
else if (ev.type === "message.added")
|
|
283
|
+
messages.push(ev.message);
|
|
284
|
+
else if (ev.type === "build.checked") {
|
|
285
|
+
lastBuild = { success: Boolean(ev.success), command: String(ev.command ?? ""), output: String(ev.output ?? "") };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const manifest = existsSync(manifestPath(workdir, id))
|
|
289
|
+
? JSON.parse(readFileSync(manifestPath(workdir, id), "utf8"))
|
|
290
|
+
: {};
|
|
291
|
+
return {
|
|
292
|
+
id,
|
|
293
|
+
createdAt,
|
|
294
|
+
updatedAt,
|
|
295
|
+
task,
|
|
296
|
+
model: manifest.model ?? "",
|
|
297
|
+
provider: manifest.provider ?? "",
|
|
298
|
+
buildCmd: manifest.buildCmd ?? null,
|
|
299
|
+
steps: manifest.steps ?? 0,
|
|
300
|
+
maxSteps: manifest.maxSteps ?? 0,
|
|
301
|
+
messages,
|
|
302
|
+
lastBuild,
|
|
303
|
+
usage: manifest.usage,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
73
306
|
export function listSessions(workdir) {
|
|
74
307
|
const dir = sessionsDir(workdir);
|
|
75
308
|
if (!existsSync(dir))
|
|
76
309
|
return [];
|
|
77
310
|
const out = [];
|
|
311
|
+
// v2 event-sourced sessions (directories with manifest.json), newest first
|
|
312
|
+
const v2 = [];
|
|
313
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
314
|
+
if (!e.isDirectory())
|
|
315
|
+
continue;
|
|
316
|
+
const mf = join(dir, e.name, "manifest.json");
|
|
317
|
+
if (!existsSync(mf))
|
|
318
|
+
continue;
|
|
319
|
+
try {
|
|
320
|
+
const m = JSON.parse(readFileSync(mf, "utf8"));
|
|
321
|
+
v2.push({ id: m.id, task: (m.task ?? "").slice(0, 60), steps: m.steps ?? 0, updatedAt: m.updatedAt ?? "" });
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// skip malformed
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
v2.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id));
|
|
328
|
+
out.push(...v2);
|
|
329
|
+
// v1 JSON snapshots (legacy, read-only)
|
|
78
330
|
for (const f of readdirSync(dir).sort().reverse()) {
|
|
79
331
|
if (!f.endsWith(".json"))
|
|
80
332
|
continue;
|
|
@@ -95,9 +347,10 @@ export function listSessions(workdir) {
|
|
|
95
347
|
}
|
|
96
348
|
/**
|
|
97
349
|
* Delete session files older than `days` (default 7).
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
350
|
+
* v2: removes the session DIRECTORY (events+manifest). v1: removes the .json.
|
|
351
|
+
* Completed sessions (finished.success) are removed; sessions that did NOT
|
|
352
|
+
* finish successfully are kept longer — they may still be worth resuming.
|
|
353
|
+
* Returns count removed.
|
|
101
354
|
*/
|
|
102
355
|
export function pruneSessions(workdir, days = 7) {
|
|
103
356
|
const dir = sessionsDir(workdir);
|
|
@@ -105,14 +358,32 @@ export function pruneSessions(workdir, days = 7) {
|
|
|
105
358
|
return 0;
|
|
106
359
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
107
360
|
let removed = 0;
|
|
108
|
-
for (const
|
|
109
|
-
|
|
110
|
-
continue;
|
|
111
|
-
const full = join(dir, f);
|
|
361
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
362
|
+
const full = join(dir, e.name);
|
|
112
363
|
try {
|
|
113
364
|
const st = statSync(full);
|
|
114
365
|
if (st.mtimeMs >= cutoff)
|
|
115
366
|
continue;
|
|
367
|
+
if (e.isDirectory()) {
|
|
368
|
+
// v2 session dir — check manifest for finished status
|
|
369
|
+
const mf = join(full, "manifest.json");
|
|
370
|
+
let completed = false;
|
|
371
|
+
if (existsSync(mf)) {
|
|
372
|
+
try {
|
|
373
|
+
completed = JSON.parse(readFileSync(mf, "utf8")).finished?.success === true;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
completed = false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (completed) {
|
|
380
|
+
rmSync(full, { recursive: true, force: true });
|
|
381
|
+
removed++;
|
|
382
|
+
}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!e.name.endsWith(".json"))
|
|
386
|
+
continue;
|
|
116
387
|
const s = JSON.parse(readFileSync(full, "utf8"));
|
|
117
388
|
const completed = s.lastBuild?.success === true;
|
|
118
389
|
if (completed) {
|
|
@@ -123,7 +394,7 @@ export function pruneSessions(workdir, days = 7) {
|
|
|
123
394
|
catch {
|
|
124
395
|
// malformed JSON — prune it (unreadable session is worthless)
|
|
125
396
|
try {
|
|
126
|
-
rmSync(full);
|
|
397
|
+
rmSync(full, { recursive: true, force: true });
|
|
127
398
|
removed++;
|
|
128
399
|
}
|
|
129
400
|
catch { /* ignore */ }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell scanner — AST-aware permission analysis using tree-sitter-bash.
|
|
3
|
+
*
|
|
4
|
+
* Source: anomalyco/opencode (shell permission scanner, /v2/docs/permissions) —
|
|
5
|
+
* concept adapted to tree-sitter-bash on Node. Behavior contract:
|
|
6
|
+
* - A syntactically INVALID command (or dynamic evaluation the scanner
|
|
7
|
+
* cannot resolve) returns { ok: false, error } — the caller must treat it
|
|
8
|
+
* as "ask" (never execute unverified).
|
|
9
|
+
* - Compound commands produce one resource per subcommand; redirect targets
|
|
10
|
+
* are reported so callers can gate external writes.
|
|
11
|
+
* - `cd <dir>` targets are extracted for external_directory gating.
|
|
12
|
+
*/
|
|
13
|
+
import Parser from "tree-sitter";
|
|
14
|
+
import Bash from "tree-sitter-bash";
|
|
15
|
+
let parser = null;
|
|
16
|
+
function getParser() {
|
|
17
|
+
if (!parser) {
|
|
18
|
+
parser = new Parser();
|
|
19
|
+
parser.setLanguage(Bash);
|
|
20
|
+
}
|
|
21
|
+
return parser;
|
|
22
|
+
}
|
|
23
|
+
function collect(root) {
|
|
24
|
+
const commandNames = [];
|
|
25
|
+
const commandLines = [];
|
|
26
|
+
const redirectTargets = [];
|
|
27
|
+
const cdTargets = [];
|
|
28
|
+
let hasCommandSubstitution = false;
|
|
29
|
+
let hasDynamicEval = false;
|
|
30
|
+
let hasError = root.hasError;
|
|
31
|
+
const walk = (n) => {
|
|
32
|
+
if (n.type === "ERROR")
|
|
33
|
+
hasError = true;
|
|
34
|
+
if (n.type === "command_substitution" || n.type === "process_substitution") {
|
|
35
|
+
hasCommandSubstitution = true;
|
|
36
|
+
}
|
|
37
|
+
if (n.type === "command") {
|
|
38
|
+
const nameNode = n.child(0);
|
|
39
|
+
const name = nameNode?.type === "command_name" ? nameNode.text : "";
|
|
40
|
+
if (name) {
|
|
41
|
+
commandNames.push(name);
|
|
42
|
+
commandLines.push(n.text);
|
|
43
|
+
if (name === "cd") {
|
|
44
|
+
const firstArg = n.child(1);
|
|
45
|
+
if (firstArg && firstArg.type !== "command_name")
|
|
46
|
+
cdTargets.push(firstArg.text);
|
|
47
|
+
}
|
|
48
|
+
if (name === "eval" || name === "source" || name === ".")
|
|
49
|
+
hasDynamicEval = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (n.type === "file_redirect") {
|
|
53
|
+
// child(0) = operator (>, >>), child(1) = destination word
|
|
54
|
+
const dest = n.child(1)?.text ?? "";
|
|
55
|
+
if (dest)
|
|
56
|
+
redirectTargets.push(dest);
|
|
57
|
+
}
|
|
58
|
+
if (n.type === "heredoc_redirect") {
|
|
59
|
+
// heredoc body is safe to ignore, but the target word matters
|
|
60
|
+
const dest = n.child(0)?.text ?? "";
|
|
61
|
+
if (dest)
|
|
62
|
+
redirectTargets.push(dest);
|
|
63
|
+
}
|
|
64
|
+
for (let i = 0; i < n.childCount; i++) {
|
|
65
|
+
const c = n.child(i);
|
|
66
|
+
if (c)
|
|
67
|
+
walk(c);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(root);
|
|
71
|
+
return { commandNames, commandLines, redirectTargets, cdTargets, hasCommandSubstitution, hasDynamicEval, hasError };
|
|
72
|
+
}
|
|
73
|
+
/** Scan one shell command line (exactly what run_command would execute). */
|
|
74
|
+
export function scanCommand(command) {
|
|
75
|
+
const empty = {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: "empty command",
|
|
78
|
+
commands: [],
|
|
79
|
+
commandLines: [],
|
|
80
|
+
redirectTargets: [],
|
|
81
|
+
cdTargets: [],
|
|
82
|
+
hasCommandSubstitution: false,
|
|
83
|
+
hasDynamicEval: false,
|
|
84
|
+
};
|
|
85
|
+
if (!command || !command.trim())
|
|
86
|
+
return empty;
|
|
87
|
+
let tree;
|
|
88
|
+
try {
|
|
89
|
+
tree = getParser().parse(command);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return { ...empty, error: `scanner parse failure: ${err.message}` };
|
|
93
|
+
}
|
|
94
|
+
const r = collect(tree.rootNode);
|
|
95
|
+
if (r.hasError) {
|
|
96
|
+
return {
|
|
97
|
+
...empty,
|
|
98
|
+
error: "command has invalid syntax or unresolvable constructs — approval required",
|
|
99
|
+
hasCommandSubstitution: r.hasCommandSubstitution,
|
|
100
|
+
hasDynamicEval: r.hasDynamicEval,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
commands: r.commandNames,
|
|
106
|
+
commandLines: r.commandLines,
|
|
107
|
+
redirectTargets: r.redirectTargets,
|
|
108
|
+
cdTargets: r.cdTargets,
|
|
109
|
+
hasCommandSubstitution: r.hasCommandSubstitution,
|
|
110
|
+
hasDynamicEval: r.hasDynamicEval,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill discovery & loading for scoder.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a directory containing a SKILL.md file with YAML-lite frontmatter:
|
|
5
|
+
*
|
|
6
|
+
* ---
|
|
7
|
+
* name: code-review
|
|
8
|
+
* description: Review code for bugs, security issues, and style
|
|
9
|
+
* ---
|
|
10
|
+
* (markdown instructions the agent follows)
|
|
11
|
+
*
|
|
12
|
+
* Discovery order (later wins on name conflict):
|
|
13
|
+
* 1. ~/.scoder/skills/<name>/SKILL.md (global, user-level)
|
|
14
|
+
* 2. <workdir>/.scoder/skills/<name>/SKILL.md (project-local)
|
|
15
|
+
*
|
|
16
|
+
* Skills are advertised to the model in the system prompt (name + description)
|
|
17
|
+
* so it can request one explicitly; users can also load one directly via
|
|
18
|
+
* `/skill <name>`. Inspired by the SKILL.md convention, implemented from
|
|
19
|
+
* scratch — no external dependencies.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
const SKILL_FILE = "SKILL.md";
|
|
25
|
+
const MAX_SKILL_BODY_CHARS = 20_000;
|
|
26
|
+
/** M5: cảnh báo validate — consumer (Agent/TUI) hiển thị rồi clear. */
|
|
27
|
+
export const skillWarnings = [];
|
|
28
|
+
/** Parse a minimal YAML-lite frontmatter (key: value lines). */
|
|
29
|
+
export function parseFrontmatter(text) {
|
|
30
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
31
|
+
if (!m)
|
|
32
|
+
return { meta: {}, body: text };
|
|
33
|
+
const meta = {};
|
|
34
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
35
|
+
const kv = /^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
|
|
36
|
+
if (kv)
|
|
37
|
+
meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
38
|
+
}
|
|
39
|
+
return { meta, body: m[2].trim() };
|
|
40
|
+
}
|
|
41
|
+
/** Read one SKILL.md into a Skill record (null when the file is malformed). */
|
|
42
|
+
export function loadSkillFile(path, scope) {
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = readFileSync(path, "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const { meta, body } = parseFrontmatter(text);
|
|
51
|
+
const name = (meta.name || "").trim() || null;
|
|
52
|
+
if (!name)
|
|
53
|
+
return null; // a skill without a usable name is not discoverable
|
|
54
|
+
// M5: thiếu description → skip + warn (Anthropic convention: description
|
|
55
|
+
// là bắt buộc — model dùng nó để quyết định khi nào load skill).
|
|
56
|
+
const description = (meta.description || "").trim();
|
|
57
|
+
if (!description) {
|
|
58
|
+
skillWarnings.push({ path, message: `skill "${name}" thiếu description — bỏ qua (thêm description vào frontmatter)` });
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const allowedToolsRaw = (meta["allowed-tools"] || "").trim();
|
|
62
|
+
return {
|
|
63
|
+
name,
|
|
64
|
+
description,
|
|
65
|
+
path,
|
|
66
|
+
scope,
|
|
67
|
+
body: body.length > MAX_SKILL_BODY_CHARS ? body.slice(0, MAX_SKILL_BODY_CHARS) + "\n… [truncated]" : body,
|
|
68
|
+
...(allowedToolsRaw ? { allowedTools: allowedToolsRaw.split(",").map((t) => t.trim()).filter(Boolean) } : {}),
|
|
69
|
+
...(meta.license ? { license: meta.license } : {}),
|
|
70
|
+
...(meta["when-to-use"] ? { whenToUse: meta["when-to-use"] } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Collect SKILL.md files from one skills root (one level of directories). */
|
|
74
|
+
function scanRoot(root, scope) {
|
|
75
|
+
if (!existsSync(root))
|
|
76
|
+
return [];
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
79
|
+
if (!entry.isDirectory())
|
|
80
|
+
continue;
|
|
81
|
+
const file = join(root, entry.name, SKILL_FILE);
|
|
82
|
+
if (!existsSync(file))
|
|
83
|
+
continue;
|
|
84
|
+
const skill = loadSkillFile(file, scope);
|
|
85
|
+
if (skill)
|
|
86
|
+
out.push(skill);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** Global skills root — overridable via SCODER_SKILLS for tests. */
|
|
91
|
+
function globalSkillsRoot() {
|
|
92
|
+
return process.env.SCODER_SKILLS || join(homedir(), ".scoder", "skills");
|
|
93
|
+
}
|
|
94
|
+
/** Discover all skills visible to a project: global first, project overrides. */
|
|
95
|
+
export function discoverSkills(workdir) {
|
|
96
|
+
const global = scanRoot(globalSkillsRoot(), "global");
|
|
97
|
+
const project = scanRoot(join(workdir, ".scoder", "skills"), "project");
|
|
98
|
+
const byName = new Map();
|
|
99
|
+
for (const s of [...global, ...project]) {
|
|
100
|
+
// M5: trùng name → warn (project wins per merge order).
|
|
101
|
+
if (byName.has(s.name)) {
|
|
102
|
+
skillWarnings.push({ path: s.path, message: `skill "${s.name}" trùng tên (${s.scope} overrides ${byName.get(s.name)?.scope})` });
|
|
103
|
+
}
|
|
104
|
+
byName.set(s.name, s); // project wins
|
|
105
|
+
}
|
|
106
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
107
|
+
}
|
|
108
|
+
/** Find one skill by (case-insensitive) name, or null. */
|
|
109
|
+
export function findSkill(workdir, name) {
|
|
110
|
+
const wanted = name.toLowerCase();
|
|
111
|
+
return discoverSkills(workdir).find((s) => s.name.toLowerCase() === wanted) ?? null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* One-line-per-skill advertisement for the system prompt — the model sees
|
|
115
|
+
* names + descriptions and can ask to load a skill, or the user invokes
|
|
116
|
+
* `/skill <name>` directly.
|
|
117
|
+
*/
|
|
118
|
+
export function formatSkillIndex(skills) {
|
|
119
|
+
if (skills.length === 0)
|
|
120
|
+
return "";
|
|
121
|
+
return skills.map((s) => `- ${s.name}: ${s.description || "(no description)"}`).join("\n");
|
|
122
|
+
}
|