loopengine 0.1.15 → 0.1.22

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.
Files changed (35) hide show
  1. package/README.md +124 -299
  2. package/dist/core/agent-config.d.ts +1 -1
  3. package/dist/core/gateway-tools.js +4 -4
  4. package/dist/core/mcpplug.d.ts +23 -0
  5. package/dist/core/mcpplug.js +64 -0
  6. package/dist/core/run-agent.js +2 -2
  7. package/dist/core/session-store.d.ts +1 -1
  8. package/dist/core/session-store.js +1 -1
  9. package/dist/core/sessionknit.d.ts +87 -0
  10. package/dist/core/sessionknit.js +189 -0
  11. package/dist/core/skillgarden/activation.d.ts +4 -0
  12. package/dist/core/skillgarden/activation.js +16 -0
  13. package/dist/core/skillgarden/budget.d.ts +20 -0
  14. package/dist/core/skillgarden/budget.js +28 -0
  15. package/dist/core/skillgarden/discovery.d.ts +11 -0
  16. package/dist/core/skillgarden/discovery.js +40 -0
  17. package/dist/core/skillgarden/frontmatter.d.ts +10 -0
  18. package/dist/core/skillgarden/frontmatter.js +35 -0
  19. package/dist/core/skillgarden/index.d.ts +8 -0
  20. package/dist/core/skillgarden/index.js +12 -0
  21. package/dist/core/skillgarden/loader.d.ts +31 -0
  22. package/dist/core/skillgarden/loader.js +70 -0
  23. package/dist/core/skillgarden/substitute.d.ts +4 -0
  24. package/dist/core/skillgarden/substitute.js +13 -0
  25. package/dist/core/skillgarden/types.d.ts +23 -0
  26. package/dist/core/skillgarden/types.js +1 -0
  27. package/dist/core/toollane.d.ts +46 -0
  28. package/dist/core/toollane.js +69 -0
  29. package/dist/index.d.ts +8 -2
  30. package/dist/index.js +4 -1
  31. package/dist/web/agents-config-page.js +1 -197
  32. package/dist/web/skills-admin.js +1 -1
  33. package/package.json +8 -5
  34. package/dist/web/skillgarden-admin.d.ts +0 -24
  35. package/dist/web/skillgarden-admin.js +0 -76
@@ -0,0 +1,87 @@
1
+ /** One node in the session's parent-linked tree. Multiple entries can
2
+ * share the same parentId — that's exactly what a turn with parallel
3
+ * tool calls produces (siblings), not an error case. */
4
+ export interface SessionEntry<TMessage> {
5
+ id: string;
6
+ parentId: string | null;
7
+ message: TMessage;
8
+ }
9
+ export interface ResumeResult<TMessage> {
10
+ messages: TMessage[];
11
+ /** True if the session ended mid-turn (an unresolved tool call with
12
+ * no result) rather than cleanly. */
13
+ resumedAfterInterruption: boolean;
14
+ /** id of the last *durably stored* entry in the reconstructed chain —
15
+ * null for a brand-new session. Excludes any synthetic continuation
16
+ * (that message was never appended), so a caller resuming a session and
17
+ * then appending new entries should parent the first one on this, not
18
+ * on the last entry of `messages`. */
19
+ leafId: string | null;
20
+ }
21
+ export interface SessionKnitOptions<TMessage> {
22
+ /** Does this message contain a tool call with no result yet? Used to
23
+ * detect a session that ended mid-turn. No default — a format SessionKnit
24
+ * doesn't recognize is treated as clean rather than guessed at. */
25
+ hasUnresolvedToolCall?: (message: TMessage) => boolean;
26
+ /** Given the unresolved message, build a synthetic continuation to
27
+ * append so the reconstructed chain is valid to resend to a model. */
28
+ buildContinuation?: (message: TMessage) => TMessage;
29
+ }
30
+ export interface Storage<TMessage> {
31
+ append(sessionId: string, entry: SessionEntry<TMessage>): Promise<void>;
32
+ /** Force any pending writes to be durable. */
33
+ flush(sessionId: string): Promise<void>;
34
+ readAll(sessionId: string): Promise<SessionEntry<TMessage>[]>;
35
+ }
36
+ /** Append-only JSONL storage with a debounced, batched write-behind
37
+ * queue — append() never blocks the caller. flush() (called
38
+ * automatically before every resume) forces pending lines to disk. */
39
+ export declare class FileStorage<TMessage> implements Storage<TMessage> {
40
+ private readonly dir;
41
+ private readonly debounceMs;
42
+ private readonly pending;
43
+ private readonly timers;
44
+ constructor(dir: string, debounceMs?: number);
45
+ private pathFor;
46
+ append(sessionId: string, entry: SessionEntry<TMessage>): Promise<void>;
47
+ flush(sessionId: string): Promise<void>;
48
+ private drain;
49
+ readAll(sessionId: string): Promise<SessionEntry<TMessage>[]>;
50
+ }
51
+ /** Same debounce/flush contract as FileStorage, backed by memory instead
52
+ * of disk — useful for tests, and a second real example proving the
53
+ * Storage interface is genuinely swappable for a host that wants a
54
+ * database or S3 backend instead. */
55
+ export declare class MemoryStorage<TMessage> implements Storage<TMessage> {
56
+ private readonly debounceMs;
57
+ private readonly committed;
58
+ private readonly pending;
59
+ private readonly timers;
60
+ constructor(debounceMs?: number);
61
+ append(sessionId: string, entry: SessionEntry<TMessage>): Promise<void>;
62
+ flush(sessionId: string): Promise<void>;
63
+ private drain;
64
+ readAll(sessionId: string): Promise<SessionEntry<TMessage>[]>;
65
+ }
66
+ export interface ReconstructResult<TMessage> {
67
+ messages: TMessage[];
68
+ entries: SessionEntry<TMessage>[];
69
+ }
70
+ /** Pure reconstruction — no I/O, so it's testable without a storage
71
+ * backend. Walks parentId from the target leaf back to the root, then
72
+ * runs topology repair: at each node on that path, any *other* children
73
+ * (siblings of the node that continues the path) are reattached too —
74
+ * exactly what a turn with parallel tool calls produces, and exactly
75
+ * what a naive single-parent walk would silently drop. */
76
+ export declare function reconstructChain<TMessage>(entries: SessionEntry<TMessage>[], leafId?: string): ReconstructResult<TMessage>;
77
+ /** Durable, parent-linked session persistence: append never blocks,
78
+ * resume runs topology repair for parallel tool-call siblings, and
79
+ * detects a session that ended mid-turn rather than cleanly. */
80
+ export declare class SessionKnit<TMessage> {
81
+ private readonly storage;
82
+ private readonly options;
83
+ constructor(storage: Storage<TMessage>, options?: SessionKnitOptions<TMessage>);
84
+ append(sessionId: string, entry: SessionEntry<TMessage>): Promise<void>;
85
+ flush(sessionId: string): Promise<void>;
86
+ resume(sessionId: string, leafId?: string): Promise<ResumeResult<TMessage>>;
87
+ }
@@ -0,0 +1,189 @@
1
+ // Formerly the standalone `sessionknit` package — folded in here for the
2
+ // same reason mcpplug.ts and toollane.ts were: no consumer besides
3
+ // loopengine itself (core/session-store.ts) ever actually materialized,
4
+ // so the separate-package/portable-to-any-host framing was aspirational
5
+ // rather than realized. Durable, parent-linked session persistence:
6
+ // append never blocks, resume runs topology repair for parallel
7
+ // tool-call siblings, and detects a session that ended mid-turn rather
8
+ // than cleanly — see session-store.ts's own header comment for why this
9
+ // matters for a real agent loop specifically.
10
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
11
+ import { dirname, join } from 'node:path';
12
+ /** Append-only JSONL storage with a debounced, batched write-behind
13
+ * queue — append() never blocks the caller. flush() (called
14
+ * automatically before every resume) forces pending lines to disk. */
15
+ export class FileStorage {
16
+ dir;
17
+ debounceMs;
18
+ pending = new Map();
19
+ timers = new Map();
20
+ constructor(dir, debounceMs = 100) {
21
+ this.dir = dir;
22
+ this.debounceMs = debounceMs;
23
+ }
24
+ pathFor(sessionId) {
25
+ // sessionId is caller-controlled and often comes straight from
26
+ // untrusted input (a CLI flag, an HTTP body) — strip path separators so
27
+ // it can't escape `dir` via `../` traversal.
28
+ const safeId = sessionId.replace(/[/\\]/g, '_');
29
+ return join(this.dir, `${safeId}.jsonl`);
30
+ }
31
+ async append(sessionId, entry) {
32
+ const lines = this.pending.get(sessionId) ?? [];
33
+ lines.push(JSON.stringify(entry));
34
+ this.pending.set(sessionId, lines);
35
+ if (!this.timers.has(sessionId)) {
36
+ const timer = setTimeout(() => {
37
+ this.timers.delete(sessionId);
38
+ void this.drain(sessionId);
39
+ }, this.debounceMs);
40
+ this.timers.set(sessionId, timer);
41
+ }
42
+ }
43
+ async flush(sessionId) {
44
+ const timer = this.timers.get(sessionId);
45
+ if (timer) {
46
+ clearTimeout(timer);
47
+ this.timers.delete(sessionId);
48
+ }
49
+ await this.drain(sessionId);
50
+ }
51
+ async drain(sessionId) {
52
+ const lines = this.pending.get(sessionId);
53
+ if (!lines || lines.length === 0)
54
+ return;
55
+ this.pending.delete(sessionId);
56
+ const path = this.pathFor(sessionId);
57
+ await mkdir(dirname(path), { recursive: true });
58
+ await appendFile(path, `${lines.join('\n')}\n`);
59
+ }
60
+ async readAll(sessionId) {
61
+ try {
62
+ const content = await readFile(this.pathFor(sessionId), 'utf8');
63
+ return content
64
+ .split('\n')
65
+ .filter((line) => line.length > 0)
66
+ .map((line) => JSON.parse(line));
67
+ }
68
+ catch (error) {
69
+ if (error.code === 'ENOENT')
70
+ return [];
71
+ throw error;
72
+ }
73
+ }
74
+ }
75
+ /** Same debounce/flush contract as FileStorage, backed by memory instead
76
+ * of disk — useful for tests, and a second real example proving the
77
+ * Storage interface is genuinely swappable for a host that wants a
78
+ * database or S3 backend instead. */
79
+ export class MemoryStorage {
80
+ debounceMs;
81
+ committed = new Map();
82
+ pending = new Map();
83
+ timers = new Map();
84
+ constructor(debounceMs = 100) {
85
+ this.debounceMs = debounceMs;
86
+ }
87
+ async append(sessionId, entry) {
88
+ const items = this.pending.get(sessionId) ?? [];
89
+ items.push(entry);
90
+ this.pending.set(sessionId, items);
91
+ if (!this.timers.has(sessionId)) {
92
+ const timer = setTimeout(() => {
93
+ this.timers.delete(sessionId);
94
+ this.drain(sessionId);
95
+ }, this.debounceMs);
96
+ this.timers.set(sessionId, timer);
97
+ }
98
+ }
99
+ async flush(sessionId) {
100
+ const timer = this.timers.get(sessionId);
101
+ if (timer) {
102
+ clearTimeout(timer);
103
+ this.timers.delete(sessionId);
104
+ }
105
+ this.drain(sessionId);
106
+ }
107
+ drain(sessionId) {
108
+ const items = this.pending.get(sessionId);
109
+ if (!items || items.length === 0)
110
+ return;
111
+ this.pending.delete(sessionId);
112
+ const existing = this.committed.get(sessionId) ?? [];
113
+ this.committed.set(sessionId, [...existing, ...items]);
114
+ }
115
+ async readAll(sessionId) {
116
+ return this.committed.get(sessionId) ?? [];
117
+ }
118
+ }
119
+ /** Pure reconstruction — no I/O, so it's testable without a storage
120
+ * backend. Walks parentId from the target leaf back to the root, then
121
+ * runs topology repair: at each node on that path, any *other* children
122
+ * (siblings of the node that continues the path) are reattached too —
123
+ * exactly what a turn with parallel tool calls produces, and exactly
124
+ * what a naive single-parent walk would silently drop. */
125
+ export function reconstructChain(entries, leafId) {
126
+ if (entries.length === 0)
127
+ return { messages: [], entries: [] };
128
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
129
+ const childrenOf = new Map();
130
+ for (const entry of entries) {
131
+ const list = childrenOf.get(entry.parentId) ?? [];
132
+ list.push(entry);
133
+ childrenOf.set(entry.parentId, list);
134
+ }
135
+ const target = leafId !== undefined ? byId.get(leafId) : entries[entries.length - 1];
136
+ if (!target) {
137
+ throw new Error(`Unknown entry '${leafId}'`);
138
+ }
139
+ // Walk parentId back to the root, collecting root-to-leaf order.
140
+ const path = [];
141
+ let current = target;
142
+ while (current) {
143
+ path.unshift(current);
144
+ current = current.parentId !== null ? byId.get(current.parentId) : undefined;
145
+ }
146
+ // Topology repair.
147
+ const repaired = [];
148
+ for (let i = 0; i < path.length; i++) {
149
+ const node = path[i];
150
+ repaired.push(node);
151
+ const nextOnPath = path[i + 1]?.id;
152
+ const siblings = childrenOf.get(node.id) ?? [];
153
+ for (const sibling of siblings) {
154
+ if (sibling.id !== nextOnPath) {
155
+ repaired.push(sibling);
156
+ }
157
+ }
158
+ }
159
+ return { messages: repaired.map((entry) => entry.message), entries: repaired };
160
+ }
161
+ /** Durable, parent-linked session persistence: append never blocks,
162
+ * resume runs topology repair for parallel tool-call siblings, and
163
+ * detects a session that ended mid-turn rather than cleanly. */
164
+ export class SessionKnit {
165
+ storage;
166
+ options;
167
+ constructor(storage, options = {}) {
168
+ this.storage = storage;
169
+ this.options = options;
170
+ }
171
+ async append(sessionId, entry) {
172
+ await this.storage.append(sessionId, entry);
173
+ }
174
+ async flush(sessionId) {
175
+ await this.storage.flush(sessionId);
176
+ }
177
+ async resume(sessionId, leafId) {
178
+ await this.storage.flush(sessionId);
179
+ const entries = await this.storage.readAll(sessionId);
180
+ const { messages, entries: repaired } = reconstructChain(entries, leafId);
181
+ const lastEntry = repaired[repaired.length - 1];
182
+ const interrupted = lastEntry !== undefined && (this.options.hasUnresolvedToolCall?.(lastEntry.message) ?? false);
183
+ let finalMessages = messages;
184
+ if (interrupted && this.options.buildContinuation) {
185
+ finalMessages = [...messages, this.options.buildContinuation(lastEntry.message)];
186
+ }
187
+ return { messages: finalMessages, resumedAfterInterruption: interrupted, leafId: lastEntry?.id ?? null };
188
+ }
189
+ }
@@ -0,0 +1,4 @@
1
+ /** A skill with no `paths` restriction is always eligible. One with a
2
+ * restriction only activates once a touched file matches one of its
3
+ * glob patterns — mirrors Claude Code's conditional-activation skills. */
4
+ export declare function matchesActivationPaths(paths: string[] | undefined, touchedPath: string): boolean;
@@ -0,0 +1,16 @@
1
+ /** Same matching semantics used in ActAuth's scope patterns: '*' matches
2
+ * any run of characters, '?' matches exactly one. Kept independent of any
3
+ * glob library on purpose — this is the whole matcher this module needs. */
4
+ function globToRegExp(pattern) {
5
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
6
+ const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
7
+ return new RegExp(`^${withWildcards}$`);
8
+ }
9
+ /** A skill with no `paths` restriction is always eligible. One with a
10
+ * restriction only activates once a touched file matches one of its
11
+ * glob patterns — mirrors Claude Code's conditional-activation skills. */
12
+ export function matchesActivationPaths(paths, touchedPath) {
13
+ if (!paths || paths.length === 0)
14
+ return true;
15
+ return paths.some((pattern) => globToRegExp(pattern).test(touchedPath));
16
+ }
@@ -0,0 +1,20 @@
1
+ import type { SkillIndexEntry } from './types.js';
2
+ /** ~4 chars/token — a common rough heuristic, good enough for budgeting
3
+ * an index of short description lines. Deliberately its own copy, not a
4
+ * shared import from core/budget.ts's own estimateTokens — that one
5
+ * budgets a model's full conversation context; this one budgets a
6
+ * skill-index listing, a different unit at a different layer, even
7
+ * though the heuristic they both land on happens to be identical. */
8
+ export declare function estimateTokens(text: string): number;
9
+ export interface BuildIndexOptions {
10
+ maxTokens: number;
11
+ }
12
+ export interface BudgetedIndex {
13
+ included: SkillIndexEntry[];
14
+ truncated: SkillIndexEntry[];
15
+ }
16
+ /** Fills the index in order until the token budget is spent, then drops
17
+ * the rest. Order matters — callers that want priority (e.g. skills
18
+ * matching recently-touched files first) should sort entries before
19
+ * calling this. */
20
+ export declare function buildBudgetedIndex(entries: SkillIndexEntry[], options: BuildIndexOptions): BudgetedIndex;
@@ -0,0 +1,28 @@
1
+ /** ~4 chars/token — a common rough heuristic, good enough for budgeting
2
+ * an index of short description lines. Deliberately its own copy, not a
3
+ * shared import from core/budget.ts's own estimateTokens — that one
4
+ * budgets a model's full conversation context; this one budgets a
5
+ * skill-index listing, a different unit at a different layer, even
6
+ * though the heuristic they both land on happens to be identical. */
7
+ export function estimateTokens(text) {
8
+ return Math.ceil(text.length / 4);
9
+ }
10
+ /** Fills the index in order until the token budget is spent, then drops
11
+ * the rest. Order matters — callers that want priority (e.g. skills
12
+ * matching recently-touched files first) should sort entries before
13
+ * calling this. */
14
+ export function buildBudgetedIndex(entries, options) {
15
+ const included = [];
16
+ const truncated = [];
17
+ let used = 0;
18
+ for (const entry of entries) {
19
+ if (used + entry.estimatedTokens <= options.maxTokens) {
20
+ included.push(entry);
21
+ used += entry.estimatedTokens;
22
+ }
23
+ else {
24
+ truncated.push(entry);
25
+ }
26
+ }
27
+ return { included, truncated };
28
+ }
@@ -0,0 +1,11 @@
1
+ export interface DiscoveredSkillFile {
2
+ /** Namespaced name — nested dirs join with ':', e.g. "deploy:web". */
3
+ namespacedName: string;
4
+ filePath: string;
5
+ }
6
+ /** Walks a root directory for nested `SKILL.md` files. A directory containing
7
+ * SKILL.md is treated as one skill and not walked further — supporting
8
+ * files live alongside it, not nested skills. Nesting depth becomes the
9
+ * namespace: `deploy/web/SKILL.md` under root `skills/` becomes
10
+ * `deploy:web`, matching Claude Code's own namespacing convention. */
11
+ export declare function discoverSkillFiles(rootDir: string): DiscoveredSkillFile[];
@@ -0,0 +1,40 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
3
+ /** Walks a root directory for nested `SKILL.md` files. A directory containing
4
+ * SKILL.md is treated as one skill and not walked further — supporting
5
+ * files live alongside it, not nested skills. Nesting depth becomes the
6
+ * namespace: `deploy/web/SKILL.md` under root `skills/` becomes
7
+ * `deploy:web`, matching Claude Code's own namespacing convention. */
8
+ export function discoverSkillFiles(rootDir) {
9
+ const results = [];
10
+ function walk(dir) {
11
+ let entries;
12
+ try {
13
+ entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
14
+ }
15
+ catch {
16
+ return;
17
+ }
18
+ for (const entry of entries) {
19
+ if (!entry.isDirectory())
20
+ continue;
21
+ const full = join(dir, entry.name);
22
+ const skillFile = join(full, 'SKILL.md');
23
+ let hasSkillFile = false;
24
+ try {
25
+ hasSkillFile = statSync(skillFile).isFile();
26
+ }
27
+ catch {
28
+ hasSkillFile = false;
29
+ }
30
+ if (hasSkillFile) {
31
+ const namespacedName = relative(rootDir, full).split(sep).join(':');
32
+ results.push({ namespacedName, filePath: skillFile });
33
+ continue;
34
+ }
35
+ walk(full);
36
+ }
37
+ }
38
+ walk(rootDir);
39
+ return results;
40
+ }
@@ -0,0 +1,10 @@
1
+ import type { SkillFrontmatter } from './types.js';
2
+ /** Parses a SKILL.md file. Frontmatter is optional and malformed YAML
3
+ * doesn't throw — a bad skill file should never break discovery of every
4
+ * other skill. Missing `name`/`description` fall back to the directory
5
+ * name and the first markdown heading, matching how authors actually
6
+ * write these files in practice. */
7
+ export declare function parseSkillFile(raw: string, fallbackName: string): {
8
+ frontmatter: SkillFrontmatter;
9
+ body: string;
10
+ };
@@ -0,0 +1,35 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
3
+ /** Parses a SKILL.md file. Frontmatter is optional and malformed YAML
4
+ * doesn't throw — a bad skill file should never break discovery of every
5
+ * other skill. Missing `name`/`description` fall back to the directory
6
+ * name and the first markdown heading, matching how authors actually
7
+ * write these files in practice. */
8
+ export function parseSkillFile(raw, fallbackName) {
9
+ const match = raw.match(FRONTMATTER_RE);
10
+ let frontmatterRaw = '';
11
+ let body = raw;
12
+ if (match) {
13
+ frontmatterRaw = match[1] ?? '';
14
+ body = match[2] ?? '';
15
+ }
16
+ let parsed = {};
17
+ if (frontmatterRaw.trim()) {
18
+ try {
19
+ parsed = parseYaml(frontmatterRaw) ?? {};
20
+ }
21
+ catch {
22
+ parsed = {};
23
+ }
24
+ }
25
+ const name = typeof parsed.name === 'string' ? parsed.name : fallbackName;
26
+ const description = typeof parsed.description === 'string' ? parsed.description : (extractFirstHeading(body) ?? name);
27
+ return {
28
+ frontmatter: { ...parsed, name, description },
29
+ body: body.trim(),
30
+ };
31
+ }
32
+ function extractFirstHeading(markdown) {
33
+ const match = markdown.match(/^#\s+(.+)$/m);
34
+ return match?.[1]?.trim();
35
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types.js';
2
+ export * from './frontmatter.js';
3
+ export * from './discovery.js';
4
+ export * from './budget.js';
5
+ export * from './substitute.js';
6
+ export * from './activation.js';
7
+ export { SkillGarden } from './loader.js';
8
+ export type { SkillGardenOptions } from './loader.js';
@@ -0,0 +1,12 @@
1
+ // Formerly the standalone `skillgarden` package — folded into loopengine
2
+ // core since it had no consumer besides loopengine itself.
3
+ // `SKILL.md` discovery/loading/budgeting: a lightweight name+description
4
+ // index loads at startup capped to a token budget, and the full body
5
+ // loads lazily only when a skill is actually invoked.
6
+ export * from './types.js';
7
+ export * from './frontmatter.js';
8
+ export * from './discovery.js';
9
+ export * from './budget.js';
10
+ export * from './substitute.js';
11
+ export * from './activation.js';
12
+ export { SkillGarden } from './loader.js';
@@ -0,0 +1,31 @@
1
+ import { type BudgetedIndex } from './budget.js';
2
+ import type { LoadedSkill, SkillIndexEntry } from './types.js';
3
+ export interface SkillGardenOptions {
4
+ /** Roots to scan for nested `SKILL.md` files. */
5
+ dirs: string[];
6
+ /** Token budget for the always-loaded name+description index. Real
7
+ * agent context windows are large (~100k-200k tokens); Claude Code
8
+ * itself caps this index around 1% of the window, so ~2000 is a
9
+ * reasonable default rather than an arbitrary round number. */
10
+ indexBudgetTokens?: number;
11
+ }
12
+ /** Phase 1: scan + parse frontmatter only, budget-capped — this is what
13
+ * stays loaded in context at all times. Phase 2 (`load`/`invoke`) reads
14
+ * the full body lazily, only for a skill actually being invoked. */
15
+ export declare class SkillGarden {
16
+ private readonly dirs;
17
+ private readonly indexBudgetTokens;
18
+ private indexed;
19
+ constructor(options: SkillGardenOptions);
20
+ buildIndex(): BudgetedIndex;
21
+ /** Full body, read only now — call after buildIndex() has discovered
22
+ * the skill. */
23
+ load(name: string): LoadedSkill;
24
+ /** Load + substitute placeholders, ready to inject into a running
25
+ * conversation. */
26
+ invoke(name: string, args?: string): string;
27
+ /** Filters index entries down to skills eligible given files touched
28
+ * so far in the session. Skills with no `paths` restriction are always
29
+ * eligible. */
30
+ eligibleFor(entries: SkillIndexEntry[], touchedPaths: string[]): SkillIndexEntry[];
31
+ }
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { matchesActivationPaths } from './activation.js';
3
+ import { buildBudgetedIndex, estimateTokens } from './budget.js';
4
+ import { discoverSkillFiles } from './discovery.js';
5
+ import { parseSkillFile } from './frontmatter.js';
6
+ import { substituteArguments } from './substitute.js';
7
+ /** Phase 1: scan + parse frontmatter only, budget-capped — this is what
8
+ * stays loaded in context at all times. Phase 2 (`load`/`invoke`) reads
9
+ * the full body lazily, only for a skill actually being invoked. */
10
+ export class SkillGarden {
11
+ dirs;
12
+ indexBudgetTokens;
13
+ indexed = new Map();
14
+ constructor(options) {
15
+ this.dirs = options.dirs;
16
+ this.indexBudgetTokens = options.indexBudgetTokens ?? 2000;
17
+ }
18
+ buildIndex() {
19
+ this.indexed.clear();
20
+ const entries = [];
21
+ for (const dir of this.dirs) {
22
+ for (const { namespacedName, filePath } of discoverSkillFiles(dir)) {
23
+ const raw = readFileSync(filePath, 'utf8');
24
+ const { frontmatter } = parseSkillFile(raw, namespacedName);
25
+ this.indexed.set(namespacedName, { filePath, frontmatter });
26
+ entries.push({
27
+ name: namespacedName,
28
+ description: String(frontmatter.description),
29
+ filePath,
30
+ estimatedTokens: estimateTokens(`${namespacedName}: ${frontmatter.description}`),
31
+ });
32
+ }
33
+ }
34
+ return buildBudgetedIndex(entries, { maxTokens: this.indexBudgetTokens });
35
+ }
36
+ /** Full body, read only now — call after buildIndex() has discovered
37
+ * the skill. */
38
+ load(name) {
39
+ const indexed = this.indexed.get(name);
40
+ if (!indexed) {
41
+ throw new Error(`Unknown skill '${name}' — call buildIndex() first, or it wasn't discovered`);
42
+ }
43
+ const raw = readFileSync(indexed.filePath, 'utf8');
44
+ const { frontmatter, body } = parseSkillFile(raw, name);
45
+ return {
46
+ name,
47
+ description: String(frontmatter.description),
48
+ body,
49
+ frontmatter,
50
+ filePath: indexed.filePath,
51
+ };
52
+ }
53
+ /** Load + substitute placeholders, ready to inject into a running
54
+ * conversation. */
55
+ invoke(name, args) {
56
+ const skill = this.load(name);
57
+ return substituteArguments(skill.body, args);
58
+ }
59
+ /** Filters index entries down to skills eligible given files touched
60
+ * so far in the session. Skills with no `paths` restriction are always
61
+ * eligible. */
62
+ eligibleFor(entries, touchedPaths) {
63
+ return entries.filter((entry) => {
64
+ const paths = this.indexed.get(entry.name)?.frontmatter.paths;
65
+ if (!paths || paths.length === 0)
66
+ return true;
67
+ return touchedPaths.some((touched) => matchesActivationPaths(paths, touched));
68
+ });
69
+ }
70
+ }
@@ -0,0 +1,4 @@
1
+ /** Replaces `$ARGUMENTS` with the raw args string, and `$1`/`$2`/... with
2
+ * whitespace-split positional args — the same placeholder convention
3
+ * skill authors already expect from Claude Code's own Skill tool. */
4
+ export declare function substituteArguments(body: string, args?: string): string;
@@ -0,0 +1,13 @@
1
+ /** Replaces `$ARGUMENTS` with the raw args string, and `$1`/`$2`/... with
2
+ * whitespace-split positional args — the same placeholder convention
3
+ * skill authors already expect from Claude Code's own Skill tool. */
4
+ export function substituteArguments(body, args) {
5
+ const argString = args ?? '';
6
+ let result = body.replaceAll('$ARGUMENTS', argString);
7
+ const positional = argString.split(/\s+/).filter(Boolean);
8
+ result = result.replace(/\$(\d+)/g, (_match, indexStr) => {
9
+ const index = Number(indexStr);
10
+ return positional[index - 1] ?? '';
11
+ });
12
+ return result;
13
+ }
@@ -0,0 +1,23 @@
1
+ /** Deliberately loose — real skill authors' frontmatter always has fields
2
+ * we haven't modeled yet, so the parser must not reject unknown keys. */
3
+ export interface SkillFrontmatter {
4
+ name: string;
5
+ description: string;
6
+ paths?: string[];
7
+ [key: string]: unknown;
8
+ }
9
+ export interface SkillIndexEntry {
10
+ /** Namespaced name — nested dirs join with ':', e.g. "deploy:web". */
11
+ name: string;
12
+ description: string;
13
+ filePath: string;
14
+ estimatedTokens: number;
15
+ }
16
+ export interface LoadedSkill {
17
+ name: string;
18
+ description: string;
19
+ /** Markdown body with the frontmatter block stripped. */
20
+ body: string;
21
+ frontmatter: SkillFrontmatter;
22
+ filePath: string;
23
+ }
@@ -0,0 +1 @@
1
+ export {};