@tanstack/ai-skills
Version:
Portable Agent Skills (SKILL.md) as a first-class chat() middleware for TanStack AI.
158 lines (157 loc) • 5.36 kB
JavaScript
import { stableHash } from "./util.js";
//#region src/combinators.ts
/**
* Source combinators (spec §3.3). Sources compose in practice: org skills +
* project skills + tenant skills. `aggregate` concatenates, `dedupe` resolves
* collisions, `filter` hides, `cache` memoizes.
*/
/** Forward a source's optional `revision`, preserving `undefined` when absent. */
function forwardRevision(source) {
const rev = source.revision;
return rev ? () => rev() : void 0;
}
/** Route a delegating method to the first source that lists `name`. */
async function ownerOf(sources, name) {
for (const source of sources) if ((await source.list()).some((s) => s.name === name)) return source;
throw new Error(`no source provides a skill named "${name}"`);
}
async function combinedRevision(sources) {
const revs = await Promise.all(sources.map((s) => s.revision?.() ?? Promise.resolve(void 0)));
if (revs.some((r) => r === void 0)) return void 0;
return stableHash(revs.join("|"));
}
/** Concatenate sources in registration order. No dedupe. */
function aggregate(sources) {
return {
...sources.every((s) => s.revision) && { revision: async () => await combinedRevision(sources) ?? "" },
list: async () => {
return (await Promise.all(sources.map((s) => s.list()))).flat();
},
load: async (name) => (await ownerOf(sources, name)).load(name),
listResources: async (name) => {
return (await ownerOf(sources, name)).listResources?.(name) ?? [];
},
readResource: async (name, path) => {
const owner = await ownerOf(sources, name);
if (!owner.readResource) throw new Error(`skill "${name}" does not support resources`);
return owner.readResource(name, path);
},
listScripts: async (name) => {
return (await ownerOf(sources, name)).listScripts?.(name) ?? [];
},
readScript: async (name, path) => {
const owner = await ownerOf(sources, name);
if (!owner.readScript) throw new Error(`skill "${name}" does not support scripts`);
return owner.readScript(name, path);
}
};
}
/** First occurrence of a name wins; warns on collision. */
function dedupe(source, onCollision = (name) => console.warn(`[ai-skills] duplicate skill "${name}" — first one wins`)) {
return {
...source,
revision: forwardRevision(source),
list: async () => {
const seen = /* @__PURE__ */ new Set();
const out = [];
for (const skill of await source.list()) {
if (seen.has(skill.name)) {
onCollision(skill.name);
continue;
}
seen.add(skill.name);
out.push(skill);
}
return out;
}
};
}
/** Hide skills the predicate rejects. Filtered skills never reach the catalog. */
function filter(source, predicate, ctx) {
const list = async () => (await source.list()).filter((s) => predicate(s, ctx));
const assertVisible = async (name) => {
if (!(await list()).some((s) => s.name === name)) throw new Error(`no skill named "${name}"`);
};
return {
...source,
revision: forwardRevision(source),
list,
load: async (name) => {
await assertVisible(name);
return source.load(name);
},
listResources: source.listResources ? async (name) => {
await assertVisible(name);
return source.listResources?.(name) ?? [];
} : void 0,
readResource: source.readResource ? async (name, path) => {
await assertVisible(name);
const read = source.readResource;
if (!read) throw new Error(`skill "${name}" does not support resources`);
return read(name, path);
} : void 0,
listScripts: source.listScripts ? async (name) => {
await assertVisible(name);
return source.listScripts?.(name) ?? [];
} : void 0,
readScript: source.readScript ? async (name, path) => {
await assertVisible(name);
const read = source.readScript;
if (!read) throw new Error(`skill "${name}" does not support scripts`);
return read(name, path);
} : void 0
};
}
/**
* Memoize `list()`/`load()`. Concurrent `list()` calls share one underlying
* fetch. `refreshInterval` (ms) expires the memo; omit for forever.
*
* Never auto-applied by the middleware — caching a tenant-scoped source in a
* shared bucket would replay one tenant's skills for another. Opt in explicitly.
*/
function cache(source, opts = {}) {
let listPromise;
let listAt = 0;
const loads = /* @__PURE__ */ new Map();
const now = () => opts.refreshInterval ? Date.now() : 0;
const fresh = () => opts.refreshInterval === void 0 || now() - listAt < opts.refreshInterval;
return {
...source,
revision: forwardRevision(source),
list: () => {
if (!listPromise || !fresh()) {
listAt = now();
loads.clear();
listPromise = source.list().catch((err) => {
listPromise = void 0;
throw err;
});
}
return listPromise;
},
load: (name) => {
let p = loads.get(name);
if (!p) {
p = source.load(name).catch((err) => {
loads.delete(name);
throw err;
});
loads.set(name, p);
}
return p;
}
};
}
/**
* Combine the sources handed to the middleware. An array is deduped and
* aggregated; a single bare source is used as-is (never auto-wrapped).
*/
function combineSources(sources) {
if (!Array.isArray(sources)) return sources;
const [first] = sources;
if (sources.length === 1 && first) return first;
return dedupe(aggregate(sources));
}
//#endregion
export { aggregate, cache, combineSources, dedupe, filter };
//# sourceMappingURL=combinators.js.map