s-app-scoder-agent 0.8.21 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/dist/agent.js +662 -65
- package/dist/agents-guide.js +201 -0
- package/dist/agents.js +76 -3
- package/dist/compaction.js +12 -4
- package/dist/config.js +16 -2
- package/dist/context.js +15 -3
- package/dist/diagnostics.js +188 -0
- package/dist/index.js +277 -6
- package/dist/mcp-import.js +204 -0
- package/dist/mcp.js +553 -54
- package/dist/memory.js +140 -0
- package/dist/permissions.js +18 -3
- package/dist/prompt.js +204 -84
- package/dist/repo-map.js +177 -0
- package/dist/sandbox.js +6 -5
- package/dist/session.js +29 -8
- package/dist/skills.js +211 -62
- package/dist/snapshot.js +5 -0
- package/dist/steering.js +4 -0
- package/dist/tools.js +374 -57
- package/dist/tui/app.js +79 -28
- package/dist/tui/ask-user-modal.js +25 -0
- package/dist/tui/chat-views.js +15 -8
- package/dist/tui/chrome.js +2 -1
- package/dist/tui/command-registry.js +284 -9
- package/dist/tui/commands.js +224 -37
- package/dist/tui/markdown.js +170 -12
- package/dist/tui/sidebar.js +36 -13
- package/dist/tui/state.js +101 -2
- package/dist/tui/theme.js +4 -0
- package/dist/tui/transcript.js +145 -0
- package/dist/watch.js +8 -0
- package/dist/webfetch.js +5 -2
- package/package.json +1 -1
package/dist/repo-map.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 (3.2): Repo map — file tree + symbol outline injected into the system
|
|
3
|
+
* prompt so the agent orients without reading half the repo first.
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - No new dependencies: symbols are extracted with per-language regexes
|
|
7
|
+
* (tree-sitter is only a dependency for bash; TS grammars aren't present).
|
|
8
|
+
* - Budget-bounded: the map is capped by chars (~budgetChars ≈ budgetTokens
|
|
9
|
+
* × 4); deeper trees are truncated with a "… more files" marker.
|
|
10
|
+
* - Cached: .scoder/cache/repo-map.json keyed by a content signature
|
|
11
|
+
* (relpath + mtime of every walked file). Any edit invalidates the map.
|
|
12
|
+
* - Hidden + noise dirs skipped (node_modules/.git/dist/…, dot-dirs).
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
17
|
+
const SKIP_DIRS = new Set([
|
|
18
|
+
"node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv",
|
|
19
|
+
"coverage", "out", "target", ".scoder", ".idea", ".vscode", "vendor",
|
|
20
|
+
]);
|
|
21
|
+
const SYMBOL_FILE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|cs)$/i;
|
|
22
|
+
const MAX_WALK_FILES = 2000;
|
|
23
|
+
const MAX_FILE_BYTES = 150 * 1024;
|
|
24
|
+
const MAX_SYMBOLS_PER_FILE = 8;
|
|
25
|
+
const DEFAULT_BUDGET_CHARS = 8000; // ~2k tokens
|
|
26
|
+
/** Walk the workdir (bounded) collecting regular files, skipping noise dirs. */
|
|
27
|
+
export function walkRepo(workdir, sub = ".", maxFiles = MAX_WALK_FILES) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const walk = (dir, depth) => {
|
|
30
|
+
if (depth > 8 || out.length >= maxFiles)
|
|
31
|
+
return;
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
if (out.length >= maxFiles)
|
|
41
|
+
return;
|
|
42
|
+
if (e.name.startsWith(".") && e.name !== ".github")
|
|
43
|
+
continue; // hidden
|
|
44
|
+
if (e.isDirectory()) {
|
|
45
|
+
if (SKIP_DIRS.has(e.name))
|
|
46
|
+
continue;
|
|
47
|
+
walk(join(dir, e.name), depth + 1);
|
|
48
|
+
}
|
|
49
|
+
else if (e.isFile()) {
|
|
50
|
+
const abs = join(dir, e.name);
|
|
51
|
+
try {
|
|
52
|
+
const st = statSync(abs);
|
|
53
|
+
out.push({ rel: relative(workdir, abs).split(sep).join("/"), abs, size: st.size, mtimeMs: st.mtimeMs });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// unreadable entry — skip
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
walk(join(workdir, sub), 0);
|
|
62
|
+
out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Content signature — changes iff any walked file's path or mtime changes. */
|
|
66
|
+
export function repoMapKey(workdir) {
|
|
67
|
+
const h = createHash("sha1");
|
|
68
|
+
for (const f of walkRepo(workdir)) {
|
|
69
|
+
h.update(`${f.rel}:${Math.floor(f.mtimeMs)};`);
|
|
70
|
+
}
|
|
71
|
+
return h.digest("hex");
|
|
72
|
+
}
|
|
73
|
+
// ── Symbol extraction (per-language regex, intentionally conservative) ───────
|
|
74
|
+
const PATTERNS = [
|
|
75
|
+
// TS/JS: export [default] [async] function|class|interface|type|enum|const…
|
|
76
|
+
{ rx: /(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s*([A-Za-z_$][\w$]*)/g, group: 1 },
|
|
77
|
+
{ rx: /export\s+(?:declare\s+)?(?:const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g, group: 1 },
|
|
78
|
+
// Python
|
|
79
|
+
{ rx: /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/gm, group: 1 },
|
|
80
|
+
{ rx: /^\s*class\s+([A-Za-z_]\w*)/gm, group: 1 },
|
|
81
|
+
// Go: func [(recv)] Name, type Name
|
|
82
|
+
{ rx: /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/gm, group: 1 },
|
|
83
|
+
{ rx: /^type\s+([A-Za-z_]\w*)/gm, group: 1 },
|
|
84
|
+
// Rust: [pub] fn|struct|enum|trait Name
|
|
85
|
+
{ rx: /^\s*(?:pub\s+)?(?:fn|struct|enum|trait)\s+([A-Za-z_]\w*)/gm, group: 1 },
|
|
86
|
+
// Java/C#: class|interface Name
|
|
87
|
+
{ rx: /(?:class|interface|enum)\s+([A-Za-z_]\w*)/g, group: 1 },
|
|
88
|
+
];
|
|
89
|
+
export function extractSymbols(path, content) {
|
|
90
|
+
const out = new Set();
|
|
91
|
+
for (const { rx, group } of PATTERNS) {
|
|
92
|
+
rx.lastIndex = 0;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = rx.exec(content)) !== null && out.size < MAX_SYMBOLS_PER_FILE) {
|
|
95
|
+
const sym = m[group];
|
|
96
|
+
if (sym && !/^(if|for|while|switch|return|import|from|package)$/.test(sym))
|
|
97
|
+
out.add(sym);
|
|
98
|
+
}
|
|
99
|
+
if (out.size >= MAX_SYMBOLS_PER_FILE)
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
// Java/C# pattern matches keywords-ish names — filter common noise
|
|
103
|
+
void path;
|
|
104
|
+
return [...out].slice(0, MAX_SYMBOLS_PER_FILE);
|
|
105
|
+
}
|
|
106
|
+
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
107
|
+
function kb(size) {
|
|
108
|
+
return size >= 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(1)}MB` : `${Math.max(1, Math.round(size / 1024))}KB`;
|
|
109
|
+
}
|
|
110
|
+
/** Render the map without touching the cache (used for sub-tree maps). */
|
|
111
|
+
export function renderRepoMap(workdir, budgetChars = DEFAULT_BUDGET_CHARS, sub = ".") {
|
|
112
|
+
const files = walkRepo(workdir, sub);
|
|
113
|
+
const lines = [
|
|
114
|
+
`REPO MAP — ${files.length} files (auto-generated; orient from this, don't read everything)`,
|
|
115
|
+
];
|
|
116
|
+
let budget = budgetChars;
|
|
117
|
+
let truncated = 0;
|
|
118
|
+
for (const f of files) {
|
|
119
|
+
let line = ` ${f.rel} (${kb(f.size)})`;
|
|
120
|
+
if (SYMBOL_FILE.test(f.rel) && f.size <= MAX_FILE_BYTES) {
|
|
121
|
+
try {
|
|
122
|
+
const syms = extractSymbols(f.rel, readFileSync(f.abs, "utf8"));
|
|
123
|
+
if (syms.length > 0)
|
|
124
|
+
line += ` — ${syms.join(", ")}`;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// unreadable — keep just the path
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (budget - line.length - 1 < 0) {
|
|
131
|
+
truncated++;
|
|
132
|
+
continue; // keep counting for the "… more files" marker
|
|
133
|
+
}
|
|
134
|
+
lines.push(line);
|
|
135
|
+
budget -= line.length + 1;
|
|
136
|
+
}
|
|
137
|
+
if (truncated > 0)
|
|
138
|
+
lines.push(` … ${truncated} more file(s) — use list_files/glob to explore`);
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
function cacheFile(workdir) {
|
|
142
|
+
return join(workdir, ".scoder", "cache", "repo-map.json");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Build (or reuse from cache) the repo map for the workdir root.
|
|
146
|
+
* Cache lives in .scoder/cache/ — safe to delete at any time.
|
|
147
|
+
*/
|
|
148
|
+
export function buildRepoMap(workdir, budgetChars = DEFAULT_BUDGET_CHARS) {
|
|
149
|
+
let key = "";
|
|
150
|
+
try {
|
|
151
|
+
key = repoMapKey(workdir);
|
|
152
|
+
const cached = JSON.parse(readFileSync(cacheFile(workdir), "utf8"));
|
|
153
|
+
if (cached.key === key && typeof cached.map === "string")
|
|
154
|
+
return cached.map;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// no/invalid cache — rebuild
|
|
158
|
+
}
|
|
159
|
+
const map = renderRepoMap(workdir, budgetChars);
|
|
160
|
+
try {
|
|
161
|
+
mkdirSync(dirname(cacheFile(workdir)), { recursive: true });
|
|
162
|
+
writeFileSync(cacheFile(workdir), JSON.stringify({ key, generatedAt: new Date().toISOString(), map }), "utf8");
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// cache is best-effort
|
|
166
|
+
}
|
|
167
|
+
return map;
|
|
168
|
+
}
|
|
169
|
+
/** Explicit invalidation (e.g. after /compact or a cache-clearing command). */
|
|
170
|
+
export function invalidateRepoMap(workdir) {
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(cacheFile(workdir), "{}", "utf8");
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// ignore — the mtime key will catch any real change anyway
|
|
176
|
+
}
|
|
177
|
+
}
|
package/dist/sandbox.js
CHANGED
|
@@ -27,8 +27,9 @@ export function buildSandboxArgv(wrapper, profile, workdir, command) {
|
|
|
27
27
|
realWs = realpathSync(workdir);
|
|
28
28
|
}
|
|
29
29
|
catch { /* nonexistent — raw path đủ */ }
|
|
30
|
+
const escapeSbp = (p) => p.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
30
31
|
const wsSubpaths = [...new Set([workdir, realWs])]
|
|
31
|
-
.map((p) => `(subpath "${p}/")`)
|
|
32
|
+
.map((p) => `(subpath "${escapeSbp(p)}/")`)
|
|
32
33
|
.join(" ");
|
|
33
34
|
const allowWrites = profile === "workspace-write"
|
|
34
35
|
? `(allow file-write* ${wsSubpaths} (subpath "/tmp") (subpath "/private/tmp"))`
|
|
@@ -47,10 +48,10 @@ export function buildSandboxArgv(wrapper, profile, workdir, command) {
|
|
|
47
48
|
"--dev-bind", "/", "/", // giữ root — sandbox theo write-permission, không chroot
|
|
48
49
|
"--ro-bind", "/", "/", // override: mọi mount ro
|
|
49
50
|
];
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
if (profile === "workspace-write") {
|
|
52
|
+
for (const w of writes)
|
|
53
|
+
args.push("--bind", w, w);
|
|
54
|
+
}
|
|
54
55
|
args.push("--", "/bin/sh", "-c", command);
|
|
55
56
|
return args;
|
|
56
57
|
}
|
package/dist/session.js
CHANGED
|
@@ -29,11 +29,17 @@ function sessionsDir(workdir) {
|
|
|
29
29
|
return resolve(workdir, SESSIONS_DIR);
|
|
30
30
|
}
|
|
31
31
|
function eventLogPath(workdir, id) {
|
|
32
|
+
validateSessionId(id);
|
|
32
33
|
return join(sessionsDir(workdir), id, "events.jsonl");
|
|
33
34
|
}
|
|
34
35
|
function manifestPath(workdir, id) {
|
|
36
|
+
validateSessionId(id);
|
|
35
37
|
return join(sessionsDir(workdir), id, "manifest.json");
|
|
36
38
|
}
|
|
39
|
+
function validateSessionId(id) {
|
|
40
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id))
|
|
41
|
+
throw new Error(`Invalid session id: ${id}`);
|
|
42
|
+
}
|
|
37
43
|
/** Atomic write: write to .tmp then rename. */
|
|
38
44
|
function atomicWrite(path, content) {
|
|
39
45
|
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
@@ -249,10 +255,21 @@ export class SessionStore {
|
|
|
249
255
|
// Load / list / prune
|
|
250
256
|
// ---------------------------------------------------------------------------
|
|
251
257
|
export function loadSession(workdir, id) {
|
|
258
|
+
try {
|
|
259
|
+
validateSessionId(id);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
252
264
|
// v2 event-sourced session directory first
|
|
253
265
|
const v2Dir = join(sessionsDir(workdir), id);
|
|
254
266
|
if (existsSync(v2Dir) && statSync(v2Dir).isDirectory()) {
|
|
255
|
-
|
|
267
|
+
try {
|
|
268
|
+
const projected = sessionFromEventLog(workdir, id);
|
|
269
|
+
if (projected)
|
|
270
|
+
return projected;
|
|
271
|
+
}
|
|
272
|
+
catch { /* fall through to v1 snapshot */ }
|
|
256
273
|
}
|
|
257
274
|
const path = join(sessionsDir(workdir), `${id}.json`);
|
|
258
275
|
if (!existsSync(path))
|
|
@@ -285,9 +302,12 @@ export function sessionFromEventLog(workdir, id) {
|
|
|
285
302
|
lastBuild = { success: Boolean(ev.success), command: String(ev.command ?? ""), output: String(ev.output ?? "") };
|
|
286
303
|
}
|
|
287
304
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
305
|
+
let manifest = {};
|
|
306
|
+
try {
|
|
307
|
+
if (existsSync(manifestPath(workdir, id)))
|
|
308
|
+
manifest = JSON.parse(readFileSync(manifestPath(workdir, id), "utf8"));
|
|
309
|
+
}
|
|
310
|
+
catch { /* malformed manifest — event log remains usable */ }
|
|
291
311
|
return {
|
|
292
312
|
id,
|
|
293
313
|
createdAt,
|
|
@@ -311,14 +331,15 @@ export function listSessions(workdir) {
|
|
|
311
331
|
// v2 event-sourced sessions (directories with manifest.json), newest first
|
|
312
332
|
const v2 = [];
|
|
313
333
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
314
|
-
if (!e.isDirectory())
|
|
334
|
+
if (!e.isDirectory() || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(e.name))
|
|
315
335
|
continue;
|
|
316
336
|
const mf = join(dir, e.name, "manifest.json");
|
|
317
|
-
|
|
337
|
+
const evf = join(dir, e.name, "events.jsonl");
|
|
338
|
+
if (!existsSync(mf) && !existsSync(evf))
|
|
318
339
|
continue;
|
|
319
340
|
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 ?? "" });
|
|
341
|
+
const m = existsSync(mf) ? JSON.parse(readFileSync(mf, "utf8")) : { id: e.name, task: "", steps: 0, updatedAt: new Date(statSync(evf).mtimeMs).toISOString() };
|
|
342
|
+
v2.push({ id: m.id || e.name, task: (m.task ?? "").slice(0, 60), steps: m.steps ?? 0, updatedAt: m.updatedAt ?? "" });
|
|
322
343
|
}
|
|
323
344
|
catch {
|
|
324
345
|
// skip malformed
|
package/dist/skills.js
CHANGED
|
@@ -1,44 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Skill discovery & loading for scoder.
|
|
3
3
|
*
|
|
4
|
-
* A skill is a directory containing a SKILL.md file with YAML-lite frontmatter
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
4
|
+
* A skill is a directory containing a SKILL.md file with YAML-lite frontmatter.
|
|
5
|
+
* Skills 2.0: supports array/nested frontmatter (version, tags, references),
|
|
6
|
+
* progressive disclosure (index → body → references/*.md), allowed-tools
|
|
7
|
+
* enforcement, auto-match scoring, and compat with Claude/OpenCode skill dirs.
|
|
20
8
|
*/
|
|
21
9
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
22
10
|
import { homedir } from "node:os";
|
|
23
|
-
import { join } from "node:path";
|
|
24
|
-
const SKILL_FILE = "SKILL.md";
|
|
11
|
+
import { join, extname, resolve, relative, isAbsolute } from "node:path";
|
|
25
12
|
const MAX_SKILL_BODY_CHARS = 20_000;
|
|
26
|
-
/** M5: cảnh báo validate — consumer (Agent/TUI) hiển thị rồi clear. */
|
|
27
13
|
export const skillWarnings = [];
|
|
28
|
-
/**
|
|
14
|
+
/** Minimal YAML-lite parser supporting scalars, flow arrays, block arrays, nested. */
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
16
|
export function parseFrontmatter(text) {
|
|
30
17
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
31
18
|
if (!m)
|
|
32
19
|
return { meta: {}, body: text };
|
|
33
|
-
const meta =
|
|
34
|
-
|
|
20
|
+
const meta = parseYamlLite(m[1]);
|
|
21
|
+
return { meta, body: m[2].trim() };
|
|
22
|
+
}
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
function parseYamlLite(src) {
|
|
25
|
+
const lines = src.split(/\r?\n/);
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
const out = {};
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (i < lines.length) {
|
|
30
|
+
const line = lines[i];
|
|
31
|
+
if (!line.trim() || line.trim().startsWith("#")) {
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
35
|
const kv = /^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
|
|
36
|
-
if (kv)
|
|
37
|
-
|
|
36
|
+
if (!kv) {
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const key = kv[1].toLowerCase();
|
|
41
|
+
const raw = kv[2].trim();
|
|
42
|
+
if (raw === "") {
|
|
43
|
+
// block array or nested
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
const items = [];
|
|
46
|
+
let j = i + 1;
|
|
47
|
+
while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
|
|
48
|
+
const item = lines[j].trim().replace(/^-\s+/, "").trim();
|
|
49
|
+
items.push(stripQuotes(item));
|
|
50
|
+
j++;
|
|
51
|
+
}
|
|
52
|
+
if (items.length > 0) {
|
|
53
|
+
out[key] = items;
|
|
54
|
+
i = j;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
out[key] = "";
|
|
58
|
+
i++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
62
|
+
const inner = raw.slice(1, -1).trim();
|
|
63
|
+
out[key] = inner === "" ? [] : inner.split(",").map((s) => stripQuotes(s.trim())).filter((s) => s !== "");
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
out[key] = stripQuotes(raw);
|
|
68
|
+
i++;
|
|
38
69
|
}
|
|
39
|
-
return
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function stripQuotes(s) {
|
|
73
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))
|
|
74
|
+
return s.slice(1, -1);
|
|
75
|
+
return s;
|
|
40
76
|
}
|
|
41
|
-
/** Read one SKILL.md into a Skill record (null when the file is malformed). */
|
|
42
77
|
export function loadSkillFile(path, scope) {
|
|
43
78
|
let text;
|
|
44
79
|
try {
|
|
@@ -48,75 +83,189 @@ export function loadSkillFile(path, scope) {
|
|
|
48
83
|
return null;
|
|
49
84
|
}
|
|
50
85
|
const { meta, body } = parseFrontmatter(text);
|
|
51
|
-
const name = (meta.name || "").trim() || null;
|
|
86
|
+
const name = String(meta.name || "").trim() || null;
|
|
52
87
|
if (!name)
|
|
53
|
-
return null;
|
|
54
|
-
|
|
55
|
-
// là bắt buộc — model dùng nó để quyết định khi nào load skill).
|
|
56
|
-
const description = (meta.description || "").trim();
|
|
88
|
+
return null;
|
|
89
|
+
const description = String(meta.description || "").trim();
|
|
57
90
|
if (!description) {
|
|
58
|
-
skillWarnings.push({ path, message: `skill "${name}" thiếu description — bỏ qua
|
|
91
|
+
skillWarnings.push({ path, message: `skill "${name}" thiếu description — bỏ qua` });
|
|
59
92
|
return null;
|
|
60
93
|
}
|
|
61
|
-
const allowedToolsRaw =
|
|
94
|
+
const allowedToolsRaw = meta["allowed-tools"] ?? meta.allowedtools ?? meta.allowed_tools;
|
|
95
|
+
const allowedTools = Array.isArray(allowedToolsRaw)
|
|
96
|
+
? allowedToolsRaw.map(String)
|
|
97
|
+
: (typeof allowedToolsRaw === "string" && allowedToolsRaw.trim()
|
|
98
|
+
? String(allowedToolsRaw).split(",").map((t) => t.trim()).filter(Boolean)
|
|
99
|
+
: undefined);
|
|
100
|
+
const tagsRaw = meta.tags;
|
|
101
|
+
const tags = Array.isArray(tagsRaw) ? tagsRaw.map(String) : (typeof tagsRaw === "string" && tagsRaw.trim() ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : undefined);
|
|
102
|
+
const referencesRaw = meta.references;
|
|
103
|
+
const references = Array.isArray(referencesRaw) ? referencesRaw.map(String) : (typeof referencesRaw === "string" && referencesRaw.trim() ? referencesRaw.split(",").map((t) => t.trim()).filter(Boolean) : undefined);
|
|
104
|
+
const dir = path.replace(/[\\/]SKILL\.md$/i, "");
|
|
105
|
+
// discover reference files in references/ subdir
|
|
106
|
+
const refDir = join(dir, "references");
|
|
107
|
+
const refFiles = [];
|
|
108
|
+
if (existsSync(refDir)) {
|
|
109
|
+
try {
|
|
110
|
+
for (const f of readdirSync(refDir)) {
|
|
111
|
+
if (extname(f).toLowerCase() === ".md")
|
|
112
|
+
refFiles.push(f);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch { /* ignore */ }
|
|
116
|
+
}
|
|
117
|
+
const allRefs = [...(references ?? []), ...refFiles.filter((f) => !(references ?? []).includes(f))];
|
|
62
118
|
return {
|
|
63
119
|
name,
|
|
64
120
|
description,
|
|
65
121
|
path,
|
|
66
122
|
scope,
|
|
67
123
|
body: body.length > MAX_SKILL_BODY_CHARS ? body.slice(0, MAX_SKILL_BODY_CHARS) + "\n… [truncated]" : body,
|
|
68
|
-
|
|
69
|
-
...(
|
|
70
|
-
...(meta
|
|
124
|
+
dir,
|
|
125
|
+
...(allowedTools && allowedTools.length ? { allowedTools } : {}),
|
|
126
|
+
...(meta.license ? { license: String(meta.license) } : {}),
|
|
127
|
+
...(meta["when-to-use"] || meta.whentouse ? { whenToUse: String(meta["when-to-use"] || meta.whentouse) } : {}),
|
|
128
|
+
...(meta.version ? { version: String(meta.version) } : {}),
|
|
129
|
+
...(tags ? { tags } : {}),
|
|
130
|
+
...(allRefs.length ? { references: allRefs } : {}),
|
|
71
131
|
};
|
|
72
132
|
}
|
|
73
|
-
|
|
133
|
+
function globalSkillsRoots() {
|
|
134
|
+
const roots = [];
|
|
135
|
+
const base = process.env.SCODER_SKILLS || join(homedir(), ".scoder", "skills");
|
|
136
|
+
roots.push(base);
|
|
137
|
+
// compat: Claude / OpenCode skill dirs
|
|
138
|
+
roots.push(join(homedir(), ".claude", "skills"));
|
|
139
|
+
roots.push(join(homedir(), ".opencode", "skills"));
|
|
140
|
+
return roots;
|
|
141
|
+
}
|
|
142
|
+
function projectSkillsRoots(workdir) {
|
|
143
|
+
return [
|
|
144
|
+
join(workdir, ".scoder", "skills"),
|
|
145
|
+
join(workdir, ".claude", "skills"),
|
|
146
|
+
join(workdir, ".opencode", "skills"),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
74
149
|
function scanRoot(root, scope) {
|
|
75
150
|
if (!existsSync(root))
|
|
76
151
|
return [];
|
|
77
152
|
const out = [];
|
|
153
|
+
// Skills may be <root>/<name>/SKILL.md OR <root>/*.md (flat) for compat.
|
|
154
|
+
// First: directory form.
|
|
78
155
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
const file = join(root, entry.name, "SKILL.md");
|
|
158
|
+
if (existsSync(file)) {
|
|
159
|
+
const skill = loadSkillFile(file, scope);
|
|
160
|
+
if (skill)
|
|
161
|
+
out.push(skill);
|
|
162
|
+
}
|
|
163
|
+
// also nested references not skills
|
|
164
|
+
}
|
|
165
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name.toLowerCase() !== "skill.md") {
|
|
166
|
+
// flat skill file: name from frontmatter or filename
|
|
167
|
+
const skill = loadSkillFile(join(root, entry.name), scope);
|
|
168
|
+
if (skill)
|
|
169
|
+
out.push(skill);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// If root itself contains SKILL.md
|
|
173
|
+
const self = join(root, "SKILL.md");
|
|
174
|
+
if (existsSync(self)) {
|
|
175
|
+
const skill = loadSkillFile(self, scope);
|
|
85
176
|
if (skill)
|
|
86
177
|
out.push(skill);
|
|
87
178
|
}
|
|
88
179
|
return out;
|
|
89
180
|
}
|
|
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
181
|
export function discoverSkills(workdir) {
|
|
96
|
-
const global = scanRoot(globalSkillsRoot(), "global");
|
|
97
|
-
const project = scanRoot(join(workdir, ".scoder", "skills"), "project");
|
|
98
182
|
const byName = new Map();
|
|
99
|
-
for (const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
183
|
+
for (const root of [...globalSkillsRoots(), ...projectSkillsRoots(workdir)]) {
|
|
184
|
+
const relRoot = relative(resolve(workdir), resolve(root));
|
|
185
|
+
const scope = !relRoot.startsWith("..") && !isAbsolute(relRoot) ? "project" : "global";
|
|
186
|
+
for (const s of scanRoot(root, scope)) {
|
|
187
|
+
if (byName.has(s.name)) {
|
|
188
|
+
skillWarnings.push({ path: s.path, message: `skill "${s.name}" trùng tên (${s.scope} overrides ${byName.get(s.name)?.scope})` });
|
|
189
|
+
}
|
|
190
|
+
byName.set(s.name, s);
|
|
103
191
|
}
|
|
104
|
-
byName.set(s.name, s); // project wins
|
|
105
192
|
}
|
|
106
193
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
107
194
|
}
|
|
108
|
-
/** Find one skill by (case-insensitive) name, or null. */
|
|
109
195
|
export function findSkill(workdir, name) {
|
|
110
196
|
const wanted = name.toLowerCase();
|
|
111
197
|
return discoverSkills(workdir).find((s) => s.name.toLowerCase() === wanted) ?? null;
|
|
112
198
|
}
|
|
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
199
|
export function formatSkillIndex(skills) {
|
|
119
200
|
if (skills.length === 0)
|
|
120
201
|
return "";
|
|
121
|
-
return skills.map((s) =>
|
|
202
|
+
return skills.map((s) => {
|
|
203
|
+
const tags = s.tags?.length ? ` [${s.tags.join(",")}]` : "";
|
|
204
|
+
return `- ${s.name}: ${s.description || "(no description)"}${tags}`;
|
|
205
|
+
}).join("\n");
|
|
206
|
+
}
|
|
207
|
+
/** Progressive disclosure: read a reference file's content. */
|
|
208
|
+
export function readSkillReference(skill, ref) {
|
|
209
|
+
if (!skill.dir)
|
|
210
|
+
return null;
|
|
211
|
+
const candidates = [join(skill.dir, ref), join(skill.dir, "references", ref)];
|
|
212
|
+
for (const c of candidates) {
|
|
213
|
+
try {
|
|
214
|
+
const root = resolve(skill.dir);
|
|
215
|
+
const abs = resolve(c);
|
|
216
|
+
const rel = relative(root, abs);
|
|
217
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
218
|
+
continue;
|
|
219
|
+
if (existsSync(abs))
|
|
220
|
+
return readFileSync(abs, "utf8");
|
|
221
|
+
}
|
|
222
|
+
catch { /* ignore */ }
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/** Load a section of a skill body by heading name (case-insensitive). */
|
|
227
|
+
export function loadSkillSection(skill, section) {
|
|
228
|
+
if (!section)
|
|
229
|
+
return skill.body;
|
|
230
|
+
const lines = skill.body.split(/\r?\n/);
|
|
231
|
+
const target = section.toLowerCase().trim();
|
|
232
|
+
let collecting = false;
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
const hm = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
236
|
+
if (hm) {
|
|
237
|
+
if (collecting)
|
|
238
|
+
break;
|
|
239
|
+
if (hm[2].toLowerCase().trim() === target) {
|
|
240
|
+
collecting = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (collecting)
|
|
246
|
+
out.push(line);
|
|
247
|
+
}
|
|
248
|
+
return collecting ? out.join("\n").trim() : skill.body;
|
|
249
|
+
}
|
|
250
|
+
/** Auto-match scoring: rank skills by keyword overlap with task text. */
|
|
251
|
+
export function scoreSkills(task, skills) {
|
|
252
|
+
const tokens = new Set(task.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2));
|
|
253
|
+
return skills.map((s) => {
|
|
254
|
+
const haystack = `${s.name} ${s.description} ${s.whenToUse ?? ""} ${(s.tags ?? []).join(" ")}`.toLowerCase();
|
|
255
|
+
let score = 0;
|
|
256
|
+
for (const t of tokens)
|
|
257
|
+
if (haystack.includes(t))
|
|
258
|
+
score += 1;
|
|
259
|
+
// name match bonus
|
|
260
|
+
if (task.toLowerCase().includes(s.name.toLowerCase()))
|
|
261
|
+
score += 3;
|
|
262
|
+
return { skill: s, score };
|
|
263
|
+
}).sort((a, b) => b.score - a.score);
|
|
264
|
+
}
|
|
265
|
+
/** Filter tool specs by an allowed-tools list (glob-ish names). */
|
|
266
|
+
export function filterToolsByAllowed(specs, allowed) {
|
|
267
|
+
if (!allowed || allowed.length === 0)
|
|
268
|
+
return specs;
|
|
269
|
+
const set = new Set(allowed.map((a) => a.toLowerCase()));
|
|
270
|
+
return specs.filter((s) => set.has(String(s.name ?? s.function?.name ?? "").toLowerCase()));
|
|
122
271
|
}
|
package/dist/snapshot.js
CHANGED
|
@@ -159,6 +159,11 @@ export class SnapshotManager {
|
|
|
159
159
|
else if (!treeFiles.has(r))
|
|
160
160
|
rmSync(abs, { force: true });
|
|
161
161
|
}
|
|
162
|
+
try {
|
|
163
|
+
if (dir !== this.workdir && readdirSync(dir).length === 0)
|
|
164
|
+
rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
catch { /* best effort */ }
|
|
162
167
|
};
|
|
163
168
|
walk(this.workdir, "");
|
|
164
169
|
return true;
|
package/dist/steering.js
CHANGED
|
@@ -29,6 +29,10 @@ export class SteeringInbox {
|
|
|
29
29
|
get size() {
|
|
30
30
|
return this.items.length;
|
|
31
31
|
}
|
|
32
|
+
/** Breakdown for the `queue` event: total pending vs steer-mode count. */
|
|
33
|
+
counts() {
|
|
34
|
+
return { pending: this.items.length, steering: this.items.filter((x) => x.mode === "steer").length };
|
|
35
|
+
}
|
|
32
36
|
clear() {
|
|
33
37
|
this.items = [];
|
|
34
38
|
}
|