diff options
25 files changed, 1067 insertions, 4 deletions
diff --git a/GLOSSARY.md b/GLOSSARY.md index 39e618f..259dc27 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -43,6 +43,9 @@ | **key** | The API key (the secret string) held by a credential. | apiKey / api-key (when meaning the whole credential profile) | | **model name** | The selectable identifier in `<credentialName>/<model>` form — what the model catalog lists and what the CLI / `/chat` `model` field take. | model reference, model id | | **model catalog** | The list of available model names; served by `GET /models`, aggregated per credential from each provider's `listModels()`. | model list | +| **skill** | A reusable instruction document (markdown) under a `.skills/` directory, loaded on demand into the conversation by the `load_skill` tool. Discovered from `~/.skills` (home) and `<cwd>/.skills` (project); on a name clash the cwd skill shadows the home one. A skill's name is its filename without `.md`. | prompt snippet, macro | +| **skill summary** | A skill file's "when to use this skill" line: line 1 of the md, valid only when line 2 is exactly `---`. Advertised (per-turn, cwd-aware) in the `load_skill` tool's description; on load the first two lines are stripped. A file lacking the `---` delimiter shows no summary but stays loadable. | — | +| **tools filter** | The per-turn `FilterDescriptor` (`toolsFilter`, owned by `session-orchestrator`) through which extensions transform a turn's tool set before it reaches `runTurn`. Applied ONCE per turn so the tool definitions stay byte-stable across steps (prompt-cache safe). The first concrete use of the §3.2 context-assembly filter chain. | — | ## Known vocabulary drift @@ -51,6 +51,7 @@ "@dispatch/kernel": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/skills": "workspace:*", "@dispatch/storage-sqlite": "workspace:*", "@dispatch/surface-loaded-extensions": "workspace:*", "@dispatch/surface-registry": "workspace:*", @@ -102,6 +103,14 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/skills": { + "name": "@dispatch/skills", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + }, + }, "packages/storage-sqlite": { "name": "@dispatch/storage-sqlite", "version": "0.0.0", @@ -252,6 +261,8 @@ "@dispatch/session-orchestrator": ["@dispatch/session-orchestrator@workspace:packages/session-orchestrator"], + "@dispatch/skills": ["@dispatch/skills@workspace:packages/skills"], + "@dispatch/storage-sqlite": ["@dispatch/storage-sqlite@workspace:packages/storage-sqlite"], "@dispatch/surface-loaded-extensions": ["@dispatch/surface-loaded-extensions@workspace:packages/surface-loaded-extensions"], diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index 9d29e4d..6a1d24a 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -11,6 +11,7 @@ "@dispatch/credential-store": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/skills": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/transport-http": "workspace:*", "@dispatch/tool-read-file": "workspace:*", diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 588dfb8..ef75f55 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -20,6 +20,7 @@ import { } from "@dispatch/kernel"; import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat"; import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator"; +import { extension as skillsExt } from "@dispatch/skills"; import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite"; import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions"; import { createSurfaceRegistryExtension } from "@dispatch/surface-registry"; @@ -71,6 +72,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [ toolWriteFileExt, throughputStoreExt, sessionOrchestratorExt, + skillsExt, createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. createSurfaceRegistryExtension(), diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json index 9fedaf9..b357c51 100644 --- a/packages/host-bin/tsconfig.json +++ b/packages/host-bin/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../tool-shell" }, { "path": "../tool-edit-file" }, { "path": "../tool-write-file" }, + { "path": "../skills" }, { "path": "../throughput-store" }, { "path": "../transport-http" }, { "path": "../transport-ws" } diff --git a/packages/kernel/src/contracts/extension.ts b/packages/kernel/src/contracts/extension.ts index 1760cf9..5a7821b 100644 --- a/packages/kernel/src/contracts/extension.ts +++ b/packages/kernel/src/contracts/extension.ts @@ -196,6 +196,26 @@ export interface HostAPI { fn: FilterHandler<TValue>, ) => () => void; + /** + * Run a filter chain: thread `value` through every filter registered for + * `hook` in priority/registration order and return the final value. The + * single-value-in/value-out counterpart to `addFilter`. Awaited in-band. + * + * Fail-open by default (a thrown filter is logged and the value passes + * through unchanged); pass `{ failClosed: true }` to make a thrown filter + * reject. With no registered filters the input value is returned as-is. + * + * This is what lets a core extension expose a contribution point (e.g. the + * session-orchestrator running a per-turn tool/context-assembly chain) that + * standard extensions plug into via `addFilter` — the kernel owns the + * mechanism, the owner declares the typed `FilterDescriptor`. + */ + readonly applyFilters: <TValue>( + hook: FilterDescriptor<TValue>, + value: TValue, + opts?: { readonly failClosed?: boolean }, + ) => Promise<TValue>; + /** Provide an implementation for a typed service handle. */ readonly provideService: <T>(handle: ServiceHandle<T>, impl: T) => void; diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts index 106dd56..669d093 100644 --- a/packages/kernel/src/host/host.test.ts +++ b/packages/kernel/src/host/host.test.ts @@ -15,7 +15,7 @@ import type { SecretsAccess, StorageNamespace, } from "../contracts/extension.js"; -import { defineEventHook, defineService } from "../contracts/hooks.js"; +import { defineEventHook, defineFilter, defineService } from "../contracts/hooks.js"; import type { Attributes, ErrorAttributes, @@ -617,6 +617,39 @@ describe("createHost", () => { expect(received).toEqual(["hello"]); }); + it("applyFilters threads a value through registered filters in order", async () => { + const hook = defineFilter<string>("test/text-transform"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-first`); + host.addFilter(hook, (value) => `${value}-second`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "start"); + expect(result).toBe("start-first-second"); + }); + + it("applyFilters returns the input unchanged when no filters are registered", async () => { + const hook = defineFilter<string>("test/unused-filter"); + + const ext = createExtension("no-filter-ext", { + activate: () => {}, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "unchanged"); + expect(result).toBe("unchanged"); + }); + it("storage delegates to the factory", async () => { let storageResult: StorageNamespace | undefined; @@ -925,6 +958,23 @@ describe("createHost", () => { "Registration not available after activation", ); }); + + it("applyFilters is available on registration-closed HostAPI", async () => { + const hook = defineFilter<string>("test/closed-filter"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-filtered`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "input"); + expect(result).toBe("input-filtered"); + }); }); describe("auto-scoped logger (D6)", () => { diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts index 8aa4f78..a6396a9 100644 --- a/packages/kernel/src/host/host.ts +++ b/packages/kernel/src/host/host.ts @@ -125,6 +125,13 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho addFilter<TValue>(hook: FilterDescriptor<TValue>, fn: FilterHandler<TValue>) { return deps.bus.addFilter(hook, fn); }, + async applyFilters<TValue>( + hook: FilterDescriptor<TValue>, + value: TValue, + opts?: { readonly failClosed?: boolean }, + ): Promise<TValue> { + return deps.bus.applyFilters(hook, value, opts); + }, provideService<T>(handle: ServiceHandle<T>, impl: T) { deps.bus.provideService(handle, impl); }, diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index fbb7c15..697eb4a 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -8,6 +8,7 @@ import { sessionOrchestratorHandle, } from "./orchestrator.js"; import { selectFirstProvider } from "./pure.js"; +import { toolsFilter } from "./tools-filter.js"; export const manifest: Manifest = { id: "session-orchestrator", @@ -36,6 +37,7 @@ export function activate(host: HostAPI): void { const provider = host.getProviders().get(r.providerId); return provider ? { provider, model: r.model } : undefined; }, + applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), runTurn, logger: host.logger, now: () => Date.now(), diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts index 3270c46..071b616 100644 --- a/packages/session-orchestrator/src/index.ts +++ b/packages/session-orchestrator/src/index.ts @@ -11,3 +11,4 @@ export { generateTurnId, selectFirstProvider, } from "./pure.js"; +export { type ToolAssembly, toolsFilter } from "./tools-filter.js"; diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index ea564c5..dcaad7d 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -13,6 +13,7 @@ import type { import { runTurn } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { createSessionOrchestrator } from "./orchestrator.js"; +import type { ToolAssembly } from "./tools-filter.js"; function createInMemoryStore(): ConversationStore & { readonly data: Map<string, ChatMessage[]>; @@ -87,6 +88,10 @@ function createFakeTool( }; } +function identityApplyToolsFilter(assembly: ToolAssembly): Promise<ToolAssembly> { + return Promise.resolve(assembly); +} + describe("handleMessage integration", () => { it("loads history, runs turn, emits events, and persists result", async () => { const store = createInMemoryStore(); @@ -103,6 +108,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -154,6 +160,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -200,6 +207,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -229,6 +237,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveDispatch: () => ({ maxConcurrent: 4, eager: false }), runTurn, }); @@ -277,6 +286,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => fallbackProvider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveModel: (name) => { if (name === "cred/gpt-4") return { provider: resolvedProvider, model: "gpt-4" }; return undefined; @@ -308,6 +318,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => fallbackProvider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveModel: () => undefined, runTurn: captureRunTurn, }); @@ -338,6 +349,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => fallbackProvider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveModel: () => ({ provider: { id: "should-not-use", stream: async function* () {} }, model: "x", @@ -365,6 +377,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn: captureRunTurn, }); @@ -398,6 +411,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn: captureRunTurn, now: fakeNow, }); @@ -422,6 +436,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn: captureRunTurn, }); @@ -450,6 +465,7 @@ describe("turn-sealed event", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -502,6 +518,7 @@ describe("turn-sealed event", () => { conversationStore: wrappedStore, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -548,6 +565,7 @@ describe("turn-sealed event", () => { conversationStore: failingStore, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -581,6 +599,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: () => 1000, }); @@ -640,6 +659,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: () => 1000, }); @@ -698,6 +718,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: clock.now, }); @@ -764,6 +785,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: () => 1000, }); @@ -817,6 +839,7 @@ describe("turn metrics persistence", () => { conversationStore: failingMetricsStore, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -836,6 +859,102 @@ describe("turn metrics persistence", () => { }); }); +describe("tools filter", () => { + it("applies the tools filter once and passes the result to runTurn", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); + const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); + + let filterCallCount = 0; + const transformingFilter = (assembly: ToolAssembly): Promise<ToolAssembly> => { + filterCallCount++; + return Promise.resolve({ ...assembly, tools: [toolB] }); + }; + + const orchestrator = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [toolA], + applyToolsFilter: transformingFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-once", + text: "hi", + onEvent: () => {}, + }); + + expect(filterCallCount).toBe(1); + expect(captured).toHaveLength(1); + expect(captured[0]?.tools).toHaveLength(1); + expect(captured[0]?.tools[0]?.name).toBe("tool-b"); + }); + + it("tools filter identity is a no-op (same tools reach runTurn)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); + const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); + + const orchestrator = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [toolA, toolB], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-identity", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.tools).toHaveLength(2); + expect(captured[0]?.tools[0]?.name).toBe("tool-a"); + expect(captured[0]?.tools[1]?.name).toBe("tool-b"); + }); + + it("threads cwd and conversationId into the tool assembly", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + let receivedAssembly: ToolAssembly | undefined; + const capturingFilter = (assembly: ToolAssembly): Promise<ToolAssembly> => { + receivedAssembly = assembly; + return Promise.resolve(assembly); + }; + + const orchestrator = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: capturingFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-threads", + text: "hi", + onEvent: () => {}, + cwd: "/test/dir", + }); + + expect(receivedAssembly).toBeDefined(); + expect(receivedAssembly?.conversationId).toBe("conv-filter-threads"); + expect(receivedAssembly?.cwd).toBe("/test/dir"); + expect(receivedAssembly?.tools).toEqual([]); + }); +}); + function createCounterNow(): { now: () => number; tick: (ms: number) => void } { let t = 0; return { diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index d84b805..2d1bbf5 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -13,6 +13,7 @@ import type { import { defineService } from "@dispatch/kernel"; import { createMetricsAccumulator } from "./metrics.js"; import { buildUserMessage, defaultDispatchPolicy, generateTurnId } from "./pure.js"; +import type { ToolAssembly } from "./tools-filter.js"; export interface SessionOrchestrator { handleMessage(input: { @@ -38,6 +39,8 @@ export interface SessionOrchestratorDeps { modelName: string, ) => { provider: ProviderContract; model: string } | undefined; readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; + /** Apply the per-turn tools filter chain. Injected for testability. */ + readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>; /** Base logger (auto-scoped to this extension); childed per turn for span capture. */ readonly logger?: Logger; /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */ @@ -71,7 +74,12 @@ export function createSessionOrchestrator(deps: SessionOrchestratorDeps): Sessio provider = deps.resolveProvider(); } - const tools = deps.resolveTools(); + const baseTools = deps.resolveTools(); + const assembled = await deps.applyToolsFilter({ + tools: baseTools, + conversationId, + ...(cwd !== undefined ? { cwd } : {}), + }); const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); const turnLogger = deps.logger?.child({ conversationId, turnId }); const metrics = createMetricsAccumulator(); @@ -84,7 +92,7 @@ export function createSessionOrchestrator(deps: SessionOrchestratorDeps): Sessio const opts: RunTurnInput = { provider, messages: [...history, userMsg], - tools, + tools: assembled.tools, dispatch, emit: emitAndAccumulate, conversationId, diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts new file mode 100644 index 0000000..19b2eb3 --- /dev/null +++ b/packages/session-orchestrator/src/tools-filter.ts @@ -0,0 +1,16 @@ +import { defineFilter, type FilterDescriptor, type ToolContract } from "@dispatch/kernel"; + +/** Per-turn tool-assembly value threaded through the `tools` filter chain. */ +export interface ToolAssembly { + /** The tool set resolved for this turn (the value filters transform). */ + readonly tools: readonly ToolContract[]; + /** This turn's working directory (verbatim from the request), for cwd-aware filters. */ + readonly cwd?: string; + /** The conversation this turn belongs to. */ + readonly conversationId: string; +} + +/** Filter chain run once per turn to transform the tool set before it reaches runTurn. */ +export const toolsFilter: FilterDescriptor<ToolAssembly> = defineFilter<ToolAssembly>( + "session-orchestrator/tools", +); diff --git a/packages/skills/package.json b/packages/skills/package.json new file mode 100644 index 0000000..514cdc7 --- /dev/null +++ b/packages/skills/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dispatch/skills", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*" + } +} diff --git a/packages/skills/src/extension.ts b/packages/skills/src/extension.ts new file mode 100644 index 0000000..e8000e4 --- /dev/null +++ b/packages/skills/src/extension.ts @@ -0,0 +1,26 @@ +import { homedir } from "node:os"; +import type { Extension, HostAPI } from "@dispatch/kernel"; +import { toolsFilter } from "@dispatch/session-orchestrator"; +import { createLoadSkillTool } from "./load-skill.js"; +import { makeSkillsToolFilter } from "./tools-filter.js"; + +export const extension: Extension = { + manifest: { + id: "skills", + name: "Skills", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["session-orchestrator"], + capabilities: { fs: true }, + contributes: { tools: ["load_skill"] }, + }, + activate(host: HostAPI) { + const homeDir = homedir(); + const workdir = process.cwd(); + + host.defineTool(createLoadSkillTool({ homeDir, workdir })); + host.addFilter(toolsFilter, makeSkillsToolFilter({ homeDir, workdir })); + }, +}; diff --git a/packages/skills/src/index.ts b/packages/skills/src/index.ts new file mode 100644 index 0000000..12f9f19 --- /dev/null +++ b/packages/skills/src/index.ts @@ -0,0 +1,13 @@ +export { extension } from "./extension.js"; +export { createLoadSkillTool, type SkillsDeps, scanSkillsDir } from "./load-skill.js"; +export { + isPathWithinDir, + isValidSkillName, + mergeCatalog, + parseSkillMeta, + renderDescription, + type SkillEntry, + type SkillMeta, + stripLoadedBody, +} from "./pure.js"; +export { makeSkillsToolFilter } from "./tools-filter.js"; diff --git a/packages/skills/src/load-skill.ts b/packages/skills/src/load-skill.ts new file mode 100644 index 0000000..4d37997 --- /dev/null +++ b/packages/skills/src/load-skill.ts @@ -0,0 +1,123 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; +import { + isPathWithinDir, + isValidSkillName, + parseSkillMeta, + type SkillEntry, + stripLoadedBody, +} from "./pure.js"; + +export interface SkillsDeps { + readonly homeDir: string; + readonly workdir: string; +} + +/** + * Scan a .skills directory and return discovered skill entries. + * Returns an empty array on any error (fail-open). + */ +export async function scanSkillsDir(dir: string): Promise<readonly SkillEntry[]> { + try { + const entries = await readdir(dir, { encoding: "utf8", withFileTypes: true }); + const skills: SkillEntry[] = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md")) { + continue; + } + const name = entry.name.slice(0, -3); + try { + const content = await readFile(join(dir, entry.name), "utf8"); + const meta = parseSkillMeta(content); + skills.push({ name, summary: meta.hasMeta ? meta.summary : undefined }); + } catch { + skills.push({ name }); + } + } + return skills; + } catch { + return []; + } +} + +/** + * Create the load_skill ToolContract. + * The tool reads a skill file from disk on execute (uncached). + */ +export function createLoadSkillTool(deps: SkillsDeps): ToolContract { + const { homeDir, workdir } = deps; + + return { + name: "load_skill", + description: "Load a skill by name. No skills are currently available.", + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: "The name of the skill to load.", + }, + }, + required: ["name"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const obj = args as Record<string, unknown>; + const rawName = obj?.name; + + if (typeof rawName !== "string") { + return { content: 'Error: Missing or invalid "name" parameter.', isError: true }; + } + + if (!isValidSkillName(rawName)) { + return { + content: `Error: Invalid skill name "${rawName}". Name must not contain path separators or "..".`, + isError: true, + }; + } + + const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : resolve(workdir); + const cwdSkillsDir = join(effectiveBase, ".skills"); + const homeSkillsDir = join(resolve(homeDir), ".skills"); + + const cwdPath = join(cwdSkillsDir, `${rawName}.md`); + const homePath = join(homeSkillsDir, `${rawName}.md`); + + const resolvedCwdPath = resolve(cwdPath); + const resolvedHomePath = resolve(homePath); + + if (!isPathWithinDir(resolvedCwdPath, cwdSkillsDir)) { + return { content: "Error: Invalid skill path.", isError: true }; + } + if (!isPathWithinDir(resolvedHomePath, homeSkillsDir)) { + return { content: "Error: Invalid skill path.", isError: true }; + } + + let content: string | null = null; + + try { + content = await readFile(resolvedCwdPath, "utf8"); + } catch { + // Cwd miss — try home + } + + if (content === null) { + try { + content = await readFile(resolvedHomePath, "utf8"); + } catch { + // Both miss + } + } + + if (content === null) { + return { content: `Error: unknown skill: ${rawName}`, isError: true }; + } + + const meta = parseSkillMeta(content); + const body = stripLoadedBody(content, meta.hasMeta); + + return { content: body }; + }, + }; +} diff --git a/packages/skills/src/pure.test.ts b/packages/skills/src/pure.test.ts new file mode 100644 index 0000000..a8c6af5 --- /dev/null +++ b/packages/skills/src/pure.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { + isPathWithinDir, + isValidSkillName, + mergeCatalog, + parseSkillMeta, + renderDescription, + stripLoadedBody, +} from "./pure.js"; + +describe("parseSkillMeta", () => { + it("extracts summary when line 2 is ---", () => { + const content = "Use this for web searches\n---\nBody content here"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(true); + expect(result.summary).toBe("Use this for web searches"); + }); + + it("extracts summary with trailing whitespace on separator", () => { + const content = "Summary text\n--- \nBody"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(true); + expect(result.summary).toBe("Summary text"); + }); + + it("reports no metadata when line 2 is not ---", () => { + const content = "Some content\nNot a separator\nBody"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(false); + expect(result.summary).toBeUndefined(); + }); + + it("reports no metadata for single-line content", () => { + const content = "Only one line"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(false); + }); + + it("reports no metadata for empty content", () => { + const result = parseSkillMeta(""); + expect(result.hasMeta).toBe(false); + }); + + it("treats empty summary as undefined", () => { + const content = "\n---\nBody"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(true); + expect(result.summary).toBeUndefined(); + }); +}); + +describe("stripLoadedBody", () => { + it("removes the first two lines when metadata is present", () => { + const content = "Summary\n---\nLine 3\nLine 4"; + const result = stripLoadedBody(content, true); + expect(result).toBe("Line 3\nLine 4"); + }); + + it("returns the whole file when malformed", () => { + const content = "No metadata here\nJust content"; + const result = stripLoadedBody(content, false); + expect(result).toBe("No metadata here\nJust content"); + }); + + it("handles file with only metadata and no body", () => { + const content = "Summary\n---\n"; + const result = stripLoadedBody(content, true); + expect(result).toBe(""); + }); +}); + +describe("mergeCatalog", () => { + it("merges disjoint entries", () => { + const home = [{ name: "a" }, { name: "b" }]; + const cwd = [{ name: "c" }]; + const result = mergeCatalog(home, cwd); + expect(result.map((e) => e.name)).toEqual(["a", "b", "c"]); + }); + + it("cwd skill shadows home skill of the same name", () => { + const home = [{ name: "shared", summary: "home summary" }]; + const cwd = [{ name: "shared", summary: "cwd summary" }]; + const result = mergeCatalog(home, cwd); + expect(result).toHaveLength(1); + expect(result[0]?.summary).toBe("cwd summary"); + }); + + it("sorts by name", () => { + const home = [{ name: "z" }, { name: "a" }]; + const cwd = [{ name: "m" }]; + const result = mergeCatalog(home, cwd); + expect(result.map((e) => e.name)).toEqual(["a", "m", "z"]); + }); + + it("handles empty inputs", () => { + expect(mergeCatalog([], [])).toEqual([]); + }); +}); + +describe("renderDescription", () => { + it("lists all skills by name and appends summaries only for valid ones", () => { + const catalog = [ + { name: "web-search", summary: "Use for web searches" }, + { name: "malformed-skill" }, + ]; + const result = renderDescription(catalog); + expect(result).toBe( + "Load a skill by name. Available skills:\n- web-search: Use for web searches\n- malformed-skill", + ); + }); + + it("returns a plain message when no skills are available", () => { + const result = renderDescription([]); + expect(result).toBe("Load a skill by name. No skills are currently available."); + }); + + it("lists skills with summaries and without", () => { + const catalog = [ + { name: "alpha", summary: "First skill" }, + { name: "beta" }, + { name: "gamma", summary: "Third skill" }, + ]; + const result = renderDescription(catalog); + expect(result).toContain("- alpha: First skill"); + expect(result).toContain("- beta"); + expect(result).toContain("- gamma: Third skill"); + }); +}); + +describe("isValidSkillName", () => { + it("accepts a bare skill name", () => { + expect(isValidSkillName("web-search")).toBe(true); + }); + + it("rejects a name containing /", () => { + expect(isValidSkillName("../escape")).toBe(false); + }); + + it("rejects a name containing \\", () => { + expect(isValidSkillName("path\\name")).toBe(false); + }); + + it("rejects a name containing ..", () => { + expect(isValidSkillName("skill..evil")).toBe(false); + }); + + it("rejects an empty string", () => { + expect(isValidSkillName("")).toBe(false); + }); + + it("rejects a non-string value", () => { + expect(isValidSkillName(123)).toBe(false); + expect(isValidSkillName(null)).toBe(false); + expect(isValidSkillName(undefined)).toBe(false); + }); +}); + +describe("isPathWithinDir", () => { + it("accepts a path within the directory", () => { + expect(isPathWithinDir("/tmp/base/file.txt", "/tmp/base")).toBe(true); + }); + + it("accepts the directory itself", () => { + expect(isPathWithinDir("/tmp/base", "/tmp/base")).toBe(true); + }); + + it("rejects a path outside the directory", () => { + expect(isPathWithinDir("/tmp/other/file.txt", "/tmp/base")).toBe(false); + }); + + it("rejects a prefix attack", () => { + expect(isPathWithinDir("/tmp/base-evil/file.txt", "/tmp/base")).toBe(false); + }); +}); diff --git a/packages/skills/src/pure.ts b/packages/skills/src/pure.ts new file mode 100644 index 0000000..2800967 --- /dev/null +++ b/packages/skills/src/pure.ts @@ -0,0 +1,111 @@ +/** + * Pure, zero-I/O functions for the skills extension. + * All decision logic is input → output; no mocks needed for testing. + */ + +/** A discovered skill entry (name + optional summary from metadata). */ +export interface SkillEntry { + readonly name: string; + readonly summary?: string | undefined; +} + +/** Result of parsing a skill file's metadata. */ +export interface SkillMeta { + readonly summary?: string | undefined; + readonly hasMeta: boolean; +} + +/** + * Parse skill file metadata. + * Line 1 = summary (when to use), Line 2 = "---" separator. + * Returns `{ hasMeta: true, summary }` when line 2 is `---`. + * Returns `{ hasMeta: false }` when line 2 is not `---` (malformed). + */ +export function parseSkillMeta(content: string): SkillMeta { + const lines = content.split("\n"); + if (lines.length < 2) { + return { hasMeta: false }; + } + const line2 = lines[1]; + if (line2 === undefined || line2.trim() !== "---") { + return { hasMeta: false }; + } + const summary = lines[0]; + return { hasMeta: true, summary: summary?.trim() === "" ? undefined : summary }; +} + +/** + * Strip the metadata header from a loaded skill body. + * When hasMeta is true, returns lines 3+ (0-indexed: index 2 onward). + * When hasMeta is false, returns the whole file unchanged. + */ +export function stripLoadedBody(content: string, hasMeta: boolean): string { + if (!hasMeta) { + return content; + } + const lines = content.split("\n"); + return lines.slice(2).join("\n"); +} + +/** + * Merge home and cwd skill entries. Cwd skills shadow home skills of the same name. + * Returns a deduplicated array sorted by name. + */ +export function mergeCatalog( + homeEntries: readonly SkillEntry[], + cwdEntries: readonly SkillEntry[], +): readonly SkillEntry[] { + const map = new Map<string, SkillEntry>(); + for (const entry of homeEntries) { + map.set(entry.name, entry); + } + for (const entry of cwdEntries) { + map.set(entry.name, entry); + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Render the skill catalog into a description string for the tool definition. + * Lists all skills by name; appends summary only for skills with valid metadata. + */ +export function renderDescription(catalog: readonly SkillEntry[]): string { + if (catalog.length === 0) { + return "Load a skill by name. No skills are currently available."; + } + const lines = ["Load a skill by name. Available skills:"]; + for (const entry of catalog) { + if (entry.summary !== undefined) { + lines.push(`- ${entry.name}: ${entry.summary}`); + } else { + lines.push(`- ${entry.name}`); + } + } + return lines.join("\n"); +} + +/** + * Validate that a skill name is a bare skill id (no path separators or traversal). + * Returns true if the name is safe, false if it contains `/`, `\`, `..`, or is empty. + */ +export function isValidSkillName(name: unknown): name is string { + if (typeof name !== "string" || name.length === 0) { + return false; + } + if (name.includes("/") || name.includes("\\")) { + return false; + } + if (name.includes("..")) { + return false; + } + return true; +} + +/** + * Check that a resolved absolute path is within the base directory. + * Prefix check — catches `..` traversal and absolute paths outside base. + */ +export function isPathWithinDir(resolvedPath: string, base: string): boolean { + const normalizedBase = base.endsWith("/") ? base : `${base}/`; + return resolvedPath === base || resolvedPath.startsWith(normalizedBase); +} diff --git a/packages/skills/src/skills.test.ts b/packages/skills/src/skills.test.ts new file mode 100644 index 0000000..fe0b437 --- /dev/null +++ b/packages/skills/src/skills.test.ts @@ -0,0 +1,268 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; +import type { ToolAssembly } from "@dispatch/session-orchestrator"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createLoadSkillTool, scanSkillsDir } from "./load-skill.js"; +import { makeSkillsToolFilter } from "./tools-filter.js"; + +function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; +} + +let homeDir: string; +let workdir: string; + +beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), "skills-home-test-")); + workdir = await mkdtemp(join(tmpdir(), "skills-workdir-test-")); +}); + +afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + await rm(workdir, { recursive: true, force: true }); +}); + +describe("load_skill tool", () => { + it("loads a skill body (strips first two lines) from cwd .skills", async () => { + const skillsDir = join(workdir, ".skills"); + await mkdir(skillsDir); + await writeFile( + join(skillsDir, "web-search.md"), + "Use for web searches\n---\n# Web Search Skill\nDo a web search.", + "utf8", + ); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "web-search" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("# Web Search Skill\nDo a web search."); + }); + + it("falls back to home .skills when not in cwd", async () => { + const homeSkillsDir = join(homeDir, ".skills"); + await mkdir(homeSkillsDir); + await writeFile( + join(homeSkillsDir, "global-skill.md"), + "Global skill summary\n---\nGlobal body content", + "utf8", + ); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "global-skill" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("Global body content"); + }); + + it("returns isError for an unknown skill", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "nonexistent" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("unknown skill"); + }); + + it("rejects a name containing a path separator", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "../escape" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Invalid skill name"); + }); + + it("rejects a name containing ..", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "skill..evil" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Invalid skill name"); + }); + + it("rejects a name containing backslash", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "path\\name" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Invalid skill name"); + }); + + it("returns the whole file when malformed (no --- on line 2)", async () => { + const skillsDir = join(workdir, ".skills"); + await mkdir(skillsDir); + await writeFile( + join(skillsDir, "malformed.md"), + "Just some content\nNo separator here\nMore content", + "utf8", + ); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "malformed" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("Just some content\nNo separator here\nMore content"); + }); + + it("cwd skill shadows home skill of the same name", async () => { + const homeSkillsDir = join(homeDir, ".skills"); + await mkdir(homeSkillsDir); + await writeFile(join(homeSkillsDir, "shared.md"), "Home summary\n---\nHome body", "utf8"); + + const cwdSkillsDir = join(workdir, ".skills"); + await mkdir(cwdSkillsDir); + await writeFile(join(cwdSkillsDir, "shared.md"), "Cwd summary\n---\nCwd body", "utf8"); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "shared" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("Cwd body"); + }); + + it("reads from ctx.cwd when set", async () => { + const ctxDir = await mkdtemp(join(tmpdir(), "skills-ctx-test-")); + try { + const ctxSkillsDir = join(ctxDir, ".skills"); + await mkdir(ctxSkillsDir); + await writeFile(join(ctxSkillsDir, "ctx-skill.md"), "Ctx summary\n---\nFrom ctx cwd", "utf8"); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "ctx-skill" }, stubCtx({ cwd: ctxDir })); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("From ctx cwd"); + } finally { + await rm(ctxDir, { recursive: true, force: true }); + } + }); + + it("concurrencySafe is true", () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + expect(tool.concurrencySafe).toBe(true); + }); +}); + +describe("scanSkillsDir", () => { + it("scans .md files and parses metadata", async () => { + const skillsDir = join(workdir, ".skills"); + await mkdir(skillsDir); + await writeFile(join(skillsDir, "valid.md"), "Summary\n---\nBody", "utf8"); + await writeFile(join(skillsDir, "malformed.md"), "No separator\nBody", "utf8"); + await writeFile(join(skillsDir, "other.txt"), "Not a skill", "utf8"); + + const result = await scanSkillsDir(skillsDir); + + expect(result).toHaveLength(2); + const valid = result.find((e) => e.name === "valid"); + expect(valid?.summary).toBe("Summary"); + const malformed = result.find((e) => e.name === "malformed"); + expect(malformed?.summary).toBeUndefined(); + }); + + it("returns empty array for nonexistent directory", async () => { + const result = await scanSkillsDir(join(workdir, "nonexistent")); + expect(result).toEqual([]); + }); +}); + +describe("tools filter", () => { + it("rewrites load_skill description with the current catalog (cwd-aware)", async () => { + const homeSkillsDir = join(homeDir, ".skills"); + await mkdir(homeSkillsDir); + await writeFile(join(homeSkillsDir, "global.md"), "Global summary\n---\nBody", "utf8"); + + const cwdSkillsDir = join(workdir, ".skills"); + await mkdir(cwdSkillsDir); + await writeFile(join(cwdSkillsDir, "local.md"), "Local summary\n---\nBody", "utf8"); + + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const asm: ToolAssembly = { + tools: [tool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + const loadSkill = result.tools.find( + (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", + ); + expect(loadSkill).toBeDefined(); + expect(loadSkill?.description).toContain("global"); + expect(loadSkill?.description).toContain("Global summary"); + expect(loadSkill?.description).toContain("local"); + expect(loadSkill?.description).toContain("Local summary"); + }); + + it("updates the name parameter enum with available skills", async () => { + const cwdSkillsDir = join(workdir, ".skills"); + await mkdir(cwdSkillsDir); + await writeFile(join(cwdSkillsDir, "alpha.md"), "Alpha\n---\nBody", "utf8"); + await writeFile(join(cwdSkillsDir, "beta.md"), "Beta\n---\nBody", "utf8"); + + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const asm: ToolAssembly = { + tools: [tool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + const loadSkill = result.tools.find( + (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", + ); + expect(loadSkill?.parameters.properties?.name?.enum).toEqual(["alpha", "beta"]); + }); + + it("handles empty skill directories gracefully", async () => { + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const asm: ToolAssembly = { + tools: [tool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + const loadSkill = result.tools.find( + (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", + ); + expect(loadSkill?.description).toContain("No skills are currently available"); + }); + + it("passes through non-load_skill tools unchanged", async () => { + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const otherTool = { + name: "other_tool", + description: "Some other tool", + parameters: { type: "object" as const }, + execute: async () => ({ content: "ok" }), + }; + const asm: ToolAssembly = { + tools: [otherTool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + expect(result.tools[0]?.name).toBe("other_tool"); + expect(result.tools[0]?.description).toBe("Some other tool"); + }); +}); diff --git a/packages/skills/src/tools-filter.ts b/packages/skills/src/tools-filter.ts new file mode 100644 index 0000000..058f971 --- /dev/null +++ b/packages/skills/src/tools-filter.ts @@ -0,0 +1,63 @@ +import { join, resolve } from "node:path"; +import type { JsonSchemaProperty, ToolContract } from "@dispatch/kernel"; +import type { ToolAssembly } from "@dispatch/session-orchestrator"; +import { type SkillsDeps, scanSkillsDir } from "./load-skill.js"; +import { mergeCatalog, renderDescription } from "./pure.js"; + +/** + * Create a tools filter that rewrites the load_skill tool's description + * and name parameter enum with the current skill catalog. + */ +export function makeSkillsToolFilter(deps: SkillsDeps) { + const { homeDir, workdir } = deps; + + return async (asm: ToolAssembly): Promise<ToolAssembly> => { + const effectiveBase = asm.cwd ? resolve(asm.cwd) : resolve(workdir); + const cwdSkillsDir = join(effectiveBase, ".skills"); + const homeSkillsDir = join(resolve(homeDir), ".skills"); + + let homeEntries: readonly import("./pure.js").SkillEntry[]; + let cwdEntries: readonly import("./pure.js").SkillEntry[]; + try { + [homeEntries, cwdEntries] = await Promise.all([ + scanSkillsDir(homeSkillsDir), + scanSkillsDir(cwdSkillsDir), + ]); + } catch { + return asm; + } + + const catalog = mergeCatalog(homeEntries, cwdEntries); + const names = catalog.map((e) => e.name); + const description = renderDescription(catalog); + + return { + ...asm, + tools: asm.tools.map((t: ToolContract) => { + if (t.name !== "load_skill") return t; + + const nameProp = t.parameters.properties?.name; + if (!nameProp) { + return { ...t, description }; + } + + const updatedNameProp: JsonSchemaProperty = + names.length > 0 ? { ...nameProp, enum: names } : { ...nameProp }; + + const updatedProperties: Record<string, JsonSchemaProperty> = { + ...t.parameters.properties, + name: updatedNameProp, + }; + + return { + ...t, + description, + parameters: { + ...t.parameters, + properties: updatedProperties, + }, + }; + }), + }; + }; +} diff --git a/packages/skills/tsconfig.json b/packages/skills/tsconfig.json new file mode 100644 index 0000000..2ae3233 --- /dev/null +++ b/packages/skills/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../session-orchestrator" }] +} diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index b2a978a..025042a 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -89,6 +89,9 @@ function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostA addFilter() { return () => {}; }, + async applyFilters(_hook, value) { + return value; + }, provideService() {}, getService(handle) { return SERVICES.get(handle.id) as never; @@ -5,7 +5,7 @@ > Keep this lean and current; do not let it re-accrete a step-by-step changelog. ## Status (current) -`tsc -b` EXIT 0 · biome clean · **686 vitest + 89 bun = 775 tests**. +`tsc -b` EXIT 0 · biome clean · **734 vitest + 109 bun = 843 tests**. Built and verified live (full-fidelity: every feature is a manifest-loaded extension through the host): @@ -118,6 +118,28 @@ User-gated calls: **one tool per extension** (matches `tool-read-file` precedent `ORCHESTRATOR.md` out-of-lane → caught by post-wave `git status`, restored from git. - Deferred (not selected): `glob`, `grep`/`search_code`, background shells. +## Skill system + load_skill tool (DONE) +User-gated calls: skills list lives in the **`load_skill` tool definition** (NOT the system prompt), +refreshed **per new turn** (cache-stable across steps), **live file read** on execute. One `skills` +standard extension (loader + filter + tool). Skill = md in `.skills/`; discovered from `~/.skills` + +`<cwd>/.skills` (cwd shadows home); name = filename w/o `.md`. Format: line1 = summary, +line2 = `---`, body = line3+; on load the first two lines are stripped; malformed (no `---`) = +no summary but still loadable. Glossary: added `skill`, `skill summary`, `tools filter`. +- **Mechanism — the per-turn `tools` filter chain** (first concrete use of the §3.2 context-assembly + chain; reusable for persona/agents later): + - [x] **kernel** — exposed `HostAPI.applyFilters` (delegates to the bus's existing `applyFilters`). + - [x] **session-orchestrator** — defines+exports `toolsFilter`/`ToolAssembly`; applies it ONCE per + turn (injected `applyToolsFilter` dep) before `runTurn`, threading `cwd`+`conversationId`. + - [x] **skills** (new ext, `dependsOn session-orchestrator`) — pure parse/merge/render + + `load_skill` tool (live read, strips first two lines, path-contained) + a `toolsFilter` filter + that rewrites `load_skill`'s description + `name` enum with the per-cwd catalog. 42 tests. + - [x] **host-bin** — registered `skills` in `CORE_EXTENSIONS`. + - [x] **Fan-out (§5.3):** `applyFilters` was a required `HostAPI` addition → broke one consumer + (transport-http `server.bun.test.ts` inline HostAPI stub) → fixed by its owner. +- **Live-verified:** clean boot (`skills` activates, filter registered, no crash); full-graph + `tsc -b` EXIT 0, biome clean. (End-to-end load_skill via a real LLM turn not yet exercised — + unit/integration tests cover the filter rewrite + live read.) + ## Open items - **`prefix.fingerprint` / `warm|real` cache-bust attributes (deferred):** decoupled from dedup by the content-addressed decision; also gated on cache-warming being diff --git a/tsconfig.json b/tsconfig.json index 5c8d6b5..8fd2d24 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ { "path": "./packages/tool-shell" }, { "path": "./packages/tool-edit-file" }, { "path": "./packages/tool-write-file" }, + { "path": "./packages/skills" }, { "path": "./packages/cli" }, { "path": "./packages/journal-sink" }, { "path": "./packages/trace-store" }, |
