@juno-ai/bind 10.0.0 → 11.0.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.
@@ -0,0 +1,91 @@
1
+ import { serializeSkillMarkdown } from "./skill-md.js";
2
+ import { sha256Hex as defaultSha256Hex } from "./sha256.js";
3
+ /**
4
+ * The Agent Skills **discovery index** — how a deployment advertises skills to
5
+ * agents that are not its own.
6
+ *
7
+ * Per the Agent Skills Discovery RFC v0.2.0
8
+ * (https://github.com/cloudflare/agent-skills-discovery-rfc,
9
+ * https://agentskills.io/), an external agent fetches
10
+ * `/.well-known/agent-skills/index.json`, reads the `skills[]` catalogue, pulls
11
+ * each `SKILL.md` from its advertised `url`, and verifies the bytes against the
12
+ * published `digest`. Both documents are generated from the same registry here,
13
+ * so the digest a reader sees always matches the bytes served.
14
+ *
15
+ * **What to publish is a product decision and stays with the host** — hence the
16
+ * explicit `include` allowlist rather than "every platform skill". The
17
+ * criterion that matters is whether a skill helps an outside agent drive
18
+ * *something the deployment actually exposes externally*. A skill about the
19
+ * host's internal agent runtime cannot help an external client and does not
20
+ * belong in a public document; a plugin-contributed skill is tied to tools that
21
+ * are not on the external surface; a workspace's own skills are private data.
22
+ * Making the list an argument also means dropping a new file into a skills
23
+ * folder never publishes it by accident.
24
+ */
25
+ /** The RFC v0.2.0 schema URL advertised in the index's `$schema` field. */
26
+ export const AGENT_SKILLS_DISCOVERY_SCHEMA_URL = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
27
+ /** Root of the well-known discovery namespace (host-relative per RFC 3986). */
28
+ export const AGENT_SKILLS_WELL_KNOWN_BASE = "/.well-known/agent-skills";
29
+ /** Host-relative URL of the `SKILL.md` artifact for a skill name. */
30
+ export function skillMarkdownUrl(name, base = AGENT_SKILLS_WELL_KNOWN_BASE) {
31
+ return `${base}/${name}/SKILL.md`;
32
+ }
33
+ /**
34
+ * Canonical `SKILL.md` bytes for a published skill — exactly the text served at
35
+ * its `url` and hashed for its `digest`.
36
+ *
37
+ * `metadata` is null because a code skill has none to preserve: it is
38
+ * registered from name, description, hint and body, so anything else in the
39
+ * document it was authored from was already dropped at registration. Emitting
40
+ * it here would put bytes in the artifact that the digest of a re-registered
41
+ * skill could not reproduce.
42
+ */
43
+ function skillMarkdownFor(skill, yaml) {
44
+ return serializeSkillMarkdown({
45
+ name: skill.def.name,
46
+ description: skill.def.description,
47
+ whenToUse: skill.def.whenToUse ?? null,
48
+ body: skill.def.render(),
49
+ metadata: null,
50
+ }, yaml);
51
+ }
52
+ /** Only `platform`-origin skills are publishable; a name that is not one is not published. */
53
+ function publishable(name, registry) {
54
+ const skill = registry.get(name);
55
+ return skill && skill.origin === "platform" ? skill : null;
56
+ }
57
+ /** The allowlist as a set — de-duplicated, and O(1) to test. */
58
+ function allowlist(include) {
59
+ return include instanceof Set ? include : new Set(include);
60
+ }
61
+ /** Build the discovery index from the allowlisted, registered platform skills. */
62
+ export function buildAgentSkillsDiscoveryIndex(options) {
63
+ const sha256Hex = options.sha256Hex ?? defaultSha256Hex;
64
+ const skills = [...allowlist(options.include)]
65
+ .map((name) => publishable(name, options.registry))
66
+ .filter((skill) => skill !== null)
67
+ .sort((a, b) => a.def.name.localeCompare(b.def.name))
68
+ .map((skill) => ({
69
+ name: skill.def.name,
70
+ type: "skill-md",
71
+ description: skill.def.description,
72
+ url: skillMarkdownUrl(skill.def.name, options.baseUrl),
73
+ digest: `sha256:${sha256Hex(skillMarkdownFor(skill, options.yaml))}`,
74
+ }));
75
+ return { $schema: AGENT_SKILLS_DISCOVERY_SCHEMA_URL, skills };
76
+ }
77
+ /**
78
+ * The published `SKILL.md` text for a name, or `null` when it is not published.
79
+ *
80
+ * One return value for "not on the allowlist", "not registered", and
81
+ * "registered but not a platform skill": the caller serves a 404 for all three,
82
+ * and distinguishing them would tell an anonymous reader which internal skills
83
+ * exist.
84
+ */
85
+ export function getPublishedSkillMarkdown(name, options) {
86
+ // Allowlist first: an unknown name short-circuits without touching the registry.
87
+ if (!allowlist(options.include).has(name))
88
+ return null;
89
+ const skill = publishable(name, options.registry);
90
+ return skill ? skillMarkdownFor(skill, options.yaml) : null;
91
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@juno-ai/bind/skills` — progressive knowledge disclosure.
3
+ *
4
+ * The knowledge sibling of `@juno-ai/bind/plugins`: the same three-tier
5
+ * disclosure applied to instructions rather than tools. See `docs/skills.md`
6
+ * for the model and `docs/bind.md` for why each piece is on this side of the
7
+ * boundary.
8
+ */
9
+ export { SKILL_CATALOG_TOKEN_BUDGET, SKILL_CHARS_PER_TOKEN, SKILL_MAX_ACTIVE_BODY_TOKENS, SKILL_MAX_ACTIVE_PER_SESSION, estimateSkillTokens, type RegisteredSkill, type ResolvedSkillBody, type SkillDef, type SkillOrigin, type SkillResourceDef, type SkillResourceKind, type SkillResourceRef, type SkillSummary, type SkillWarn, } from "./types.js";
10
+ export { PLATFORM_SKILL_REF_PREFIX, codeSkillNameFromRef, codeSkillRef, isCodeSkillRef, } from "./refs.js";
11
+ export { sha256Hex, utf8ByteLength, type Sha256Hex } from "./sha256.js";
12
+ export { computeSkillContentSha, stableSkillJson, type SkillShaInput } from "./sha.js";
13
+ export { parseSkillMarkdown, serializeSkillMarkdown, type ParsedSkillMd, type SkillMdParseResult, type SkillYamlCodec, } from "./skill-md.js";
14
+ export { codeSkillResourceRefs, createSkillRegistry, toCodeSkillSummary, type SkillRegistry, type SkillRegistryOptions, } from "./registry.js";
15
+ export { compareSkillSummaries, partitionSkillCatalog, type SkillCatalogDetail, type SkillCatalogEntry, type SkillCatalogOptions, type SkillCatalogPartition, } from "./catalog.js";
16
+ export { resolveActiveSkillInstructions, wrapSkillContent, type ExternalSkillSource, type ResolveActiveSkillsParams, type ResolvedSkillInstructions, } from "./resolve.js";
17
+ export { createSkillActivation, type SkillActivation, type SkillActivationParams, type SkillStore, } from "./activation.js";
18
+ export { admitSkillLoad, estimateSkillBodyTokens, type SkillLoadBounds, type SkillLoadDecision, type SkillLoadRefusal, } from "./admission.js";
19
+ export { AGENT_SKILLS_DISCOVERY_SCHEMA_URL, AGENT_SKILLS_WELL_KNOWN_BASE, buildAgentSkillsDiscoveryIndex, getPublishedSkillMarkdown, skillMarkdownUrl, type AgentSkillsDiscoveryIndex, type DiscoverySkillEntry, type SkillDiscoveryOptions, } from "./discovery.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@juno-ai/bind/skills` — progressive knowledge disclosure.
3
+ *
4
+ * The knowledge sibling of `@juno-ai/bind/plugins`: the same three-tier
5
+ * disclosure applied to instructions rather than tools. See `docs/skills.md`
6
+ * for the model and `docs/bind.md` for why each piece is on this side of the
7
+ * boundary.
8
+ */
9
+ export { SKILL_CATALOG_TOKEN_BUDGET, SKILL_CHARS_PER_TOKEN, SKILL_MAX_ACTIVE_BODY_TOKENS, SKILL_MAX_ACTIVE_PER_SESSION, estimateSkillTokens, } from "./types.js";
10
+ export { PLATFORM_SKILL_REF_PREFIX, codeSkillNameFromRef, codeSkillRef, isCodeSkillRef, } from "./refs.js";
11
+ export { sha256Hex, utf8ByteLength } from "./sha256.js";
12
+ export { computeSkillContentSha, stableSkillJson } from "./sha.js";
13
+ export { parseSkillMarkdown, serializeSkillMarkdown, } from "./skill-md.js";
14
+ export { codeSkillResourceRefs, createSkillRegistry, toCodeSkillSummary, } from "./registry.js";
15
+ export { compareSkillSummaries, partitionSkillCatalog, } from "./catalog.js";
16
+ export { resolveActiveSkillInstructions, wrapSkillContent, } from "./resolve.js";
17
+ export { createSkillActivation, } from "./activation.js";
18
+ export { admitSkillLoad, estimateSkillBodyTokens, } from "./admission.js";
19
+ export { AGENT_SKILLS_DISCOVERY_SCHEMA_URL, AGENT_SKILLS_WELL_KNOWN_BASE, buildAgentSkillsDiscoveryIndex, getPublishedSkillMarkdown, skillMarkdownUrl, } from "./discovery.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Activation refs.
3
+ *
4
+ * A run persists which skills are loaded, and the set spans two sources whose
5
+ * identifiers cannot be told apart by shape — a code skill has a name, a
6
+ * host-stored skill has whatever id the host mints. Namespacing the code side
7
+ * makes the union unambiguous with one string comparison and no lookup, which
8
+ * matters because the resolver has to route every ref before it knows whether
9
+ * either source has it.
10
+ *
11
+ * The prefix is part of the persisted format. Changing it strands every stored
12
+ * active-skill set and every `loadedSkillShas` pin recorded against one.
13
+ */
14
+ /** The namespace every code-registered skill's ref carries. */
15
+ export declare const PLATFORM_SKILL_REF_PREFIX = "platform:";
16
+ /** Build the activation ref for a code-registered skill name. */
17
+ export declare function codeSkillRef(name: string): string;
18
+ /** True if a ref points at a code-registered skill rather than a host-stored one. */
19
+ export declare function isCodeSkillRef(ref: string): boolean;
20
+ /** Extract the skill name from a code-skill ref (`platform:foo` → `foo`). */
21
+ export declare function codeSkillNameFromRef(ref: string): string;
package/skills/refs.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Activation refs.
3
+ *
4
+ * A run persists which skills are loaded, and the set spans two sources whose
5
+ * identifiers cannot be told apart by shape — a code skill has a name, a
6
+ * host-stored skill has whatever id the host mints. Namespacing the code side
7
+ * makes the union unambiguous with one string comparison and no lookup, which
8
+ * matters because the resolver has to route every ref before it knows whether
9
+ * either source has it.
10
+ *
11
+ * The prefix is part of the persisted format. Changing it strands every stored
12
+ * active-skill set and every `loadedSkillShas` pin recorded against one.
13
+ */
14
+ /** The namespace every code-registered skill's ref carries. */
15
+ export const PLATFORM_SKILL_REF_PREFIX = "platform:";
16
+ /** Build the activation ref for a code-registered skill name. */
17
+ export function codeSkillRef(name) {
18
+ return `${PLATFORM_SKILL_REF_PREFIX}${name}`;
19
+ }
20
+ /** True if a ref points at a code-registered skill rather than a host-stored one. */
21
+ export function isCodeSkillRef(ref) {
22
+ return ref.startsWith(PLATFORM_SKILL_REF_PREFIX);
23
+ }
24
+ /** Extract the skill name from a code-skill ref (`platform:foo` → `foo`). */
25
+ export function codeSkillNameFromRef(ref) {
26
+ return ref.slice(PLATFORM_SKILL_REF_PREFIX.length);
27
+ }
@@ -0,0 +1,57 @@
1
+ import { type Sha256Hex } from "./sha256.js";
2
+ import type { RegisteredSkill, SkillDef, SkillResourceRef, SkillSummary, SkillWarn } from "./types.js";
3
+ /**
4
+ * The code-skill registry — what a deployment's own source contributes, as
5
+ * against what its users author.
6
+ *
7
+ * **A factory, not a module singleton**, for the same two reasons the tool
8
+ * registry is one: module state in a workerd isolate has a lifetime the host
9
+ * does not control, and an implicit global makes every test share a registry
10
+ * with every other. A host that wants singleton ergonomics wraps one instance
11
+ * in a module of its own — that decision belongs to the host.
12
+ *
13
+ * The registry is deliberately **not** the whole skill library. It holds code
14
+ * skills, whose bodies are build artifacts and whose version history is source
15
+ * control. Host-stored skills never enter it; they meet code skills only in the
16
+ * resolved {@link SkillSummary} catalog, which is source-agnostic by design.
17
+ */
18
+ export interface SkillRegistry {
19
+ /**
20
+ * Register a standalone skill with no owning plugin — available to every
21
+ * agent.
22
+ *
23
+ * `ownerPlugin` on the def is **ignored** and warned about. A platform skill
24
+ * is unowned by definition, and honouring a stray field here would make the
25
+ * loader auto-activate a plugin and mislabel the skill as that plugin's.
26
+ */
27
+ registerPlatform(def: SkillDef): void;
28
+ /**
29
+ * Register a skill contributed by a tool plugin. `ownerPlugin` is filled from
30
+ * `pluginName` rather than the def, so a recipe is only ever offered to an
31
+ * agent that can load the tools it describes.
32
+ */
33
+ registerPlugin(pluginName: string, def: SkillDef): void;
34
+ get(name: string): RegisteredSkill | undefined;
35
+ all(): RegisteredSkill[];
36
+ /** Tier-1 summaries, gated to the plugins available on this run. */
37
+ summaries(options?: {
38
+ availablePlugins?: Iterable<string>;
39
+ }): SkillSummary[];
40
+ /** Drop every registration. For a test that wants a controlled set. */
41
+ clear(): void;
42
+ }
43
+ export interface SkillRegistryOptions {
44
+ /**
45
+ * Observe a likely author bug: a name collision, or a platform registration
46
+ * carrying an owner. Both overwrite/ignore and continue — a bad skill must
47
+ * not take down agent startup — so this is the only signal they happened.
48
+ */
49
+ onWarn?: SkillWarn;
50
+ /** Override the content digest. Must be synchronous SHA-256 (see `sha256.ts`). */
51
+ sha256Hex?: Sha256Hex;
52
+ }
53
+ /** The Tier-3 listing for a code skill: metadata, with sizes measured in UTF-8 bytes. */
54
+ export declare function codeSkillResourceRefs(def: SkillDef): SkillResourceRef[];
55
+ /** Project a registered code skill into the source-agnostic Tier-1 summary. */
56
+ export declare function toCodeSkillSummary(skill: RegisteredSkill): SkillSummary;
57
+ export declare function createSkillRegistry(options?: SkillRegistryOptions): SkillRegistry;
@@ -0,0 +1,94 @@
1
+ import { codeSkillRef } from "./refs.js";
2
+ import { computeSkillContentSha } from "./sha.js";
3
+ import { sha256Hex as defaultSha256Hex, utf8ByteLength } from "./sha256.js";
4
+ /** The Tier-3 listing for a code skill: metadata, with sizes measured in UTF-8 bytes. */
5
+ export function codeSkillResourceRefs(def) {
6
+ return (def.resources ?? []).map((resource) => ({
7
+ path: resource.path,
8
+ kind: resource.kind,
9
+ bytes: utf8ByteLength(resource.render()),
10
+ }));
11
+ }
12
+ /** Project a registered code skill into the source-agnostic Tier-1 summary. */
13
+ export function toCodeSkillSummary(skill) {
14
+ return {
15
+ ref: codeSkillRef(skill.def.name),
16
+ name: skill.def.name,
17
+ description: skill.def.description,
18
+ whenToUse: skill.def.whenToUse ?? null,
19
+ ownerPlugin: skill.ownerPlugin,
20
+ origin: skill.origin,
21
+ };
22
+ }
23
+ export function createSkillRegistry(options = {}) {
24
+ const skills = new Map();
25
+ const sha256Hex = options.sha256Hex ?? defaultSha256Hex;
26
+ const warn = options.onWarn ?? (() => { });
27
+ const digestResources = (resources) => (resources ?? []).map((resource) => ({
28
+ path: resource.path,
29
+ contentType: resource.contentType,
30
+ contentHash: sha256Hex(resource.render()),
31
+ }));
32
+ const register = (def, origin, ownerPlugin) => {
33
+ const existing = skills.get(def.name);
34
+ // Re-registering the *same* def is ordinary — a module imported twice, a
35
+ // host that re-runs its bootstrap — and is silent. Two distinct defs under
36
+ // one name is a copy-paste or a cross-plugin clash: last wins, loudly.
37
+ if (existing && existing.def !== def) {
38
+ warn("code skill name collision — overwriting", {
39
+ name: def.name,
40
+ previousOrigin: existing.origin,
41
+ previousOwnerPlugin: existing.ownerPlugin,
42
+ newOrigin: origin,
43
+ newOwnerPlugin: ownerPlugin,
44
+ });
45
+ }
46
+ const contentSha = computeSkillContentSha({
47
+ name: def.name,
48
+ description: def.description,
49
+ whenToUse: def.whenToUse ?? null,
50
+ body: def.render(),
51
+ ownerPlugin,
52
+ metadata: null,
53
+ resources: digestResources(def.resources),
54
+ }, sha256Hex);
55
+ skills.set(def.name, { def, origin, ownerPlugin, contentSha });
56
+ };
57
+ return {
58
+ registerPlatform(def) {
59
+ if (def.ownerPlugin != null) {
60
+ warn("platform skill declared an ownerPlugin; ignoring it", {
61
+ name: def.name,
62
+ ignoredOwnerPlugin: def.ownerPlugin,
63
+ });
64
+ }
65
+ register(def, "platform", null);
66
+ },
67
+ registerPlugin(pluginName, def) {
68
+ register({ ...def, ownerPlugin: pluginName }, "plugin", pluginName);
69
+ },
70
+ get(name) {
71
+ return skills.get(name);
72
+ },
73
+ all() {
74
+ return [...skills.values()];
75
+ },
76
+ summaries(summaryOptions) {
77
+ const availablePlugins = summaryOptions?.availablePlugins === undefined
78
+ ? undefined
79
+ : new Set(summaryOptions.availablePlugins);
80
+ const out = [];
81
+ for (const skill of skills.values()) {
82
+ if (skill.origin === "plugin" && availablePlugins) {
83
+ if (!skill.ownerPlugin || !availablePlugins.has(skill.ownerPlugin))
84
+ continue;
85
+ }
86
+ out.push(toCodeSkillSummary(skill));
87
+ }
88
+ return out;
89
+ },
90
+ clear() {
91
+ skills.clear();
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,89 @@
1
+ import type { RegisteredSkill, ResolvedSkillBody, SkillResourceRef, SkillSummary, SkillWarn } from "./types.js";
2
+ /**
3
+ * Tier 2 — resolving the bodies of the skills a run has loaded.
4
+ *
5
+ * The bodies land in the **system prompt**, not the conversation tail, which is
6
+ * the decision the rest of this module follows from. It makes them immune to
7
+ * compaction (an agent cannot forget an instruction it was given the way it
8
+ * forgets a message), and it puts them inside the provider's cacheable prefix —
9
+ * so their order and their bytes have to be a pure function of the run's state,
10
+ * or every turn pays for a cache miss on everything below them.
11
+ */
12
+ /**
13
+ * Resolve the bodies of the host's own skills — the ones the registry does not
14
+ * hold. Batched rather than per-ref because the natural implementation is a
15
+ * query, and a per-ref port turns one query into N.
16
+ *
17
+ * `pinnedShas` is a subset of the caller's pin map covering only these refs. A
18
+ * source that can reproduce a historical body should return it; one that cannot
19
+ * should return its current body and leave the drift for the caller to notice
20
+ * (the resolver records what it actually resolved, never what was asked for).
21
+ * Refs the source omits are dropped from the result, which is the right answer
22
+ * for a skill that was deleted mid-session.
23
+ */
24
+ export type ExternalSkillSource = (refs: string[], pinnedShas: Record<string, string> | undefined) => Promise<ReadonlyMap<string, ResolvedSkillBody>>;
25
+ export interface ResolveActiveSkillsParams {
26
+ /** The run's active refs, in whatever order the host stored them. */
27
+ activeRefs: readonly string[];
28
+ /** This run's Tier-1 catalog. A ref absent from it resolves to nothing. */
29
+ available: readonly SkillSummary[];
30
+ /** Where code refs (`platform:<name>`) resolve from. */
31
+ registry: {
32
+ get(name: string): RegisteredSkill | undefined;
33
+ };
34
+ /** Where every other ref resolves from. Omit for a code-only deployment. */
35
+ externalSource?: ExternalSkillSource;
36
+ /**
37
+ * Ref → `contentSha` recorded by a completed run. Present = **pinned mode**:
38
+ * reproduce what that run saw, for a replay, an eval, or an optimizer.
39
+ * Absent = live head, which is what an ordinary turn wants — a skill edited
40
+ * mid-session should take effect on the next turn.
41
+ */
42
+ pinnedShas?: Record<string, string>;
43
+ /**
44
+ * Skills resolvable by name that are **not** in the registry, consulted
45
+ * before it.
46
+ *
47
+ * This is the isolation seam. A host may want an always-on instruction module
48
+ * — an onboarding persona, a per-agent operating manual — that flows through
49
+ * this one resolver alongside the standard library but must never appear in
50
+ * any catalog and must never be loadable by name from a model's tool call.
51
+ * Registering it globally would do both. Passing it here does neither.
52
+ */
53
+ extraSkills?: ReadonlyMap<string, RegisteredSkill>;
54
+ onWarn?: SkillWarn;
55
+ }
56
+ export interface ResolvedSkillInstructions {
57
+ /** One wrapped body per resolved ref, in catalog order. */
58
+ instructions: string[];
59
+ /** Ref → the `contentSha` actually resolved. The run's pin. */
60
+ shas: Record<string, string>;
61
+ }
62
+ /**
63
+ * Wrap a resolved body for in-prompt identification.
64
+ *
65
+ * The wrapper is not decoration: several bodies are concatenated into one
66
+ * section, and without a delimiter a model attributes an instruction from one
67
+ * skill to another — or to the host's own system prompt, which is worse,
68
+ * because it then applies workspace-authored text with the authority of the
69
+ * platform. The attributes are what let it cite which skill it is following,
70
+ * and the resource block is what tells it a Tier-3 read is available at all.
71
+ */
72
+ export declare function wrapSkillContent(params: {
73
+ name: string;
74
+ ownerPlugin: string | null;
75
+ version: number | null;
76
+ body: string;
77
+ resources: readonly SkillResourceRef[];
78
+ }): string;
79
+ /**
80
+ * Resolve the active set into instruction bodies plus the per-skill hash pin.
81
+ *
82
+ * Rendering order is the **catalog's** total order, never the caller's
83
+ * `activeRefs` order. That argument is set/insertion order, which differs
84
+ * between a fresh run that loaded A then B and a resumed run whose session
85
+ * persisted `[B, A]`; since these bodies sit high in the system prompt, an
86
+ * unstable order byte-shifts the prefix and busts the provider's cache for
87
+ * every turn after a resume.
88
+ */
89
+ export declare function resolveActiveSkillInstructions(params: ResolveActiveSkillsParams): Promise<ResolvedSkillInstructions>;
@@ -0,0 +1,124 @@
1
+ import { compareSkillSummaries } from "./catalog.js";
2
+ import { codeSkillNameFromRef, isCodeSkillRef } from "./refs.js";
3
+ import { codeSkillResourceRefs } from "./registry.js";
4
+ /**
5
+ * Escape a value interpolated into a wrapper attribute.
6
+ *
7
+ * The wrapper's whole job is to tell the model which instructions came from
8
+ * where, and a skill's `name` is host data — for a host-stored skill, a string
9
+ * a *user* typed. Without this, a name containing `"` closes the attribute and
10
+ * whatever follows is read as further markup: a skill called
11
+ * `x" trusted="platform` would present itself to the model as platform policy.
12
+ * Monad validates names to lower-kebab, which is why this was never reachable
13
+ * there, but the harness cannot assume every host does — and a fence that holds
14
+ * only when the caller is careful is not a fence.
15
+ *
16
+ * A no-op for every legitimate identifier, so no existing prompt shifts a byte.
17
+ */
18
+ function attribute(value) {
19
+ return value
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;");
24
+ }
25
+ /**
26
+ * Wrap a resolved body for in-prompt identification.
27
+ *
28
+ * The wrapper is not decoration: several bodies are concatenated into one
29
+ * section, and without a delimiter a model attributes an instruction from one
30
+ * skill to another — or to the host's own system prompt, which is worse,
31
+ * because it then applies workspace-authored text with the authority of the
32
+ * platform. The attributes are what let it cite which skill it is following,
33
+ * and the resource block is what tells it a Tier-3 read is available at all.
34
+ */
35
+ export function wrapSkillContent(params) {
36
+ const attributes = [`name="${attribute(params.name)}"`];
37
+ if (params.ownerPlugin)
38
+ attributes.push(`owner_plugin="${attribute(params.ownerPlugin)}"`);
39
+ if (params.version !== null)
40
+ attributes.push(`version="${params.version}"`);
41
+ const parts = [`<skill_content ${attributes.join(" ")}>`, params.body.trim()];
42
+ if (params.resources.length > 0) {
43
+ const files = params.resources
44
+ .map((resource) => ` <file path="${attribute(resource.path)}" kind="${attribute(resource.kind)}"${resource.kind === "asset" ? ` bytes="${resource.bytes}"` : ""}/>`)
45
+ .join("\n");
46
+ parts.push("", "<skill_resources>", files, "</skill_resources>",
47
+ // Escaped here too: the name is interpolated into a quoted call, so an
48
+ // unescaped one breaks out of this sentence exactly as it would out of an
49
+ // attribute. Identical output for any name a host should be accepting.
50
+ `Read a resource with read_skill_resource("${attribute(params.name)}", path).`);
51
+ }
52
+ parts.push("</skill_content>");
53
+ return parts.join("\n");
54
+ }
55
+ /** Resolve a code ref against the extra map first, then the registry. */
56
+ function resolveCodeSkill(ref, params) {
57
+ const name = codeSkillNameFromRef(ref);
58
+ const registered = params.extraSkills?.get(name) ?? params.registry.get(name);
59
+ if (!registered)
60
+ return null;
61
+ const pinned = params.pinnedShas?.[ref];
62
+ if (pinned && registered.contentSha !== pinned) {
63
+ // A code skill's history is the deployment's source control, and the
64
+ // running build has only its own copy — the body at the pinned hash is not
65
+ // recoverable here. Reproduce best-effort and flag it, so a consumer
66
+ // comparing runs can see that this one is not a faithful replay.
67
+ params.onWarn?.("pinned code skill drifted since the run; using current body", {
68
+ ref,
69
+ pinnedSha: pinned,
70
+ currentSha: registered.contentSha,
71
+ });
72
+ }
73
+ return {
74
+ name: registered.def.name,
75
+ ownerPlugin: registered.ownerPlugin,
76
+ version: null,
77
+ body: registered.def.render(),
78
+ contentSha: registered.contentSha,
79
+ resources: codeSkillResourceRefs(registered.def),
80
+ };
81
+ }
82
+ /**
83
+ * Resolve the active set into instruction bodies plus the per-skill hash pin.
84
+ *
85
+ * Rendering order is the **catalog's** total order, never the caller's
86
+ * `activeRefs` order. That argument is set/insertion order, which differs
87
+ * between a fresh run that loaded A then B and a resumed run whose session
88
+ * persisted `[B, A]`; since these bodies sit high in the system prompt, an
89
+ * unstable order byte-shifts the prefix and busts the provider's cache for
90
+ * every turn after a resume.
91
+ */
92
+ export async function resolveActiveSkillInstructions(params) {
93
+ const byRef = new Map(params.available.map((summary) => [summary.ref, summary]));
94
+ const present = params.activeRefs
95
+ .map((ref) => byRef.get(ref))
96
+ .filter((summary) => summary !== undefined)
97
+ .sort(compareSkillSummaries)
98
+ .map((summary) => summary.ref);
99
+ const externalRefs = present.filter((ref) => !isCodeSkillRef(ref));
100
+ const external = externalRefs.length > 0 && params.externalSource
101
+ ? await params.externalSource(externalRefs, params.pinnedShas)
102
+ : new Map();
103
+ const instructions = [];
104
+ const shas = {};
105
+ for (const ref of present) {
106
+ const resolved = isCodeSkillRef(ref)
107
+ ? resolveCodeSkill(ref, params)
108
+ : (external.get(ref) ?? null);
109
+ // A ref that resolves to nothing is skipped rather than reported: it means
110
+ // the skill was deleted, renamed, or gated off since it was loaded, and a
111
+ // run should carry on without the instruction rather than fail on it.
112
+ if (!resolved)
113
+ continue;
114
+ instructions.push(wrapSkillContent({
115
+ name: resolved.name,
116
+ ownerPlugin: resolved.ownerPlugin,
117
+ version: resolved.version,
118
+ body: resolved.body,
119
+ resources: resolved.resources,
120
+ }));
121
+ shas[ref] = resolved.contentSha;
122
+ }
123
+ return { instructions, shas };
124
+ }
@@ -0,0 +1,53 @@
1
+ import { sha256Hex as defaultSha256Hex, type Sha256Hex } from "./sha256.js";
2
+ /**
3
+ * A skill's content hash — the value that makes a replay honest.
4
+ *
5
+ * A run records which skills it loaded, and an eval, a regression harness, or a
6
+ * GEPA-style optimizer later asks what the agent was actually told. The name is
7
+ * not enough: skills are edited. So a run pins a hash per loaded skill, and the
8
+ * hash must cover **every input that changes the model's instructions** — body,
9
+ * catalog line, activation hint, owner, preserved frontmatter, and each bundled
10
+ * resource. Anything left out is a way to change what the agent reads while the
11
+ * pin still claims the run is reproducible.
12
+ *
13
+ * Resources are hashed by digest rather than by content so a large reference
14
+ * costs one pass, and they are sorted by path so listing order — which is
15
+ * incidental, and differs between a directory scan and a hand-written array —
16
+ * never changes the result.
17
+ */
18
+ export interface SkillShaInput {
19
+ name: string;
20
+ description: string;
21
+ whenToUse: string | null;
22
+ body: string;
23
+ ownerPlugin: string | null;
24
+ /** Preserved unknown frontmatter keys, or null. */
25
+ metadata: Record<string, unknown> | null;
26
+ /** One entry per resource; order-independent. */
27
+ resources: ReadonlyArray<{
28
+ path: string;
29
+ contentType: string;
30
+ contentHash: string;
31
+ }>;
32
+ }
33
+ /**
34
+ * Deterministic, compact JSON with object keys sorted recursively — the
35
+ * canonical form the content hash is taken over.
36
+ *
37
+ * Two details are load-bearing. Keys are sorted because a `metadata` object
38
+ * that arrived from a YAML parse, a JSON column, or a hand-written literal has
39
+ * no reliable key order, and unsorted output would rotate the hash for content
40
+ * nobody edited. A `Date` serializes to its ISO string because `typeof
41
+ * new Date() === "object"` with no own keys, so recursing into it would
42
+ * collapse every distinct timestamp to `{}` — and a YAML timestamp in
43
+ * frontmatter is exactly how one gets here.
44
+ */
45
+ export declare function stableSkillJson(value: unknown): string;
46
+ /**
47
+ * Compute a skill's content hash from its version-defining inputs.
48
+ *
49
+ * `sha256Hex` defaults to the package's own implementation; pass a host digest
50
+ * only if it is synchronous and genuinely SHA-256 (see `sha256.ts`).
51
+ */
52
+ export declare function computeSkillContentSha(input: SkillShaInput, sha256Hex?: Sha256Hex): string;
53
+ export { defaultSha256Hex as sha256Hex, type Sha256Hex };
package/skills/sha.js ADDED
@@ -0,0 +1,60 @@
1
+ import { sha256Hex as defaultSha256Hex } from "./sha256.js";
2
+ /**
3
+ * Deterministic, compact JSON with object keys sorted recursively — the
4
+ * canonical form the content hash is taken over.
5
+ *
6
+ * Two details are load-bearing. Keys are sorted because a `metadata` object
7
+ * that arrived from a YAML parse, a JSON column, or a hand-written literal has
8
+ * no reliable key order, and unsorted output would rotate the hash for content
9
+ * nobody edited. A `Date` serializes to its ISO string because `typeof
10
+ * new Date() === "object"` with no own keys, so recursing into it would
11
+ * collapse every distinct timestamp to `{}` — and a YAML timestamp in
12
+ * frontmatter is exactly how one gets here.
13
+ */
14
+ export function stableSkillJson(value) {
15
+ return JSON.stringify(sortKeys(value));
16
+ }
17
+ function sortKeys(value) {
18
+ if (Array.isArray(value))
19
+ return value.map(sortKeys);
20
+ if (value instanceof Date)
21
+ return value.toISOString();
22
+ if (value && typeof value === "object") {
23
+ const source = value;
24
+ const sorted = {};
25
+ for (const key of Object.keys(source).sort())
26
+ sorted[key] = sortKeys(source[key]);
27
+ return sorted;
28
+ }
29
+ return value;
30
+ }
31
+ /**
32
+ * Compute a skill's content hash from its version-defining inputs.
33
+ *
34
+ * `sha256Hex` defaults to the package's own implementation; pass a host digest
35
+ * only if it is synchronous and genuinely SHA-256 (see `sha256.ts`).
36
+ */
37
+ export function computeSkillContentSha(input, sha256Hex = defaultSha256Hex) {
38
+ const canonical = {
39
+ name: input.name,
40
+ description: input.description,
41
+ whenToUse: input.whenToUse,
42
+ body: input.body,
43
+ ownerPlugin: input.ownerPlugin,
44
+ // Stringified rather than nested so a `metadata` key named `body` or
45
+ // `resources` cannot collide with a sibling of the canonical object.
46
+ metadata: stableSkillJson(input.metadata),
47
+ resources: [...input.resources]
48
+ .map((resource) => ({
49
+ path: resource.path,
50
+ contentType: resource.contentType,
51
+ contentHash: resource.contentHash,
52
+ }))
53
+ // `localeCompare`, not a code-unit comparison, because hosts already
54
+ // store hashes computed this way and any reordering rotates them —
55
+ // orphaning every version snapshot a pinned replay resolves against.
56
+ .sort((a, b) => a.path.localeCompare(b.path)),
57
+ };
58
+ return sha256Hex(stableSkillJson(canonical));
59
+ }
60
+ export { defaultSha256Hex as sha256Hex };