summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 17:03:23 +0900
committerAdam Malczewski <[email protected]>2026-06-10 17:03:23 +0900
commitf6b45507210e04e9884256b0132900640de4334b (patch)
tree73c5779bf2eec5a1d03732be0f6a8b698b8a2c7f /packages/kernel/src
parentbf862168f0fd7b10d02ae04a9d82f7c37b9d85e5 (diff)
downloaddispatch-f6b45507210e04e9884256b0132900640de4334b.tar.gz
dispatch-f6b45507210e04e9884256b0132900640de4334b.zip
feat(skills): skill system + load_skill tool via per-turn tools filter
Skills are markdown in .skills/ dirs (~/.skills + <cwd>/.skills, cwd shadows home; name = filename). Format: line1 summary, line2 ---, body line3+; load strips the first two lines; malformed = no summary but still loadable. Mechanism (first use of the context-assembly filter chain, §3.2): - kernel: expose HostAPI.applyFilters (delegates to bus.applyFilters) - session-orchestrator: define/export toolsFilter + ToolAssembly; apply once per turn before runTurn (cache-stable across steps), threading cwd + conversationId - skills (new ext): pure parse/merge/render + load_skill tool (live read, path-contained) + a toolsFilter filter rewriting load_skill's description + name enum per cwd - host-bin: register skills in CORE_EXTENSIONS - transport-http: fix HostAPI test stub for the new applyFilters method (fan-out) 734 vitest + 109 bun = 843 tests; tsc -b EXIT 0; biome clean; clean live boot.
Diffstat (limited to 'packages/kernel/src')
-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
3 files changed, 78 insertions, 1 deletions
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);
},