summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--GLOSSARY.md3
-rw-r--r--bun.lock11
-rw-r--r--packages/host-bin/package.json1
-rw-r--r--packages/host-bin/src/main.ts2
-rw-r--r--packages/host-bin/tsconfig.json1
-rw-r--r--packages/kernel/src/contracts/extension.ts20
-rw-r--r--packages/kernel/src/host/host.test.ts52
-rw-r--r--packages/kernel/src/host/host.ts7
-rw-r--r--packages/session-orchestrator/src/extension.ts2
-rw-r--r--packages/session-orchestrator/src/index.ts1
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts119
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts12
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts16
-rw-r--r--packages/skills/package.json12
-rw-r--r--packages/skills/src/extension.ts26
-rw-r--r--packages/skills/src/index.ts13
-rw-r--r--packages/skills/src/load-skill.ts123
-rw-r--r--packages/skills/src/pure.test.ts174
-rw-r--r--packages/skills/src/pure.ts111
-rw-r--r--packages/skills/src/skills.test.ts268
-rw-r--r--packages/skills/src/tools-filter.ts63
-rw-r--r--packages/skills/tsconfig.json6
-rw-r--r--packages/transport-http/src/server.bun.test.ts3
-rw-r--r--tasks.md24
-rw-r--r--tsconfig.json1
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
diff --git a/bun.lock b/bun.lock
index 3cd4cc8..e1c62d6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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;
diff --git a/tasks.md b/tasks.md
index afb304a..7ccfbcd 100644
--- a/tasks.md
+++ b/tasks.md
@@ -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" },