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.
@@ -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
- for (const w of writes)
51
- args.push("--bind", w, w);
52
- if (profile === "readonly")
53
- args.pop(); // readOnly: bỏ rw workspace
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
- return sessionFromEventLog(workdir, id);
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
- const manifest = existsSync(manifestPath(workdir, id))
289
- ? JSON.parse(readFileSync(manifestPath(workdir, id), "utf8"))
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
- if (!existsSync(mf))
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
- * 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.
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
- /** Parse a minimal YAML-lite frontmatter (key: value lines). */
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
- for (const line of m[1].split(/\r?\n/)) {
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
- meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
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 { meta, body: m[2].trim() };
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; // 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();
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 (thêm description vào frontmatter)` });
91
+ skillWarnings.push({ path, message: `skill "${name}" thiếu description — bỏ qua` });
59
92
  return null;
60
93
  }
61
- const allowedToolsRaw = (meta["allowed-tools"] || "").trim();
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
- ...(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"] } : {}),
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
- /** Collect SKILL.md files from one skills root (one level of directories). */
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 (!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);
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 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})` });
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) => `- ${s.name}: ${s.description || "(no description)"}`).join("\n");
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
  }