@tanstack/ai-skills 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/esm/catalog.d.ts +5 -0
- package/dist/esm/catalog.js +18 -0
- package/dist/esm/catalog.js.map +1 -0
- package/dist/esm/combinators.d.ts +24 -0
- package/dist/esm/combinators.js +158 -0
- package/dist/esm/combinators.js.map +1 -0
- package/dist/esm/errors.d.ts +7 -0
- package/dist/esm/errors.js +2 -0
- package/dist/esm/index.d.ts +26 -0
- package/dist/esm/index.js +13 -0
- package/dist/esm/middleware.d.ts +44 -0
- package/dist/esm/middleware.js +133 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/node/index.d.ts +33 -0
- package/dist/esm/node/index.js +198 -0
- package/dist/esm/node/index.js.map +1 -0
- package/dist/esm/parse.d.ts +25 -0
- package/dist/esm/parse.js +155 -0
- package/dist/esm/parse.js.map +1 -0
- package/dist/esm/sources/inline.d.ts +9 -0
- package/dist/esm/sources/inline.js +48 -0
- package/dist/esm/sources/inline.js.map +1 -0
- package/dist/esm/static/index.d.ts +17 -0
- package/dist/esm/static/index.js +29 -0
- package/dist/esm/static/index.js.map +1 -0
- package/dist/esm/testing/index.d.ts +2 -0
- package/dist/esm/testing/index.js +78 -0
- package/dist/esm/testing/index.js.map +1 -0
- package/dist/esm/tools/load-skill.d.ts +11 -0
- package/dist/esm/tools/load-skill.js +67 -0
- package/dist/esm/tools/load-skill.js.map +1 -0
- package/dist/esm/tools/read-resource.d.ts +4 -0
- package/dist/esm/tools/read-resource.js +55 -0
- package/dist/esm/tools/read-resource.js.map +1 -0
- package/dist/esm/types.d.ts +70 -0
- package/dist/esm/types.js +13 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/util.d.ts +9 -0
- package/dist/esm/util.js +24 -0
- package/dist/esm/util.js.map +1 -0
- package/dist/esm/validate.d.ts +14 -0
- package/dist/esm/validate.js +33 -0
- package/dist/esm/validate.js.map +1 -0
- package/dist/esm/walk.d.ts +35 -0
- package/dist/esm/walk.js +49 -0
- package/dist/esm/walk.js.map +1 -0
- package/package.json +90 -1
- package/skills/ai-skills/SKILL.md +138 -0
- package/src/catalog.ts +44 -0
- package/src/combinators.ts +224 -0
- package/src/errors.ts +7 -0
- package/src/index.ts +49 -0
- package/src/middleware.ts +256 -0
- package/src/node/index.ts +281 -0
- package/src/parse.ts +247 -0
- package/src/sources/inline.ts +66 -0
- package/src/static/index.ts +64 -0
- package/src/testing/index.ts +92 -0
- package/src/tools/load-skill.ts +96 -0
- package/src/tools/read-resource.ts +64 -0
- package/src/types.ts +86 -0
- package/src/util.ts +28 -0
- package/src/validate.ts +63 -0
- package/src/walk.ts +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tanner Linsley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ModelFamily, SkillMetadata } from './types.js';
|
|
2
|
+
/** Sort skills into a stable, cache-friendly order. */
|
|
3
|
+
export declare function sortSkills(skills: Array<SkillMetadata>): Array<SkillMetadata>;
|
|
4
|
+
/** Render the skill catalog for a model family. Skills are sorted by name. */
|
|
5
|
+
export declare function renderCatalog(skills: Array<SkillMetadata>, family: ModelFamily): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/catalog.ts
|
|
2
|
+
/** Sort skills into a stable, cache-friendly order. */
|
|
3
|
+
function sortSkills(skills) {
|
|
4
|
+
return [...skills].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
5
|
+
}
|
|
6
|
+
function escapeXml(s) {
|
|
7
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
8
|
+
}
|
|
9
|
+
/** Render the skill catalog for a model family. Skills are sorted by name. */
|
|
10
|
+
function renderCatalog(skills, family) {
|
|
11
|
+
const sorted = sortSkills(skills);
|
|
12
|
+
if (family === "anthropic") return `<available_skills>\n${sorted.map((s) => ` <skill name="${escapeXml(s.name)}">${escapeXml(s.description)}</skill>`).join("\n")}\n</available_skills>`;
|
|
13
|
+
return `## Available skills\n\n${sorted.map((s) => `- **${s.name}**: ${s.description}`).join("\n")}`;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { renderCatalog, sortSkills };
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=catalog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog.js","names":[],"sources":["../../src/catalog.ts"],"sourcesContent":["/**\n * Catalog rendering (spec §4.3). Deterministic order (sort by name) and a fixed\n * position in the system prompt are prompt-cache requirements, not style. The\n * shape is per model family because `skills-ref` documents `<available_skills>`\n * XML as recommended specifically for Anthropic models.\n */\nimport type { ModelFamily, SkillMetadata } from './types'\n\n/** Sort skills into a stable, cache-friendly order. */\nexport function sortSkills(skills: Array<SkillMetadata>): Array<SkillMetadata> {\n return [...skills].sort((a, b) =>\n a.name < b.name ? -1 : a.name > b.name ? 1 : 0,\n )\n}\n\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n/** Render the skill catalog for a model family. Skills are sorted by name. */\nexport function renderCatalog(\n skills: Array<SkillMetadata>,\n family: ModelFamily,\n): string {\n const sorted = sortSkills(skills)\n if (family === 'anthropic') {\n const entries = sorted\n .map(\n (s) =>\n ` <skill name=\"${escapeXml(s.name)}\">${escapeXml(s.description)}</skill>`,\n )\n .join('\\n')\n return `<available_skills>\\n${entries}\\n</available_skills>`\n }\n const entries = sorted\n .map((s) => `- **${s.name}**: ${s.description}`)\n .join('\\n')\n return `## Available skills\\n\\n${entries}`\n}\n"],"mappings":";;AASA,SAAgB,WAAW,QAAoD;CAC7E,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,MAAM,GAAG,MAC1B,EAAE,OAAO,EAAE,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,IAAI,CAC/C;AACF;AAEA,SAAS,UAAU,GAAmB;CACpC,OAAO,EACJ,QAAQ,MAAM,OAAO,CAAC,CACtB,QAAQ,MAAM,MAAM,CAAC,CACrB,QAAQ,MAAM,MAAM,CAAC,CACrB,QAAQ,MAAM,QAAQ,CAAC,CACvB,QAAQ,MAAM,QAAQ;AAC3B;;AAGA,SAAgB,cACd,QACA,QACQ;CACR,MAAM,SAAS,WAAW,MAAM;CAChC,IAAI,WAAW,aAOb,OAAO,uBANS,OACb,KACE,MACC,kBAAkB,UAAU,EAAE,IAAI,EAAE,IAAI,UAAU,EAAE,WAAW,EAAE,SACrE,CAAC,CACA,KAAK,IACsB,EAAQ;CAKxC,OAAO,0BAHS,OACb,KAAK,MAAM,OAAO,EAAE,KAAK,MAAM,EAAE,aAAa,CAAC,CAC/C,KAAK,IACyB;AACnC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SkillMetadata, SkillSource } from './types.js';
|
|
2
|
+
export type FilterContext = Record<string, unknown>;
|
|
3
|
+
export type FilterPredicate = (skill: SkillMetadata, ctx?: FilterContext) => boolean;
|
|
4
|
+
/** Concatenate sources in registration order. No dedupe. */
|
|
5
|
+
export declare function aggregate(sources: Array<SkillSource>): SkillSource;
|
|
6
|
+
/** First occurrence of a name wins; warns on collision. */
|
|
7
|
+
export declare function dedupe(source: SkillSource, onCollision?: (name: string) => void): SkillSource;
|
|
8
|
+
/** Hide skills the predicate rejects. Filtered skills never reach the catalog. */
|
|
9
|
+
export declare function filter(source: SkillSource, predicate: FilterPredicate, ctx?: FilterContext): SkillSource;
|
|
10
|
+
/**
|
|
11
|
+
* Memoize `list()`/`load()`. Concurrent `list()` calls share one underlying
|
|
12
|
+
* fetch. `refreshInterval` (ms) expires the memo; omit for forever.
|
|
13
|
+
*
|
|
14
|
+
* Never auto-applied by the middleware — caching a tenant-scoped source in a
|
|
15
|
+
* shared bucket would replay one tenant's skills for another. Opt in explicitly.
|
|
16
|
+
*/
|
|
17
|
+
export declare function cache(source: SkillSource, opts?: {
|
|
18
|
+
refreshInterval?: number;
|
|
19
|
+
}): SkillSource;
|
|
20
|
+
/**
|
|
21
|
+
* Combine the sources handed to the middleware. An array is deduped and
|
|
22
|
+
* aggregated; a single bare source is used as-is (never auto-wrapped).
|
|
23
|
+
*/
|
|
24
|
+
export declare function combineSources(sources: SkillSource | Array<SkillSource>): SkillSource;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { stableHash } from "./util.js";
|
|
2
|
+
//#region src/combinators.ts
|
|
3
|
+
/**
|
|
4
|
+
* Source combinators (spec §3.3). Sources compose in practice: org skills +
|
|
5
|
+
* project skills + tenant skills. `aggregate` concatenates, `dedupe` resolves
|
|
6
|
+
* collisions, `filter` hides, `cache` memoizes.
|
|
7
|
+
*/
|
|
8
|
+
/** Forward a source's optional `revision`, preserving `undefined` when absent. */
|
|
9
|
+
function forwardRevision(source) {
|
|
10
|
+
const rev = source.revision;
|
|
11
|
+
return rev ? () => rev() : void 0;
|
|
12
|
+
}
|
|
13
|
+
/** Route a delegating method to the first source that lists `name`. */
|
|
14
|
+
async function ownerOf(sources, name) {
|
|
15
|
+
for (const source of sources) if ((await source.list()).some((s) => s.name === name)) return source;
|
|
16
|
+
throw new Error(`no source provides a skill named "${name}"`);
|
|
17
|
+
}
|
|
18
|
+
async function combinedRevision(sources) {
|
|
19
|
+
const revs = await Promise.all(sources.map((s) => s.revision?.() ?? Promise.resolve(void 0)));
|
|
20
|
+
if (revs.some((r) => r === void 0)) return void 0;
|
|
21
|
+
return stableHash(revs.join("|"));
|
|
22
|
+
}
|
|
23
|
+
/** Concatenate sources in registration order. No dedupe. */
|
|
24
|
+
function aggregate(sources) {
|
|
25
|
+
return {
|
|
26
|
+
...sources.every((s) => s.revision) && { revision: async () => await combinedRevision(sources) ?? "" },
|
|
27
|
+
list: async () => {
|
|
28
|
+
return (await Promise.all(sources.map((s) => s.list()))).flat();
|
|
29
|
+
},
|
|
30
|
+
load: async (name) => (await ownerOf(sources, name)).load(name),
|
|
31
|
+
listResources: async (name) => {
|
|
32
|
+
return (await ownerOf(sources, name)).listResources?.(name) ?? [];
|
|
33
|
+
},
|
|
34
|
+
readResource: async (name, path) => {
|
|
35
|
+
const owner = await ownerOf(sources, name);
|
|
36
|
+
if (!owner.readResource) throw new Error(`skill "${name}" does not support resources`);
|
|
37
|
+
return owner.readResource(name, path);
|
|
38
|
+
},
|
|
39
|
+
listScripts: async (name) => {
|
|
40
|
+
return (await ownerOf(sources, name)).listScripts?.(name) ?? [];
|
|
41
|
+
},
|
|
42
|
+
readScript: async (name, path) => {
|
|
43
|
+
const owner = await ownerOf(sources, name);
|
|
44
|
+
if (!owner.readScript) throw new Error(`skill "${name}" does not support scripts`);
|
|
45
|
+
return owner.readScript(name, path);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** First occurrence of a name wins; warns on collision. */
|
|
50
|
+
function dedupe(source, onCollision = (name) => console.warn(`[ai-skills] duplicate skill "${name}" — first one wins`)) {
|
|
51
|
+
return {
|
|
52
|
+
...source,
|
|
53
|
+
revision: forwardRevision(source),
|
|
54
|
+
list: async () => {
|
|
55
|
+
const seen = /* @__PURE__ */ new Set();
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const skill of await source.list()) {
|
|
58
|
+
if (seen.has(skill.name)) {
|
|
59
|
+
onCollision(skill.name);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
seen.add(skill.name);
|
|
63
|
+
out.push(skill);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Hide skills the predicate rejects. Filtered skills never reach the catalog. */
|
|
70
|
+
function filter(source, predicate, ctx) {
|
|
71
|
+
const list = async () => (await source.list()).filter((s) => predicate(s, ctx));
|
|
72
|
+
const assertVisible = async (name) => {
|
|
73
|
+
if (!(await list()).some((s) => s.name === name)) throw new Error(`no skill named "${name}"`);
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
...source,
|
|
77
|
+
revision: forwardRevision(source),
|
|
78
|
+
list,
|
|
79
|
+
load: async (name) => {
|
|
80
|
+
await assertVisible(name);
|
|
81
|
+
return source.load(name);
|
|
82
|
+
},
|
|
83
|
+
listResources: source.listResources ? async (name) => {
|
|
84
|
+
await assertVisible(name);
|
|
85
|
+
return source.listResources?.(name) ?? [];
|
|
86
|
+
} : void 0,
|
|
87
|
+
readResource: source.readResource ? async (name, path) => {
|
|
88
|
+
await assertVisible(name);
|
|
89
|
+
const read = source.readResource;
|
|
90
|
+
if (!read) throw new Error(`skill "${name}" does not support resources`);
|
|
91
|
+
return read(name, path);
|
|
92
|
+
} : void 0,
|
|
93
|
+
listScripts: source.listScripts ? async (name) => {
|
|
94
|
+
await assertVisible(name);
|
|
95
|
+
return source.listScripts?.(name) ?? [];
|
|
96
|
+
} : void 0,
|
|
97
|
+
readScript: source.readScript ? async (name, path) => {
|
|
98
|
+
await assertVisible(name);
|
|
99
|
+
const read = source.readScript;
|
|
100
|
+
if (!read) throw new Error(`skill "${name}" does not support scripts`);
|
|
101
|
+
return read(name, path);
|
|
102
|
+
} : void 0
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Memoize `list()`/`load()`. Concurrent `list()` calls share one underlying
|
|
107
|
+
* fetch. `refreshInterval` (ms) expires the memo; omit for forever.
|
|
108
|
+
*
|
|
109
|
+
* Never auto-applied by the middleware — caching a tenant-scoped source in a
|
|
110
|
+
* shared bucket would replay one tenant's skills for another. Opt in explicitly.
|
|
111
|
+
*/
|
|
112
|
+
function cache(source, opts = {}) {
|
|
113
|
+
let listPromise;
|
|
114
|
+
let listAt = 0;
|
|
115
|
+
const loads = /* @__PURE__ */ new Map();
|
|
116
|
+
const now = () => opts.refreshInterval ? Date.now() : 0;
|
|
117
|
+
const fresh = () => opts.refreshInterval === void 0 || now() - listAt < opts.refreshInterval;
|
|
118
|
+
return {
|
|
119
|
+
...source,
|
|
120
|
+
revision: forwardRevision(source),
|
|
121
|
+
list: () => {
|
|
122
|
+
if (!listPromise || !fresh()) {
|
|
123
|
+
listAt = now();
|
|
124
|
+
loads.clear();
|
|
125
|
+
listPromise = source.list().catch((err) => {
|
|
126
|
+
listPromise = void 0;
|
|
127
|
+
throw err;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return listPromise;
|
|
131
|
+
},
|
|
132
|
+
load: (name) => {
|
|
133
|
+
let p = loads.get(name);
|
|
134
|
+
if (!p) {
|
|
135
|
+
p = source.load(name).catch((err) => {
|
|
136
|
+
loads.delete(name);
|
|
137
|
+
throw err;
|
|
138
|
+
});
|
|
139
|
+
loads.set(name, p);
|
|
140
|
+
}
|
|
141
|
+
return p;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Combine the sources handed to the middleware. An array is deduped and
|
|
147
|
+
* aggregated; a single bare source is used as-is (never auto-wrapped).
|
|
148
|
+
*/
|
|
149
|
+
function combineSources(sources) {
|
|
150
|
+
if (!Array.isArray(sources)) return sources;
|
|
151
|
+
const [first] = sources;
|
|
152
|
+
if (sources.length === 1 && first) return first;
|
|
153
|
+
return dedupe(aggregate(sources));
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
export { aggregate, cache, combineSources, dedupe, filter };
|
|
157
|
+
|
|
158
|
+
//# sourceMappingURL=combinators.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"combinators.js","names":[],"sources":["../../src/combinators.ts"],"sourcesContent":["/**\n * Source combinators (spec §3.3). Sources compose in practice: org skills +\n * project skills + tenant skills. `aggregate` concatenates, `dedupe` resolves\n * collisions, `filter` hides, `cache` memoizes.\n */\nimport { stableHash } from './util'\nimport type { SkillMetadata, SkillScriptRef, SkillSource } from './types'\n\nexport type FilterContext = Record<string, unknown>\nexport type FilterPredicate = (\n skill: SkillMetadata,\n ctx?: FilterContext,\n) => boolean\n\n/** Forward a source's optional `revision`, preserving `undefined` when absent. */\nfunction forwardRevision(\n source: SkillSource,\n): (() => Promise<string>) | undefined {\n const rev = source.revision\n return rev ? () => rev() : undefined\n}\n\n/** Route a delegating method to the first source that lists `name`. */\nasync function ownerOf(\n sources: Array<SkillSource>,\n name: string,\n): Promise<SkillSource> {\n for (const source of sources) {\n const list = await source.list()\n if (list.some((s) => s.name === name)) return source\n }\n throw new Error(`no source provides a skill named \"${name}\"`)\n}\n\nasync function combinedRevision(\n sources: Array<SkillSource>,\n): Promise<string | undefined> {\n const revs = await Promise.all(\n sources.map((s) => s.revision?.() ?? Promise.resolve(undefined)),\n )\n if (revs.some((r) => r === undefined)) return undefined\n return stableHash(revs.join('|'))\n}\n\n/** Concatenate sources in registration order. No dedupe. */\nexport function aggregate(sources: Array<SkillSource>): SkillSource {\n // Only expose revision() when every child does — a partial revision would\n // report \"unchanged\" while an unversioned child mutated underneath.\n const allVersioned = sources.every((s) => s.revision)\n return {\n ...(allVersioned && {\n revision: async () => (await combinedRevision(sources)) ?? '',\n }),\n list: async () => {\n const lists = await Promise.all(sources.map((s) => s.list()))\n return lists.flat()\n },\n load: async (name) => (await ownerOf(sources, name)).load(name),\n listResources: async (name) => {\n const owner = await ownerOf(sources, name)\n return owner.listResources?.(name) ?? []\n },\n readResource: async (name, path) => {\n const owner = await ownerOf(sources, name)\n if (!owner.readResource) {\n throw new Error(`skill \"${name}\" does not support resources`)\n }\n return owner.readResource(name, path)\n },\n listScripts: async (name) => {\n const owner = await ownerOf(sources, name)\n return (owner.listScripts?.(name) ?? []) as Array<SkillScriptRef>\n },\n readScript: async (name, path) => {\n const owner = await ownerOf(sources, name)\n if (!owner.readScript) {\n throw new Error(`skill \"${name}\" does not support scripts`)\n }\n return owner.readScript(name, path)\n },\n }\n}\n\n/** First occurrence of a name wins; warns on collision. */\nexport function dedupe(\n source: SkillSource,\n onCollision: (name: string) => void = (name) =>\n console.warn(`[ai-skills] duplicate skill \"${name}\" — first one wins`),\n): SkillSource {\n return {\n ...source,\n revision: forwardRevision(source),\n list: async () => {\n const seen = new Set<string>()\n const out: Array<SkillMetadata> = []\n for (const skill of await source.list()) {\n if (seen.has(skill.name)) {\n onCollision(skill.name)\n continue\n }\n seen.add(skill.name)\n out.push(skill)\n }\n return out\n },\n }\n}\n\n/** Hide skills the predicate rejects. Filtered skills never reach the catalog. */\nexport function filter(\n source: SkillSource,\n predicate: FilterPredicate,\n ctx?: FilterContext,\n): SkillSource {\n const list = async () =>\n (await source.list()).filter((s) => predicate(s, ctx))\n const assertVisible = async (name: string) => {\n const skills = await list()\n if (!skills.some((s) => s.name === name)) {\n throw new Error(`no skill named \"${name}\"`)\n }\n }\n return {\n ...source,\n revision: forwardRevision(source),\n list,\n load: async (name) => {\n await assertVisible(name)\n return source.load(name)\n },\n listResources: source.listResources\n ? async (name) => {\n await assertVisible(name)\n return source.listResources?.(name) ?? []\n }\n : undefined,\n readResource: source.readResource\n ? async (name, path) => {\n await assertVisible(name)\n const read = source.readResource\n if (!read) {\n throw new Error(`skill \"${name}\" does not support resources`)\n }\n return read(name, path)\n }\n : undefined,\n listScripts: source.listScripts\n ? async (name) => {\n await assertVisible(name)\n return (source.listScripts?.(name) ?? []) as Array<SkillScriptRef>\n }\n : undefined,\n readScript: source.readScript\n ? async (name, path) => {\n await assertVisible(name)\n const read = source.readScript\n if (!read) {\n throw new Error(`skill \"${name}\" does not support scripts`)\n }\n return read(name, path)\n }\n : undefined,\n }\n}\n\n/**\n * Memoize `list()`/`load()`. Concurrent `list()` calls share one underlying\n * fetch. `refreshInterval` (ms) expires the memo; omit for forever.\n *\n * Never auto-applied by the middleware — caching a tenant-scoped source in a\n * shared bucket would replay one tenant's skills for another. Opt in explicitly.\n */\nexport function cache(\n source: SkillSource,\n opts: { refreshInterval?: number } = {},\n): SkillSource {\n let listPromise: Promise<Array<SkillMetadata>> | undefined\n let listAt = 0\n const loads = new Map<string, Promise<string>>()\n\n const now = () => (opts.refreshInterval ? Date.now() : 0)\n const fresh = () =>\n opts.refreshInterval === undefined || now() - listAt < opts.refreshInterval\n\n return {\n ...source,\n revision: forwardRevision(source),\n list: () => {\n if (!listPromise || !fresh()) {\n listAt = now()\n loads.clear()\n listPromise = source.list().catch((err) => {\n listPromise = undefined // don't cache failures\n throw err\n })\n }\n return listPromise\n },\n load: (name) => {\n let p = loads.get(name)\n if (!p) {\n p = source.load(name).catch((err) => {\n loads.delete(name)\n throw err\n })\n loads.set(name, p)\n }\n return p\n },\n }\n}\n\n/**\n * Combine the sources handed to the middleware. An array is deduped and\n * aggregated; a single bare source is used as-is (never auto-wrapped).\n */\nexport function combineSources(\n sources: SkillSource | Array<SkillSource>,\n): SkillSource {\n if (!Array.isArray(sources)) return sources\n const [first] = sources\n if (sources.length === 1 && first) return first\n return dedupe(aggregate(sources))\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SkillLimitError` is defined in core `@tanstack/ai` (so the native tool
|
|
3
|
+
* factories can throw it without depending on this package) and re-exported
|
|
4
|
+
* here for the portable path.
|
|
5
|
+
*/
|
|
6
|
+
export { SkillLimitError } from '@tanstack/ai';
|
|
7
|
+
export type { SkillLimitErrorInit } from '@tanstack/ai';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@tanstack/ai-skills` — portable Agent Skills (`SKILL.md`) as a first-class
|
|
3
|
+
* `chat()` middleware. Edge-safe root export; `skillDirectory` (node:fs) lives
|
|
4
|
+
* behind the `/node` subpath, the Vite plugin behind `/static`, and the
|
|
5
|
+
* conformance suite behind `/testing`.
|
|
6
|
+
*/
|
|
7
|
+
export type { SkillSource, SkillMetadata, SkillScriptRef, LoadSkillResult, ModelFamily, } from './types.js';
|
|
8
|
+
export { modelFamilyOf } from './types.js';
|
|
9
|
+
export { parseSkill, stripFrontmatter, SkillParseError } from './parse.js';
|
|
10
|
+
export type { ParsedSkill, ParseWarning } from './parse.js';
|
|
11
|
+
export { walkSkillDirs, SKILL_FILE, MAX_SKILL_WALK_DEPTH } from './walk.js';
|
|
12
|
+
export type { DiscoveredSkillDir, WalkEntry, ListDir } from './walk.js';
|
|
13
|
+
export { inlineSkill } from './sources/inline.js';
|
|
14
|
+
export type { InlineSkillConfig } from './sources/inline.js';
|
|
15
|
+
export { aggregate, dedupe, filter, cache, combineSources } from './combinators.js';
|
|
16
|
+
export type { FilterContext, FilterPredicate } from './combinators.js';
|
|
17
|
+
export { renderCatalog, sortSkills } from './catalog.js';
|
|
18
|
+
export { withSkills, SKILLS_STATE_EVENT } from './middleware.js';
|
|
19
|
+
export type { SkillsOptions, SkillsStateEventValue } from './middleware.js';
|
|
20
|
+
export { createLoadSkillTool, ALREADY_LOADED } from './tools/load-skill.js';
|
|
21
|
+
export { createResourceTool, READ_RESOURCE_TOOL_NAME, } from './tools/read-resource.js';
|
|
22
|
+
export { validateSkill } from './validate.js';
|
|
23
|
+
export type { SkillTarget, SkillValidationIssue, SkillValidationResult, } from './validate.js';
|
|
24
|
+
export { SkillLimitError } from './errors.js';
|
|
25
|
+
export type { SkillLimitErrorInit } from './errors.js';
|
|
26
|
+
export { assertSafeResourcePath, stableHash } from './util.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { modelFamilyOf } from "./types.js";
|
|
2
|
+
import { SkillParseError, parseSkill, stripFrontmatter } from "./parse.js";
|
|
3
|
+
import { MAX_SKILL_WALK_DEPTH, SKILL_FILE, walkSkillDirs } from "./walk.js";
|
|
4
|
+
import { assertSafeResourcePath, stableHash } from "./util.js";
|
|
5
|
+
import { validateSkill } from "./validate.js";
|
|
6
|
+
import { inlineSkill } from "./sources/inline.js";
|
|
7
|
+
import { aggregate, cache, combineSources, dedupe, filter } from "./combinators.js";
|
|
8
|
+
import { renderCatalog, sortSkills } from "./catalog.js";
|
|
9
|
+
import { ALREADY_LOADED, createLoadSkillTool } from "./tools/load-skill.js";
|
|
10
|
+
import { READ_RESOURCE_TOOL_NAME, createResourceTool } from "./tools/read-resource.js";
|
|
11
|
+
import { SKILLS_STATE_EVENT, withSkills } from "./middleware.js";
|
|
12
|
+
import { SkillLimitError } from "./errors.js";
|
|
13
|
+
export { ALREADY_LOADED, MAX_SKILL_WALK_DEPTH, READ_RESOURCE_TOOL_NAME, SKILLS_STATE_EVENT, SKILL_FILE, SkillLimitError, SkillParseError, aggregate, assertSafeResourcePath, cache, combineSources, createLoadSkillTool, createResourceTool, dedupe, filter, inlineSkill, modelFamilyOf, parseSkill, renderCatalog, sortSkills, stableHash, stripFrontmatter, validateSkill, walkSkillDirs, withSkills };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DefinedChatMiddleware, Tool } from '@tanstack/ai';
|
|
2
|
+
import { ModelFamily, SkillMetadata, SkillSource } from './types.js';
|
|
3
|
+
/** CUSTOM stream-event name carrying the catalog to the browser DevTools. */
|
|
4
|
+
export declare const SKILLS_STATE_EVENT = "skills:state";
|
|
5
|
+
export interface SkillsStateEventValue {
|
|
6
|
+
catalog: Array<{
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
}>;
|
|
10
|
+
activated: Array<string>;
|
|
11
|
+
}
|
|
12
|
+
export interface SkillsOptions {
|
|
13
|
+
/** Override catalog rendering. Receives resolved metadata + the model family. */
|
|
14
|
+
renderCatalog?: (skills: Array<SkillMetadata>, family: ModelFamily) => string;
|
|
15
|
+
/** Template with a required `{skills}` placeholder. Literal braces escape as `{{`/`}}`. */
|
|
16
|
+
instructionTemplate?: string;
|
|
17
|
+
/** Hard cap on tier-1 catalog token spend. Default 4000. */
|
|
18
|
+
maxCatalogTokens?: number;
|
|
19
|
+
/** `'error'` (default) or a reducer invoked when the cap is exceeded. */
|
|
20
|
+
onLimitExceeded?: 'error' | ((skills: Array<SkillMetadata>, limit: number) => Array<SkillMetadata>);
|
|
21
|
+
/** Where the catalog goes. Default `'system'`. */
|
|
22
|
+
catalogPlacement?: 'system' | 'tool-description';
|
|
23
|
+
/** Require approval before load_skill / read_skill_resource. Default false. */
|
|
24
|
+
requireApproval?: boolean;
|
|
25
|
+
}
|
|
26
|
+
interface SkillsRuntime {
|
|
27
|
+
skills: Array<SkillMetadata>;
|
|
28
|
+
activated: Set<string>;
|
|
29
|
+
source: SkillSource;
|
|
30
|
+
family: ModelFamily;
|
|
31
|
+
catalog: string;
|
|
32
|
+
options: SkillsOptions;
|
|
33
|
+
/** Built on first onConfig (needs config.tools to detect the resource tool). */
|
|
34
|
+
memo?: {
|
|
35
|
+
prompt: {
|
|
36
|
+
content: string;
|
|
37
|
+
} | undefined;
|
|
38
|
+
tools: Array<Tool>;
|
|
39
|
+
};
|
|
40
|
+
stateChunkEmitted?: boolean;
|
|
41
|
+
}
|
|
42
|
+
declare const SkillsCapability: import('@tanstack/ai').Capability<SkillsRuntime, "skills">;
|
|
43
|
+
export declare function withSkills(sources: SkillSource | Array<SkillSource>, options?: SkillsOptions): DefinedChatMiddleware<unknown, readonly [], readonly [typeof SkillsCapability]>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { modelFamilyOf } from "./types.js";
|
|
2
|
+
import { combineSources } from "./combinators.js";
|
|
3
|
+
import { renderCatalog } from "./catalog.js";
|
|
4
|
+
import { createLoadSkillTool } from "./tools/load-skill.js";
|
|
5
|
+
import { READ_RESOURCE_TOOL_NAME } from "./tools/read-resource.js";
|
|
6
|
+
import { SkillLimitError, createCapability, defineChatMiddleware } from "@tanstack/ai";
|
|
7
|
+
//#region src/middleware.ts
|
|
8
|
+
/**
|
|
9
|
+
* `withSkills` — portable Agent Skills as a chat middleware.
|
|
10
|
+
*
|
|
11
|
+
* All source resolution and catalog rendering happen once in `setup`; `onConfig`
|
|
12
|
+
* (which fires every agent iteration) only returns memoized values. Otherwise an
|
|
13
|
+
* S3-backed source would hit the network per loop turn and catalog reordering
|
|
14
|
+
* would break Anthropic's cache prefix mid-run.
|
|
15
|
+
*/
|
|
16
|
+
/** CUSTOM stream-event name carrying the catalog to the browser DevTools. */
|
|
17
|
+
var SKILLS_STATE_EVENT = "skills:state";
|
|
18
|
+
var SkillsCapability = createCapability()("skills");
|
|
19
|
+
/** ~4 chars/token — good enough to guard a runaway catalog. */
|
|
20
|
+
var estimateTokens = (s) => Math.ceil(s.length / 4);
|
|
21
|
+
function fillTemplate(template, catalog) {
|
|
22
|
+
const OPEN = "\0OPEN\0";
|
|
23
|
+
const CLOSE = "\0CLOSE\0";
|
|
24
|
+
return template.split("{{").join(OPEN).split("}}").join(CLOSE).split("{skills}").join(catalog).split(OPEN).join("{").split(CLOSE).join("}");
|
|
25
|
+
}
|
|
26
|
+
/** True when a code_execution/shell tool in `tools` carries hosted skills. */
|
|
27
|
+
function findNativeSkillTool(tools) {
|
|
28
|
+
for (const tool of tools) {
|
|
29
|
+
const meta = tool.metadata;
|
|
30
|
+
if (tool.name === "code_execution" && (meta?.skills?.length ?? 0) > 0) return "code_execution";
|
|
31
|
+
if (tool.name === "shell" && (meta?.environment?.skills?.length ?? 0) > 0) return "shell";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function activationInstructions(catalog, hasResourceTool) {
|
|
35
|
+
return [
|
|
36
|
+
"You have access to a library of skills. When a task matches one, call the `load_skill` tool with its name to load its full instructions before proceeding.",
|
|
37
|
+
catalog,
|
|
38
|
+
hasResourceTool ? "To read a skill’s bundled resource files, call `read_skill_resource` with the skill name and the resource path." : "Some skills may list resource files; they are not loadable in this configuration."
|
|
39
|
+
].join("\n\n");
|
|
40
|
+
}
|
|
41
|
+
function withSkills(sources, options = {}) {
|
|
42
|
+
if (options.instructionTemplate && options.renderCatalog) throw new Error("`instructionTemplate` and `renderCatalog` are mutually exclusive");
|
|
43
|
+
if (options.instructionTemplate && !options.instructionTemplate.includes("{skills}")) throw new Error("`instructionTemplate` must contain a `{skills}` placeholder");
|
|
44
|
+
return defineChatMiddleware({
|
|
45
|
+
name: "skills",
|
|
46
|
+
provides: [SkillsCapability],
|
|
47
|
+
async setup(ctx) {
|
|
48
|
+
const source = combineSources(sources);
|
|
49
|
+
let skills = await source.list();
|
|
50
|
+
const family = modelFamilyOf(ctx.provider);
|
|
51
|
+
const limit = options.maxCatalogTokens ?? 4e3;
|
|
52
|
+
const render = options.renderCatalog ?? renderCatalog;
|
|
53
|
+
let catalog = render(skills, family);
|
|
54
|
+
if (estimateTokens(catalog) > limit) {
|
|
55
|
+
if (options.onLimitExceeded && options.onLimitExceeded !== "error") {
|
|
56
|
+
skills = options.onLimitExceeded(skills, limit);
|
|
57
|
+
catalog = render(skills, family);
|
|
58
|
+
}
|
|
59
|
+
if (estimateTokens(catalog) > limit) throw new SkillLimitError({
|
|
60
|
+
provider: family,
|
|
61
|
+
path: "portable",
|
|
62
|
+
limit: `maxCatalogTokens (${limit})`,
|
|
63
|
+
allowed: limit,
|
|
64
|
+
actual: estimateTokens(catalog),
|
|
65
|
+
offending: skills.map((s) => s.name)
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
ctx.provide(SkillsCapability, {
|
|
69
|
+
skills,
|
|
70
|
+
activated: /* @__PURE__ */ new Set(),
|
|
71
|
+
source,
|
|
72
|
+
family,
|
|
73
|
+
catalog,
|
|
74
|
+
options
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
onConfig(ctx, config) {
|
|
78
|
+
const rt = ctx.get(SkillsCapability);
|
|
79
|
+
if (rt.skills.length === 0) return;
|
|
80
|
+
const native = findNativeSkillTool(config.tools);
|
|
81
|
+
if (native) throw new Error(`withSkills (portable skills) cannot be combined with a "${native}" tool that carries hosted/native skills. Use one delivery mode: remove the hosted skills, or drop withSkills.`);
|
|
82
|
+
if (!rt.memo) {
|
|
83
|
+
const hasResourceTool = config.tools.some((t) => t.name === READ_RESOURCE_TOOL_NAME);
|
|
84
|
+
const body = options.instructionTemplate !== void 0 ? fillTemplate(options.instructionTemplate, rt.catalog) : activationInstructions(rt.catalog, hasResourceTool);
|
|
85
|
+
const loadTool = createLoadSkillTool({
|
|
86
|
+
source: rt.source,
|
|
87
|
+
skills: rt.skills,
|
|
88
|
+
activated: rt.activated,
|
|
89
|
+
requireApproval: options.requireApproval
|
|
90
|
+
});
|
|
91
|
+
if ((options.catalogPlacement ?? "system") === "tool-description") {
|
|
92
|
+
loadTool.description = `${loadTool.description}\n\n${body}`;
|
|
93
|
+
rt.memo = {
|
|
94
|
+
prompt: void 0,
|
|
95
|
+
tools: [loadTool]
|
|
96
|
+
};
|
|
97
|
+
} else rt.memo = {
|
|
98
|
+
prompt: { content: body },
|
|
99
|
+
tools: [loadTool]
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const prompt = rt.memo.prompt;
|
|
103
|
+
const promptPresent = !prompt || config.systemPrompts.some((p) => typeof p === "string" ? p === prompt.content : p.content === prompt.content);
|
|
104
|
+
const existingNames = new Set(config.tools.map((t) => t.name));
|
|
105
|
+
const toolsToAdd = rt.memo.tools.filter((t) => !existingNames.has(t.name));
|
|
106
|
+
return {
|
|
107
|
+
systemPrompts: prompt && !promptPresent ? [...config.systemPrompts, prompt] : config.systemPrompts,
|
|
108
|
+
tools: toolsToAdd.length > 0 ? [...config.tools, ...toolsToAdd] : config.tools
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
onChunk(ctx, chunk) {
|
|
112
|
+
const rt = ctx.getOptional(SkillsCapability);
|
|
113
|
+
if (!rt || rt.stateChunkEmitted) return;
|
|
114
|
+
rt.stateChunkEmitted = true;
|
|
115
|
+
return [chunk, {
|
|
116
|
+
type: "CUSTOM",
|
|
117
|
+
name: SKILLS_STATE_EVENT,
|
|
118
|
+
value: {
|
|
119
|
+
catalog: rt.skills.map((s) => ({
|
|
120
|
+
name: s.name,
|
|
121
|
+
description: s.description
|
|
122
|
+
})),
|
|
123
|
+
activated: [...rt.activated]
|
|
124
|
+
},
|
|
125
|
+
timestamp: Date.now()
|
|
126
|
+
}];
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { SKILLS_STATE_EVENT, withSkills };
|
|
132
|
+
|
|
133
|
+
//# sourceMappingURL=middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.js","names":[],"sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSkills` — portable Agent Skills as a chat middleware.\n *\n * All source resolution and catalog rendering happen once in `setup`; `onConfig`\n * (which fires every agent iteration) only returns memoized values. Otherwise an\n * S3-backed source would hit the network per loop turn and catalog reordering\n * would break Anthropic's cache prefix mid-run.\n */\nimport {\n createCapability,\n defineChatMiddleware,\n SkillLimitError,\n} from '@tanstack/ai'\nimport { combineSources } from './combinators'\nimport { renderCatalog } from './catalog'\nimport { modelFamilyOf } from './types'\nimport { createLoadSkillTool } from './tools/load-skill'\nimport { READ_RESOURCE_TOOL_NAME } from './tools/read-resource'\nimport type { DefinedChatMiddleware, StreamChunk, Tool } from '@tanstack/ai'\nimport type { ModelFamily, SkillMetadata, SkillSource } from './types'\n\n/** CUSTOM stream-event name carrying the catalog to the browser DevTools. */\nexport const SKILLS_STATE_EVENT = 'skills:state'\n\nexport interface SkillsStateEventValue {\n catalog: Array<{ name: string; description: string }>\n activated: Array<string>\n}\n\nexport interface SkillsOptions {\n /** Override catalog rendering. Receives resolved metadata + the model family. */\n renderCatalog?: (skills: Array<SkillMetadata>, family: ModelFamily) => string\n /** Template with a required `{skills}` placeholder. Literal braces escape as `{{`/`}}`. */\n instructionTemplate?: string\n /** Hard cap on tier-1 catalog token spend. Default 4000. */\n maxCatalogTokens?: number\n /** `'error'` (default) or a reducer invoked when the cap is exceeded. */\n onLimitExceeded?:\n | 'error'\n | ((skills: Array<SkillMetadata>, limit: number) => Array<SkillMetadata>)\n /** Where the catalog goes. Default `'system'`. */\n catalogPlacement?: 'system' | 'tool-description'\n /** Require approval before load_skill / read_skill_resource. Default false. */\n requireApproval?: boolean\n}\n\ninterface SkillsRuntime {\n skills: Array<SkillMetadata>\n activated: Set<string>\n source: SkillSource\n family: ModelFamily\n catalog: string\n options: SkillsOptions\n /** Built on first onConfig (needs config.tools to detect the resource tool). */\n memo?: { prompt: { content: string } | undefined; tools: Array<Tool> }\n stateChunkEmitted?: boolean\n}\n\nconst SkillsCapability = createCapability<SkillsRuntime>()('skills')\n\n/** ~4 chars/token — good enough to guard a runaway catalog. */\nconst estimateTokens = (s: string) => Math.ceil(s.length / 4)\n\nfunction fillTemplate(template: string, catalog: string): string {\n // Escape `{{`/`}}` to sentinels, substitute `{skills}`, then restore braces.\n const OPEN = '\\u0000OPEN\\u0000'\n const CLOSE = '\\u0000CLOSE\\u0000'\n return template\n .split('{{')\n .join(OPEN)\n .split('}}')\n .join(CLOSE)\n .split('{skills}')\n .join(catalog)\n .split(OPEN)\n .join('{')\n .split(CLOSE)\n .join('}')\n}\n\n/** True when a code_execution/shell tool in `tools` carries hosted skills. */\nfunction findNativeSkillTool(tools: Array<Tool>): string | undefined {\n for (const tool of tools) {\n const meta = tool.metadata as\n | { skills?: Array<unknown>; environment?: { skills?: Array<unknown> } }\n | undefined\n if (tool.name === 'code_execution' && (meta?.skills?.length ?? 0) > 0) {\n return 'code_execution'\n }\n if (tool.name === 'shell' && (meta?.environment?.skills?.length ?? 0) > 0) {\n return 'shell'\n }\n }\n return undefined\n}\n\nfunction activationInstructions(\n catalog: string,\n hasResourceTool: boolean,\n): string {\n const resourceLine = hasResourceTool\n ? 'To read a skill’s bundled resource files, call `read_skill_resource` with the skill name and the resource path.'\n : 'Some skills may list resource files; they are not loadable in this configuration.'\n return [\n 'You have access to a library of skills. When a task matches one, call the `load_skill` tool with its name to load its full instructions before proceeding.',\n catalog,\n resourceLine,\n ].join('\\n\\n')\n}\n\nexport function withSkills(\n sources: SkillSource | Array<SkillSource>,\n options: SkillsOptions = {},\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SkillsCapability]\n> {\n if (options.instructionTemplate && options.renderCatalog) {\n throw new Error(\n '`instructionTemplate` and `renderCatalog` are mutually exclusive',\n )\n }\n if (\n options.instructionTemplate &&\n !options.instructionTemplate.includes('{skills}')\n ) {\n throw new Error(\n '`instructionTemplate` must contain a `{skills}` placeholder',\n )\n }\n\n return defineChatMiddleware({\n name: 'skills',\n provides: [SkillsCapability],\n\n async setup(ctx) {\n const source = combineSources(sources)\n let skills = await source.list()\n const family = modelFamilyOf(ctx.provider)\n\n // Catalog token cap (spec §4.2).\n const limit = options.maxCatalogTokens ?? 4000\n const render = options.renderCatalog ?? renderCatalog\n let catalog = render(skills, family)\n if (estimateTokens(catalog) > limit) {\n if (options.onLimitExceeded && options.onLimitExceeded !== 'error') {\n skills = options.onLimitExceeded(skills, limit)\n catalog = render(skills, family)\n }\n if (estimateTokens(catalog) > limit) {\n throw new SkillLimitError({\n provider: family,\n path: 'portable',\n limit: `maxCatalogTokens (${limit})`,\n allowed: limit,\n actual: estimateTokens(catalog),\n offending: skills.map((s) => s.name),\n })\n }\n }\n\n ctx.provide(SkillsCapability, {\n skills,\n activated: new Set<string>(),\n source,\n family,\n catalog,\n options,\n })\n },\n\n onConfig(ctx, config) {\n const rt = ctx.get(SkillsCapability)\n if (rt.skills.length === 0) return // empty catalog → no tools, no prompt\n\n // Native co-existence: portable + hosted skills don't compose (spec §6.1).\n const native = findNativeSkillTool(config.tools)\n if (native) {\n throw new Error(\n `withSkills (portable skills) cannot be combined with a \"${native}\" tool that carries hosted/native skills. ` +\n 'Use one delivery mode: remove the hosted skills, or drop withSkills.',\n )\n }\n\n if (!rt.memo) {\n const hasResourceTool = config.tools.some(\n (t) => t.name === READ_RESOURCE_TOOL_NAME,\n )\n const body =\n options.instructionTemplate !== undefined\n ? fillTemplate(options.instructionTemplate, rt.catalog)\n : activationInstructions(rt.catalog, hasResourceTool)\n\n const loadTool = createLoadSkillTool({\n source: rt.source,\n skills: rt.skills,\n activated: rt.activated,\n requireApproval: options.requireApproval,\n })\n\n const placement = options.catalogPlacement ?? 'system'\n if (placement === 'tool-description') {\n loadTool.description = `${loadTool.description}\\n\\n${body}`\n rt.memo = { prompt: undefined, tools: [loadTool] }\n } else {\n rt.memo = { prompt: { content: body }, tools: [loadTool] }\n }\n }\n\n // onConfig fires every iteration and the engine feeds the merged config\n // back in — so appending must be idempotent (add our prompt/tools only\n // when not already present) or a second iteration duplicates them.\n const prompt = rt.memo.prompt\n const promptPresent =\n !prompt ||\n config.systemPrompts.some((p) =>\n typeof p === 'string'\n ? p === prompt.content\n : p.content === prompt.content,\n )\n const existingNames = new Set(config.tools.map((t) => t.name))\n const toolsToAdd = rt.memo.tools.filter((t) => !existingNames.has(t.name))\n\n return {\n systemPrompts:\n prompt && !promptPresent\n ? [...config.systemPrompts, prompt]\n : config.systemPrompts,\n tools:\n toolsToAdd.length > 0\n ? [...config.tools, ...toolsToAdd]\n : config.tools,\n }\n },\n\n onChunk(ctx, chunk) {\n const rt = ctx.getOptional(SkillsCapability)\n if (!rt || rt.stateChunkEmitted) return\n rt.stateChunkEmitted = true\n const custom: StreamChunk = {\n type: 'CUSTOM',\n name: SKILLS_STATE_EVENT,\n value: {\n catalog: rt.skills.map((s) => ({\n name: s.name,\n description: s.description,\n })),\n activated: [...rt.activated],\n } satisfies SkillsStateEventValue,\n timestamp: Date.now(),\n }\n return [chunk, custom]\n },\n })\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { GeneratedCatalog } from '../static/index.js';
|
|
2
|
+
import { SkillSource } from '../types.js';
|
|
3
|
+
export interface SkillDirectoryOptions {
|
|
4
|
+
maxDepth?: number;
|
|
5
|
+
/** default true — promote parse warnings to errors (see spec §7). */
|
|
6
|
+
strict?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function skillDirectory(root: string | Array<string>, options?: SkillDirectoryOptions): SkillSource;
|
|
9
|
+
/**
|
|
10
|
+
* Read a skill directory tree into a plain {@link GeneratedCatalog} — the shape
|
|
11
|
+
* `staticSkills` consumes. Used by the Vite plugin and directly available for
|
|
12
|
+
* custom build scripts.
|
|
13
|
+
*/
|
|
14
|
+
export declare function generateCatalog(root: string | Array<string>, options?: SkillDirectoryOptions): Promise<GeneratedCatalog>;
|
|
15
|
+
/** Structural Vite plugin (no `vite` type dependency). */
|
|
16
|
+
export interface SkillsCatalogPlugin {
|
|
17
|
+
name: string;
|
|
18
|
+
resolveId: (id: string) => string | undefined;
|
|
19
|
+
load: (this: {
|
|
20
|
+
addWatchFile?: (id: string) => void;
|
|
21
|
+
}, id: string) => Promise<string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Vite plugin that globs `SKILL.md` under `dir` at build time and serves a
|
|
25
|
+
* virtual module (default id `virtual:tanstack-skills`) exporting the catalog
|
|
26
|
+
* `as const`. Consumers then wrap it with `staticSkills` for a literal-union of
|
|
27
|
+
* skill names. The catalog is embedded as JSON, so the bundle hash tracks it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function skillsCatalogPlugin(options?: {
|
|
30
|
+
dir?: string;
|
|
31
|
+
virtualId?: string;
|
|
32
|
+
maxDepth?: number;
|
|
33
|
+
}): SkillsCatalogPlugin;
|