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.
- package/README.md +124 -299
- package/dist/core/agent-config.d.ts +1 -1
- package/dist/core/gateway-tools.js +4 -4
- package/dist/core/mcpplug.d.ts +23 -0
- package/dist/core/mcpplug.js +64 -0
- package/dist/core/run-agent.js +2 -2
- package/dist/core/session-store.d.ts +1 -1
- package/dist/core/session-store.js +1 -1
- package/dist/core/sessionknit.d.ts +87 -0
- package/dist/core/sessionknit.js +189 -0
- package/dist/core/skillgarden/activation.d.ts +4 -0
- package/dist/core/skillgarden/activation.js +16 -0
- package/dist/core/skillgarden/budget.d.ts +20 -0
- package/dist/core/skillgarden/budget.js +28 -0
- package/dist/core/skillgarden/discovery.d.ts +11 -0
- package/dist/core/skillgarden/discovery.js +40 -0
- package/dist/core/skillgarden/frontmatter.d.ts +10 -0
- package/dist/core/skillgarden/frontmatter.js +35 -0
- package/dist/core/skillgarden/index.d.ts +8 -0
- package/dist/core/skillgarden/index.js +12 -0
- package/dist/core/skillgarden/loader.d.ts +31 -0
- package/dist/core/skillgarden/loader.js +70 -0
- package/dist/core/skillgarden/substitute.d.ts +4 -0
- package/dist/core/skillgarden/substitute.js +13 -0
- package/dist/core/skillgarden/types.d.ts +23 -0
- package/dist/core/skillgarden/types.js +1 -0
- package/dist/core/toollane.d.ts +46 -0
- package/dist/core/toollane.js +69 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +4 -1
- package/dist/web/agents-config-page.js +1 -197
- package/dist/web/skills-admin.js +1 -1
- package/package.json +8 -5
- package/dist/web/skillgarden-admin.d.ts +0 -24
- 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 {};
|