SLOPSHOPPER

com.anthropic.claudecode

piebird-kagi-search's mod: the Kagi tools, and WebSearch kept out of the way as the search setting says

newguardprompt
A shopper browsing a rack in a slop shop
README

piebird

Plugins for coding agents: omp first, Claude Code and Codex where a plugin works there too, and the Tern terminal.

Each plugin's README shows it working in a short video.

PluginWhat it gives youWorks in
piebird-kagi-searchKagi web search, quick answers and Kagi News with your own Kagi account, from the plugin's own program; the kagi skill; a setting for how much the agent relies on Kagi over its own web searchomp, Claude Code, Codex, ChatGPT, Claude
piebird-agent-variantsThe agent_variant_setup tool: temporary project-scoped variants of an agent, with explicit model routingomp
piebird-sessionsThe session_read tool: reads, pages and searches a session journal (omp's, Claude Code's or Codex's) by session id and line without opening it whole, with asks shown in full (each question, its options and what was picked or typed); and a line telling the agent its own session idomp, Claude Code, Codex, ChatGPT, Claude
piebird-tern-bridgeThe tern_lua tool, running Lua with Tern's window API in the Tern window holding omp's pane, and the tern skillomp in Tern
piebird-threadsYour project's open threads in front of the agent: the files in the folder its settings name, in omp before a prompt whenever the list changes and shown in the chat, in Claude Code at the start of a conversation; a rule pointing to the threads skill, which gives the few parts of a thread file the list reads and leaves the rest to the agentomp, Claude Code, Codex, ChatGPT
piebird-workflow-rulesRules on how to work (proportion, thinking before building, fixing corrections as classes, project tooling, changelogs, asking for decisions) and the failure-classes skillomp, Claude Code, Codex, ChatGPT
piebird-tern-ghostty-styleomp's chat in Tern drawn the way omp looks in Ghostty: one box per tool call, thoughts left open, bash as $ command, compact diffs and repliesomp in Tern

Install

On a Mac or a Windows PC with ChatGPT or Claude, download the installer from the releases page (piebird-<version>-macos-arm64.dmg for Apple silicon, -macos-x64.dmg for an Intel Mac, -windows-x64.exe for Windows) and open it. It lists the plugins and the apps on your computer they go to; pick the plugins and click Install. It finds omp, Claude Code and Codex too. docs/installer.md has what it writes where.

In a terminal, add this repository as a marketplace once, then install the plugins you want. piebird-kagi-search needs its vault, a program a marketplace can't carry, so it comes with the installer.

omp:

omp plugin marketplace add georgelubaretsi/piebird omp plugin install piebird-sessions@piebird

Claude Code:

claude plugin marketplace add georgelubaretsi/piebird claude plugin install piebird-threads@piebird

Codex:

codex plugin marketplace add georgelubaretsi/piebird codex plugin add piebird-threads@piebird

Codex runs a plugin's program (piebird-kagi-search's, piebird-sessions') only as the installer sets it up, so those two come to Codex through the installer.

A plugin that works in Tern carries a Tern plugin too (its so.stencil.tern/ folder), and omp brings it along: when omp starts in a Tern pane, it installs that Tern plugin if Tern lacks it, updates it after omp plugin upgrade, and says so in the chat. There is nothing to install in Tern yourself. A Tern plugin turned off in Tern's settings is left alone.

Contributing

Issues and pull requests are welcome. This repository is exported from a private workspace, so a pull request is applied there by hand, with credit to its author, and arrives here as a new commit rather than as a merge.

Development

Everything runs in the dev shell (Nix):

nix develop bun install bun run check # generated files, lint, format, typecheck, tests

AGENTS.md has the layout, commands and conventions. Tern's docs are not redistributed here; bun tools/sync-references.ts tern fetches them into reference/.

License

MIT. reference/agent-plugins/ is the Agent Plugins specification and schemas, redistributed under their own licenses (NOTICE).

Source 26 files
hooks/register.ts 133 lines
1// piebird-kagi-search's Claude Code part, a mod: the Kagi tools, performed by the plugin's vault,
2// and the "search" setting (core/search.ts) applied to Claude Code's built-in WebSearch. While it
3// should stay out of the way it waits behind tool search, and a call to it is refused with a word
4// on what to use instead; it also runs while the vault isn't there. The model's instructions about
5// searching are a block in its context. A hook that fails is skipped, so a broken mod lets
6// WebSearch run.
7import type { Api, Register } from "claude-code";
8import type { Io, Place } from "../../kit/core/io";
9import type { Search } from "../../core/search";
10import { contextBlock } from "../../kit/claude/context";
11import { FS_READ_LIMIT, printerOf, stdoutPieces } from "../../kit/claude/pieces";
12import { dispatch, toolArguments, toolNames, toolSpecs } from "../../kit/claude/tools";
13import { vaultPath } from "../../kit/core/vault";
14import { handlersFor } from "../../core/handlers";
15import { model } from "../../core/model.gen";
16import { operations } from "../../core/operations.gen";
17import { builtInOff, instructions, PLUGIN, readSearch, VAULT } from "../../core/search";
18
19/** Claude Code's built-in search tool. */
20const WEB_SEARCH = "WebSearch";
21
22/** Whether a Kagi call has failed in this conversation, which brings WebSearch back with "first". */
23let kagiFailed = false;
24/** The "search" setting, read once per conversation. */
25let search: Promise<Search> | undefined;
26
27/** Core's file reads, folder listings and program runs, made of this session's `$`. */
28function ioOf($: Api): Io {
29	return {
30		async readText(path) {
31			try {
32				return await $.fs.read(path);
33			} catch (error) {
34				// $.fs.read rejects a missing file as it does any other failure ("… failed: ENOENT").
35				if (!(await $.fs.exists(path))) return undefined;
36				throw error;
37			}
38		},
39		async readPieces(path) {
40			if (!(await $.fs.exists(path))) return undefined;
41			// Nothing is read or spawned until the first piece is asked for.
42			return (async function* () {
43				if ((await $.fs.stat(path)).size < FS_READ_LIMIT) {
44					yield await $.fs.read(path);
45					return;
46				}
47				const printer = printerOf(path, (await $.env.get("OS")) === "Windows_NT");
48				yield* stdoutPieces($.process.spawn(printer), printer);
49			})();
50		},
51		async list(path) {
52			try {
53				return (await $.fs.list(path)).map(entry => ({
54					name: entry.name,
55					directory: entry.kind === "dir",
56					modified: entry.kind === "file" ? entry.mtimeMs : 0,
57				}));
58			} catch (error) {
59				if (!(await $.fs.exists(path))) return undefined;
60				throw error;
61			}
62		},
63		// Rejects when the program can't start or outlives timeoutMs (default 30 s, at most 10 min).
64		run: (argv, options) => $.process.run(argv, { ...options }),
65	};
66}
67
68/** The person's home, the session's project root (it follows /cd), and the plugin's folder. */
69async function placeOf($: Api): Promise<Place> {
70	const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
71	if (home === undefined) throw new Error("neither HOME nor USERPROFILE is set");
72	return { home, project: await $.session.root(), plugin: $.plugin.root };
73}
74
75async function vaultOf($: Api): Promise<string> {
76	return vaultPath($.plugin.root, VAULT, (await $.env.get("OS")) === "Windows_NT");
77}
78
79/**
80 * The setting, given Claude Code's own choice (read from the person's settings only, never a
81 * project's); "offered" when it can't be read, so nothing is held back.
82 */
83function setting($: Api, choice: unknown): Promise<Search> {
84	search ??= placeOf($)
85		.then(place => readSearch(ioOf($), place, { person: choice }))
86		.catch((): Search => "offered");
87	return search;
88}
89
90/** Whether Kagi can't be used here: a call failed, or the plugin has no vault built. */
91async function kagiOut($: Api): Promise<boolean> {
92	return kagiFailed || !(await $.fs.exists(await vaultOf($)));
93}
94
95export const register: Register = (on, options) => {
96	on("session.start", async ($, e, next) => {
97		for (const spec of toolSpecs(operations)) await $.tool.register(spec);
98		return next(e);
99	});
100	// /clear, /resume and /branch end the conversation without reloading the module: the next one
101	// starts over, maybe in another project.
102	on("session.end", async ($, e, next) => {
103		kagiFailed = false;
104		search = undefined;
105		$.ui.invalidate("tool.describe");
106		return next(e);
107	});
108	on("prompt.context", async ($, e, next) => {
109		const r = await next(e);
110		const block = contextBlock(PLUGIN, instructions(await setting($, options.search)));
111		return { ...r, blocks: [...r.blocks, block] };
112	});
113	on("tool.call", { tool: toolNames(PLUGIN, operations) }, async ($, e, next) => {
114		const handlers = handlersFor(ioOf($), await vaultOf($), () => {
115			if (kagiFailed) return;
116			kagiFailed = true;
117			// Claude Code asks tool.describe again, which puts WebSearch back where it was.
118			$.ui.invalidate("tool.describe");
119		});
120		return (await dispatch(operations, handlers, e.tool, toolArguments(e))) ?? next(e);
121	});
122	on("tool.describe", { tool: WEB_SEARCH }, async ($, e, next) => {
123		const described = await next(e);
124		const off = builtInOff(await setting($, options.search), await kagiOut($));
125		return off ? { ...described, isDeferred: true } : described;
126	});
127	on("tool.call", { tool: WEB_SEARCH }, async ($, e, next) => {
128		const value = await setting($, options.search);
129		if (!builtInOff(value, await kagiOut($))) return next(e);
130		return { deny: model.text("refused-web-search", { search: value }).text };
131	});
132};
133
../kit/core/io.ts 53 lines
1// What a shell lends core code that needs the outside world: reading a file, whole or in pieces,
2// listing a folder and running a program. Each shell makes one from its own API (kit/bun: Bun; a
3// Claude Code mod: `$`, in its hooks module, where `$` calls must stay), so the code that uses it
4// stays pure.
5import type { CallSignal } from "./operations";
6
7export interface RunResult {
8	readonly exitCode: number;
9	readonly stdout: string;
10	readonly stderr: string;
11}
12
13export interface RunOptions {
14	/** Written to the program's stdin, then closed. */
15	readonly stdin?: string;
16	readonly timeoutMs?: number;
17	/** The program is killed when it aborts: the call that started it was cancelled. */
18	readonly signal?: CallSignal;
19}
20
21/** One entry of a folder. */
22export interface IoEntry {
23	readonly name: string;
24	readonly directory: boolean;
25	/** A file's last modification, milliseconds since the epoch; 0 for anything else. */
26	readonly modified: number;
27}
28
29export interface Io {
30	/** The file's text; undefined when there is no such file. Other failures throw. */
31	readText(path: string): Promise<string | undefined>;
32	/**
33	 * The file's text in pieces as it is read, for a file too big to hold at once or read only in
34	 * part: reading starts with the first piece asked for, and leaving the loop early stops it.
35	 * Undefined when there is no such file; other failures throw while reading.
36	 */
37	readPieces(path: string): Promise<AsyncIterable<string> | undefined>;
38	/** The folder's entries; undefined when there is no such folder. Other failures throw. */
39	list(path: string): Promise<readonly IoEntry[] | undefined>;
40	/** Runs argv[0] with the rest as arguments, without a shell. */
41	run(argv: readonly string[], options?: RunOptions): Promise<RunResult>;
42}
43
44/** Where the shell runs: the folders core code may need, as absolute paths. */
45export interface Place {
46	/** The person's home folder. */
47	readonly home: string;
48	/** The project the agent works in; settings files and paths in them are relative to it. */
49	readonly project: string;
50	/** The plugin's own folder, where it is installed. */
51	readonly plugin: string;
52}
53
../core/search.ts 43 lines
1// The "search" setting (core/settings.json) and what follows from it in every agent: what the model
2// is told, and whether the agent's own web search stays out of the way. With "only" it always
3// does; with "first" until a Kagi call fails in the session (kagiDown, core/errors.ts), so the
4// agent searches with Kagi and never has to fail a call to learn it; with "offered" never.
5import type { Io, Place } from "../kit/core/io";
6import type { ModelText } from "../kit/core/model";
7import type { AgentChoice } from "../kit/core/settings";
8import { agentChoices, readSettings } from "../kit/core/settings";
9import { model } from "./model.gen";
10import { declared } from "./settings.gen";
11
12export const PLUGIN = "piebird-kagi-search";
13
14/** The vault's program name (catalog.json `program`). */
15export const VAULT = "kagi-vault";
16
17export type Search = "only" | "first" | "offered";
18
19function isSearch(value: unknown): value is Search {
20	return value === "only" || value === "first" || value === "offered";
21}
22
23/**
24 * The setting where the agent works at `place`, given the agent's own choices when it has a
25 * settings screen ("shared", none or a value that isn't a choice follow the settings files). A
26 * project's choice ranks with the project's file: a repo can't override the person.
27 */
28export async function readSearch(io: Io, place: Place, choice?: AgentChoice): Promise<Search> {
29	const { values } = await readSettings(io, place, { plugin: PLUGIN, declared, ...agentChoices("search", choice) });
30	const value = values.search?.value;
31	return isSearch(value) ? value : "first";
32}
33
34/** Whether the agent's own web search stays out of the way, given whether Kagi failed in the session. */
35export function builtInOff(search: Search, kagiFailed: boolean): boolean {
36	return search === "only" || (search === "first" && !kagiFailed);
37}
38
39/** What the model is told about searching. */
40export function instructions(search: Search): ModelText {
41	return model.text("instructions", { search });
42}
43
../kit/claude/context.ts 17 lines
1// What a plugin tells the model at the start of a conversation, in a Claude Code mod: a block its
2// `prompt.context` hook adds to the context of the first user message, which Claude Code renders
3// under `# <name>` and computes again after compaction or /clear. The build copies kit/ into each
4// plugin that needs it (plugins/<name>/kit/); edit it in kit/.
5import type { ModelText } from "../core/model";
6
7/** One block of `prompt.context`; its name must be unique among the conversation's blocks. */
8export interface ContextBlock {
9	readonly name: string;
10	readonly text: string;
11}
12
13/** The block `name` with the model's text, for the hook to add to `(await next(e)).blocks`. */
14export function contextBlock(name: string, text: ModelText): ContextBlock {
15	return { name, text: text.text };
16}
17
../kit/claude/pieces.ts 55 lines
1// A file read in pieces in a Claude Code mod (`Io.readPieces`, kit/core/io.ts): `$.fs.read` holds
2// at most 4 MiB, so a bigger file is printed by a program the mod spawns and read as it comes. The
3// hooks module makes the `$` calls (kit/claude/io-of.md); this turns what they give into pieces.
4// The build copies kit/ into each plugin that needs it (plugins/<name>/kit/); edit it in kit/.
5
6/**
7 * Files below this size are read with `$.fs.read`. It refuses one over 4 MiB, and a file may grow
8 * between the size check and the read (an agent's own journal does), hence the margin.
9 */
10export const FS_READ_LIMIT = 3 * 1024 * 1024;
11
12/** What `$.process.spawn` gives: the program's output in pieces, then how it ended. */
13export interface Spawned extends AsyncIterable<{ readonly stream: "stdout" | "stderr"; readonly text: string }> {
14	readonly result: Promise<{ readonly code: number | null; readonly signal: string | null }>;
15}
16
17/** A program to spawn: its command line and the variables set for it. */
18export interface Printer {
19	readonly argv: readonly string[];
20	readonly env?: Readonly<Record<string, string>>;
21}
22
23/** Where the Windows printer finds its file: a variable, which no shell parses. */
24const FILE_VARIABLE = "PIEBIRD_PRINT_FILE";
25
26/** PowerShell copying the file's bytes to standard output as they are. */
27const COPY_TO_STDOUT = `$f=[IO.File]::OpenRead($env:${FILE_VARIABLE});$o=[Console]::OpenStandardOutput();$f.CopyTo($o);$o.Flush();$f.Close()`;
28
29/**
30 * The program that prints a file on this system: `cat`, or PowerShell on Windows. The path may
31 * come from the model, so on Windows it goes in a variable: on cmd's command line, a file named
32 * `x&calc&.jsonl` would run calc.
33 */
34export function printerOf(path: string, windows: boolean): Printer {
35	if (!windows) return { argv: ["cat", path] };
36	return {
37		argv: ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", COPY_TO_STDOUT],
38		env: { [FILE_VARIABLE]: path },
39	};
40}
41
42/**
43 * The program's standard output in pieces as it writes them. Leaving the loop early stops the
44 * program; one that fails throws with what it wrote to standard error.
45 */
46export async function* stdoutPieces(spawned: Spawned, printer: Printer): AsyncGenerator<string> {
47	let stderr = "";
48	for await (const piece of spawned) {
49		if (piece.stream === "stdout") yield piece.text;
50		else stderr += piece.text;
51	}
52	const { code, signal } = await spawned.result;
53	if (code !== 0) throw new Error(`${printer.argv[0]} failed (${signal ?? `exit ${code}`}): ${stderr.trim()}`);
54}
55
../kit/claude/tools.ts 88 lines
1// A plugin's operations that the model gets as tools, in a Claude Code mod: the specs its hooks
2// module registers with `$.tool.register` at session.start, and the answer its `tool.call` hook
3// gives, from core's handler. No `$` here: Claude Code refuses a module that passes `$` to another
4// file, so the hooks module makes every call and hands values in and out (kit/claude/io-of.md).
5// The build copies kit/ into each plugin that needs it (plugins/<name>/kit/); edit it in kit/.
6import type { Handlers, Operations, Outcome } from "../core/operations";
7import { crashText } from "../core/operations";
8
9/** What `$.tool.register` takes (Claude Code's `ToolSpec`). */
10export interface ToolSpec {
11	/** The short name; the model calls it as `mcp__<plugin>__<name>`. */
12	readonly name: string;
13	readonly description: string;
14	readonly inputSchema: Readonly<Record<string, unknown>>;
15	/** True: the tool waits behind tool search; false: its schema is in every prompt. */
16	readonly isDeferred: boolean;
17}
18
19/**
20 * A `tool.call` hook's answer. Claude Code 2.1.296 gives the model `result` (a string) as the
21 * tool's result and `deny` as an error result (`is_error`); it ignores `text` and `isError` on a
22 * hook's own answer and refuses a `result` that is neither a string nor content blocks.
23 */
24export type CallResult = { readonly result: string } | { readonly deny: string };
25
26/** The fields of a `tool.call` event that are Claude Code's, not the tool's arguments. */
27const RESERVED: Readonly<Record<string, true>> = {
28	tool: true,
29	tool_use_id: true,
30	agentId: true,
31	requestMeta: true,
32	consent: true,
33};
34
35/** One spec per operation the model gets as a tool, in declaration order. */
36export function toolSpecs(operations: Operations): ToolSpec[] {
37	return operations.tools.map(({ tool }) => ({
38		name: tool.name,
39		description: tool.description.text,
40		inputSchema: tool.inputSchema,
41		isDeferred: tool.hints.alwaysLoad !== true,
42	}));
43}
44
45/** The name the model and `tool.call` hooks see for the plugin's tool `name`. */
46export function toolName(plugin: string, name: string): string {
47	return `mcp__${plugin}__${name}`;
48}
49
50/** Every tool's full name, for the `tool.call` hook's matcher. */
51export function toolNames(plugin: string, operations: Operations): string[] {
52	return operations.tools.map(({ tool }) => toolName(plugin, tool.name));
53}
54
55/** The tool's arguments in a `tool.call` event, which carries them beside its own fields. */
56export function toolArguments(event: Readonly<Record<string, unknown>>): Record<string, unknown> {
57	return Object.fromEntries(Object.entries(event).filter(([key]) => !Object.hasOwn(RESERVED, key)));
58}
59
60/** The hook's answer for an outcome: its text, as a result or, for a declared error, a refusal. */
61export function callResult(outcome: Outcome): CallResult {
62	return outcome.ok ? { result: outcome.text.text } : { deny: outcome.text.text };
63}
64
65/**
66 * Performs the call to the tool `tool` (its full name) with its arguments `input`: core's handler
67 * for the operation answers, and a handler that throws answers with the catalog's error-crashed.
68 * Undefined when no operation is that tool, for the hook to pass the call on.
69 */
70export async function dispatch(
71	operations: Operations,
72	handlers: Handlers,
73	tool: string,
74	input: unknown,
75): Promise<CallResult | undefined> {
76	// Tool names never hold `__` (tools/operations.schema.json), so the last one ends the plugin's.
77	const at = tool.lastIndexOf("__");
78	const name = tool.startsWith("mcp__") && at > "mcp_".length ? tool.slice(at + 2) : undefined;
79	const op = operations.tools.find(o => o.tool.name === name);
80	if (op === undefined) return undefined;
81	try {
82		if (!Object.hasOwn(handlers, op.id)) throw new Error(`no handler for operation ${op.id}`);
83		return callResult(await handlers[op.id](input));
84	} catch (error) {
85		return { deny: crashText(operations, error).text };
86	}
87}
88
../kit/core/vault.ts 67 lines
1// Calling a plugin's vault: the signed native program that holds its secrets and performs the
2// operations that need one (core/operations.json, `vault: true`). The protocol: `<vault> call
3// <operation>`, the input as JSON on stdin; on stdout one JSON object, `{"output": ...}` or
4// `{"error": {"id": "<declared error>", "variables": {...}}}`, with exit status 0 for both. Anything
5// else is a failure of the vault itself. The caller turns the reply into text from its catalog.
6import type { Io } from "./io";
7import type { CallSignal } from "./operations";
8
9export type VaultReply =
10	| { readonly kind: "output"; readonly output: unknown }
11	| { readonly kind: "error"; readonly id: string; readonly variables: Readonly<Record<string, string | number>> }
12	/** The vault didn't answer by the protocol; `detail` is for logs, not the model. */
13	| { readonly kind: "failed"; readonly detail: string };
14
15const TIMEOUT_MS = 60_000;
16
17function variables(value: unknown): Readonly<Record<string, string | number>> {
18	if (typeof value !== "object" || value === null) return {};
19	const out: Record<string, string | number> = {};
20	for (const [k, v] of Object.entries(value)) if (typeof v === "string" || typeof v === "number") out[k] = v;
21	return out;
22}
23
24/** The vault's reply to `stdout`, a run that exited 0. */
25export function parseReply(stdout: string): VaultReply {
26	let parsed: unknown;
27	try {
28		parsed = JSON.parse(stdout);
29	} catch {
30		return { kind: "failed", detail: `the vault's answer isn't JSON: ${stdout.slice(0, 200)}` };
31	}
32	if (typeof parsed !== "object" || parsed === null)
33		return { kind: "failed", detail: "the vault's answer isn't an object" };
34	if ("output" in parsed) return { kind: "output", output: parsed.output };
35	const error: unknown = "error" in parsed ? parsed.error : undefined;
36	if (typeof error === "object" && error !== null && "id" in error && typeof error.id === "string") {
37		return { kind: "error", id: error.id, variables: variables("variables" in error ? error.variables : undefined) };
38	}
39	return { kind: "failed", detail: "the vault's answer has neither output nor error" };
40}
41
42/**
43 * Where the plugin at `plugin` keeps its vault `program`: native/<program>, a starter that runs this
44 * system's build, or native/<program>.exe on Windows (docs/manifests.md, "Native programs").
45 */
46export function vaultPath(plugin: string, program: string, windows: boolean): string {
47	return windows ? `${plugin}\\native\\${program}.exe` : `${plugin}/native/${program}`;
48}
49
50/** One call of a vault: its operation, the input, and the signal of the call it serves. */
51export interface VaultCall {
52	readonly operation: string;
53	readonly input: unknown;
54	readonly signal?: CallSignal;
55}
56
57export async function callVault(io: Io, vault: string, call: VaultCall): Promise<VaultReply> {
58	const run = await io.run([vault, "call", call.operation], {
59		stdin: JSON.stringify(call.input),
60		timeoutMs: TIMEOUT_MS,
61		...(call.signal === undefined ? {} : { signal: call.signal }),
62	});
63	if (run.exitCode !== 0)
64		return { kind: "failed", detail: `the vault exited ${run.exitCode}: ${run.stderr.slice(0, 500)}` };
65	return parseReply(run.stdout);
66}
67
../core/handlers.ts 71 lines
1// The Kagi tools' handlers, the same in every shell: each sends its operation to the vault, which
2// holds the session and talks to Kagi (cmd/kagi-vault), and turns the reply into what the model
3// reads. `format` is the one argument the vault never sees: it picks the answer's form here.
4import type { Io } from "../kit/core/io";
5import type { CallSignal, Handlers, Outcome } from "../kit/core/operations";
6import type { VaultReply } from "../kit/core/vault";
7import { callVault } from "../kit/core/vault";
8import { fieldsOf, isFields } from "./data";
9import { errorText, kagiDown } from "./errors";
10import { formatters } from "./format";
11import { model } from "./model.gen";
12import { operations } from "./operations.gen";
13
14const FORMATS = ["markdown", "json"];
15
16/** Where the vault is and how to run it. */
17interface Vault {
18	readonly io: Io;
19	readonly path: string;
20}
21
22async function ask(vault: Vault, operation: string, input: unknown, signal?: CallSignal): Promise<VaultReply> {
23	try {
24		return await callVault(vault.io, vault.path, { operation, input, ...(signal ? { signal } : {}) });
25	} catch (error) {
26		return { kind: "failed", detail: error instanceof Error ? error.message : String(error) };
27	}
28}
29
30function outcomeOf(operation: string, reply: VaultReply, json: boolean): Outcome {
31	if (reply.kind === "error") return { ok: false, error: reply.id, text: errorText(reply.id, reply.variables) };
32	if (reply.kind === "failed")
33		return { ok: false, error: "vault", text: model.text("error-vault", { detail: reply.detail }) };
34	const formatter = formatters[operation];
35	const text =
36		json || formatter === undefined
37			? model.text("json", { json: JSON.stringify(reply.output) })
38			: formatter(fieldsOf(reply.output));
39	return { ok: true, text, data: reply.output };
40}
41
42async function perform(vault: Vault, operation: string, input: unknown, signal?: CallSignal): Promise<Outcome> {
43	const { format, ...rest } = fieldsOf(input);
44	if (format !== undefined && (typeof format !== "string" || !FORMATS.includes(format))) {
45		const got = typeof format === "string" ? format : JSON.stringify(format);
46		const variables = { name: "format", values: FORMATS.join(", "), got };
47		return { ok: false, error: "one-of", text: errorText("one-of", variables) };
48	}
49	const reply = await ask(vault, operation, isFields(input) ? rest : input, signal);
50	return outcomeOf(operation, reply, format === "json");
51}
52
53/**
54 * A handler per Kagi tool, each calling the vault at `vault` through `io`, stopped when its call is
55 * cancelled; `failed` hears of each outcome saying Kagi isn't working (kagiDown), for the "search"
56 * setting.
57 */
58export function handlersFor(io: Io, vault: string, failed: () => void = () => undefined): Handlers {
59	return Object.fromEntries(
60		operations.tools.map(op => [
61			op.id,
62			async (input: unknown, signal?: CallSignal) => {
63				const outcome = await perform({ io, path: vault }, op.id, input, signal);
64				// A call cancelled midway stopped the vault; that says nothing about Kagi.
65				if (kagiDown(outcome) && signal?.aborted !== true) failed();
66				return outcome;
67			},
68		]),
69	);
70}
71
../core/model.gen.ts 256 lines
1// Generated from model.ftl by `bun run build`; edit model.ftl.
2import { Catalog } from "../kit/core/model";
3
4const SOURCE =
5	"## The Kagi tools: what each does, and what each argument is for.\n\nformat-argument = markdown (the default) to read; json for the data as fields, to filter it or pass it on\nlang-argument = Language of Kagi News, e.g. en, de, ja (default en)\n\nsearch = Search the web with Kagi. Returns markdown: each result's title linked to its page, its date and a snippet.\n    .query = What to search for\n    .time = Only results from this last period\n    .from_date = Only results from this date on, YYYY-MM-DD\n    .to_date = Only results up to this date, YYYY-MM-DD\n    .limit = Results to return (default 10)\n    .format = { format-argument }\nbatch-search = Run several Kagi web searches at once (up to 10 queries), with the same filters; one result list per query.\n    .queries = The searches\n    .time = Only results from this last period\n    .from_date = Only results from this date on, YYYY-MM-DD\n    .to_date = Only results up to this date, YYYY-MM-DD\n    .limit = Results to return per search (default 10)\n    .format = { format-argument }\nquick = Ask Kagi's Quick Answer: a short answer written from live search results, with its sources as footnotes.\n    .query = The question\n    .format = { format-argument }\nstatus = Whether a Kagi session is kept on this computer, where, and whether Kagi accepts it.\n    .format = { format-argument }\nnews-search = Search the News tab of kagi.com: articles with their source and age.\n    .query = What to search for\n    .freshness = Only articles from this last period\n    .limit = Results to return (default 10)\n    .format = { format-argument }\nnews = Today's Kagi News stories in a category, each summarized with its sources.\n    .category = A category's name or id, such as World, Technology, Science, Business or Sports; kagi_news_categories lists all of today's (default world)\n    .lang = { lang-argument }\n    .limit = Stories to return (default 10)\n    .hide = Stories to leave out: preset ids from kagi_news_filter_presets, or keywords\n    .format = { format-argument }\nnews-categories = List today's Kagi News categories: the id kagi_news takes, the name and the story count.\n    .lang = { lang-argument }\n    .format = { format-argument }\nnews-chaos = Kagi News' chaos index today: how unsettled world news looks, 0 to 100, with Kagi's reasons.\n    .lang = { lang-argument }\n    .format = { format-argument }\nnews-filter-presets = List Kagi News' content-filter presets, which kagi_news can hide, with their keywords.\n    .lang = Language of the keywords, e.g. en, de, ja (default en)\n    .format = { format-argument }\nsmallweb = The newest posts of Kagi's Small Web feed: personal sites and blogs.\n    .limit = Posts to return (default 10)\n    .format = { format-argument }\nraw-read = Raw mode, for what the other tools don't cover: GET any page or JSON endpoint of kagi.com (signed in) or news.kagi.com and get the response as it came: status, type, body. For anything but a plain GET, use kagi_raw_write.\n    .request = A path on kagi.com (`/html/search?q=x&l=lens`) or an https URL on kagi.com or news.kagi.com\n    .format = { format-argument }\nraw-write = Raw mode for requests that may change the person's Kagi account (settings, lenses, Assistant threads) or that need another method than GET; the response comes as it came: status, type, body. Header lines look like `Content-Type: application/json`; without a Content-Type, a JSON body is sent as JSON and any other as a form. Plain reads go through kagi_raw_read.\n    .request = The whole request in HTTP's form: `POST /path` (or a full URL on kagi.com or news.kagi.com), optional header lines, a blank line, then the body\n    .format = { format-argument }\n\n## What the model is told about searching, by the \"search\" setting (core/settings.json).\n\ninstructions = { $search ->\n        [only] Search the web only with the Kagi tools (kagi_search, kagi_batch_search and the other kagi_ tools): never with a built-in web search, another search engine or another search tool. When they fail, use the kagi skill's command-line fallback; when that fails too, say so. Fetch the pages a search finds with your usual fetch or read tool.\n        [first] Search the web with the Kagi tools (kagi_search, kagi_batch_search and the other kagi_ tools) first. A built-in web search or another search tool is for when the Kagi tools and the kagi skill's command-line fallback fail, or when the person asks for one. Fetch the pages a search finds with your usual fetch or read tool.\n       *[offered] The Kagi tools (kagi_search, kagi_batch_search and the other kagi_ tools) search the web through Kagi, with the person's own Kagi account.\n    }\n# Codex's answer to a web.run search it refuses.\nrefused-web-run = { $search ->\n        [only] Searching with web.run is off: this person searches the web only with Kagi.\n       *[first] Searching with web.run is off while the Kagi tools work.\n    } Search with the `kagi_search` tool, or `kagi_batch_search` for several queries at once; the kagi skill has the rest. web.run still opens pages and runs its other commands.\n# Claude Code's answer to a WebSearch call it refuses.\nrefused-web-search = { $search ->\n        [only] WebSearch is off: this person searches the web only with Kagi.\n       *[first] WebSearch is off while the Kagi tools work.\n    } Search with `kagi_search` (`mcp__piebird-kagi-search__kagi_search`), or `kagi_batch_search` for several queries at once; the kagi skill has the rest.\n\n## Answers: a list of results, stories or posts; one item per line, details after \" \u00b7 \".\n\nresult = { $n }. [{ $title }]({ $url })\nwith-detail = { $line } \u00b7 { $detail }\nresult-snippet = {\"   \"}{ $snippet }\nresults-none = No results.\nbatch-query = ## { $query }\nanswer = { $answer }\nanswer-references = { $references }\nstory-heading = ### { $heading }\nstory-summary = { $summary }\nstory-sources = Sources: { $sources }\nstory-source = [{ $name }]({ $url })\nstories-none = No stories.\ncategory = - `{ $id }`: { $name } ({ $stories ->\n        [one] { $stories } story\n       *[other] { $stories } stories\n    })\nchaos = Chaos index: { $index }/100 (updated { $updated })\nchaos-description = { $description }\npreset = - `{ $id }` ({ $label }): { $keywords }\nposts-none = No posts.\njoin-comma = { $a }, { $b }\n# The data as JSON, for format json.\njson = { $json }\n\n## Raw mode: Kagi's response as it came.\n\nresponse-head = HTTP { $status } { $type }\nresponse-location = { $head } \u2192 { $location }\nresponse-truncated = { $head } (the first { $mb } MB of the body)\nresponse-body = { $body }\nresponse-saved = The body is { $kb } KB, too long to send whole, so it is saved in { $path } ({ $type }). Read it a part at a time with your file tools (grep, jq for JSON, a range of lines).\nresponse-preview = It starts:\nfenced =\n    { $fence }\n    { $text }\n    { $fence }\n\n## The session: whether one is kept, where, and whether Kagi accepts it.\n\nstatus-signed-in = Signed in to Kagi: the session works ({ $from }).\nstatus-refused = A Kagi session is kept ({ $from }), but Kagi doesn't accept it: { $problem }\nstatus-none = Not signed in to Kagi. The first search opens a sign-in page in the browser.\nsource = { $kind ->\n        [env] the { $env } environment variable\n        [keychain] the macOS Keychain\n        [credential-manager] Windows Credential Manager\n        [secret-service] the keyring (the Secret Service)\n       *[file] { $path }, readable only by the person\n    }\n\n## What each error means.\n\n# A tool's own code failing (a bug, an unexpected error), and arguments its schema doesn't take.\nerror-crashed = the plugin failed: { $cause }\nerror-arguments = the arguments don't fit this tool: { $problem }\nerror-bad-input = the arguments don't fit this tool: { $cause }\nerror-failed = the plugin's vault failed: { $cause }\nerror-vault = the plugin's vault (its program that keeps the Kagi session) didn't answer: { $detail }\nerror-one-of = { $name } is one of { $values }, not \"{ $got }\"\nerror-empty-query = the query is empty\nerror-limit = limit is 1 to 50\nerror-time-and-dates = give time or from_date/to_date, not both\nerror-date = dates are YYYY-MM-DD, not \"{ $got }\"\nerror-dates-order = from_date { $from } is after to_date { $to }\nerror-queries = give 1 to 10 queries, not { $got }\n# A batch's error names the query it came from.\nerror-in-query = \"{ $query }\": { $error }\nerror-sign-in-needed = Kagi needs a sign-in ({ $why ->\n        [signed-out] Kagi no longer accepts the kept session\n       *[no-session] no Kagi session is kept on this computer\n    }). { $opened ->\n        [yes] A page opened in the person's browser: { $page }. Ask them to follow it (copy their Kagi Session Link and paste it there), then call this tool again.\n        [no] The browser didn't open ({ $problem }). Ask the person to open { $page } on this computer and follow it, then call this tool again.\n       *[no-page] The sign-in page couldn't start ({ $problem }).\n    } Without a browser on this computer: `\"{ $vault }\" sign-in --link` in a terminal, then paste the link from https://kagi.com/settings/user_details when it asks.\nerror-env-session-refused = Kagi doesn't accept the session in { $env }, which is set where this agent runs and comes before a kept session, so signing in won't help. Ask the person to put the token from their current Kagi Session Link (https://kagi.com/settings/user_details) in { $env }, or to remove { $env } and sign in.\nerror-secret-store = cannot { $doing ->\n        [save] save\n        [forget] remove\n       *[read] read\n    } the Kagi session in { $store ->\n        [keychain] the macOS Keychain\n        [credential-manager] Windows Credential Manager\n        [secret-service] the keyring (the Secret Service)\n        [file] the plugin's secrets file\n       *[other] the secret store\n    }: { $cause }\nerror-signed-out = Kagi no longer accepts the kept session (it signed it out)\nerror-http-status = Kagi answered HTTP { $status } for { $path }\nerror-cannot-reach = cannot reach Kagi: { $cause }\nerror-reading = reading { $what ->\n        [results] Kagi's result page\n        [news] Kagi News\n        [news-page] Kagi's news page\n        [smallweb] the Small Web feed\n       *[answer] Kagi's answer\n    }: { $cause }\nerror-no-quick-answer = Kagi gave no quick answer for this query\nerror-no-batch = Kagi News has no current batch\nerror-no-category = Kagi News has no category \"{ $want }\" today; kagi_news_categories lists the { $count } it has\nerror-raw-read-method = kagi_raw_read only reads (GET); send { $method } through kagi_raw_write\nerror-raw-read-target = give one path on kagi.com (/html/search?q=x) or one https URL on kagi.com or news.kagi.com\nerror-raw-read-sign-out = kagi_raw_read leaves the sign-out page alone: it ends the person's Kagi session\nerror-raw-write-start = start with the method and the target, e.g. \"POST /path\" or \"DELETE https://kagi.com/...\"\nerror-raw-write-header = \"{ $line }\" isn't a header line (Name: value); a blank line goes before the body\nerror-raw-write-cookie = leave out Cookie and Host: the session and the host are set for Kagi\nerror-target = the target is a path on kagi.com (/html/search?q=...) or an https URL on kagi.com or news.kagi.com\nerror-host = only kagi.com and news.kagi.com, not { $host }: the session goes to Kagi alone\nerror-cannot-keep = cannot keep the long body in a file: { $cause }\n";
6
7export type Messages = {
8	answer: {
9		answer: string | number;
10	};
11	"answer-references": {
12		references: string | number;
13	};
14	"batch-query": {
15		query: string | number;
16	};
17	"batch-search": undefined;
18	"batch-search.format": undefined;
19	"batch-search.from_date": undefined;
20	"batch-search.limit": undefined;
21	"batch-search.queries": undefined;
22	"batch-search.time": undefined;
23	"batch-search.to_date": undefined;
24	category: {
25		id: string | number;
26		name: string | number;
27		stories: string | number;
28	};
29	chaos: {
30		index: string | number;
31		updated: string | number;
32	};
33	"chaos-description": {
34		description: string | number;
35	};
36	"error-arguments": {
37		problem: string | number;
38	};
39	"error-bad-input": {
40		cause: string | number;
41	};
42	"error-cannot-keep": {
43		cause: string | number;
44	};
45	"error-cannot-reach": {
46		cause: string | number;
47	};
48	"error-crashed": {
49		cause: string | number;
50	};
51	"error-date": {
52		got: string | number;
53	};
54	"error-dates-order": {
55		from: string | number;
56		to: string | number;
57	};
58	"error-empty-query": undefined;
59	"error-env-session-refused": {
60		env: string | number;
61	};
62	"error-failed": {
63		cause: string | number;
64	};
65	"error-host": {
66		host: string | number;
67	};
68	"error-http-status": {
69		path: string | number;
70		status: string | number;
71	};
72	"error-in-query": {
73		error: string | number;
74		query: string | number;
75	};
76	"error-limit": undefined;
77	"error-no-batch": undefined;
78	"error-no-category": {
79		count: string | number;
80		want: string | number;
81	};
82	"error-no-quick-answer": undefined;
83	"error-one-of": {
84		got: string | number;
85		name: string | number;
86		values: string | number;
87	};
88	"error-queries": {
89		got: string | number;
90	};
91	"error-raw-read-method": {
92		method: string | number;
93	};
94	"error-raw-read-sign-out": undefined;
95	"error-raw-read-target": undefined;
96	"error-raw-write-cookie": undefined;
97	"error-raw-write-header": {
98		line: string | number;
99	};
100	"error-raw-write-start": undefined;
101	"error-reading": {
102		cause: string | number;
103		what: string | number;
104	};
105	"error-secret-store": {
106		cause: string | number;
107		doing: string | number;
108		store: string | number;
109	};
110	"error-sign-in-needed": {
111		opened: string | number;
112		page: string | number;
113		problem: string | number;
114		vault: string | number;
115		why: string | number;
116	};
117	"error-signed-out": undefined;
118	"error-target": undefined;
119	"error-time-and-dates": undefined;
120	"error-vault": {
121		detail: string | number;
122	};
123	fenced: {
124		fence: string | number;
125		text: string | number;
126	};
127	"format-argument": undefined;
128	instructions: {
129		search: string | number;
130	};
131	"join-comma": {
132		a: string | number;
133		b: string | number;
134	};
135	json: {
136		json: string | number;
137	};
138	"lang-argument": undefined;
139	news: undefined;
140	"news-categories": undefined;
141	"news-categories.format": undefined;
142	"news-categories.lang": undefined;
143	"news-chaos": undefined;
144	"news-chaos.format": undefined;
145	"news-chaos.lang": undefined;
146	"news-filter-presets": undefined;
147	"news-filter-presets.format": undefined;
148	"news-filter-presets.lang": undefined;
149	"news-search": undefined;
150	"news-search.format": undefined;
151	"news-search.freshness": undefined;
152	"news-search.limit": undefined;
153	"news-search.query": undefined;
154	"news.category": undefined;
155	"news.format": undefined;
156	"news.hide": undefined;
157	"news.lang": undefined;
158	"news.limit": undefined;
159	"posts-none": undefined;
160	preset: {
161		id: string | number;
162		keywords: string | number;
163		label: string | number;
164	};
165	quick: undefined;
166	"quick.format": undefined;
167	"quick.query": undefined;
168	"raw-read": undefined;
169	"raw-read.format": undefined;
170	"raw-read.request": undefined;
171	"raw-write": undefined;
172	"raw-write.format": undefined;
173	"raw-write.request": undefined;
174	"refused-web-run": {
175		search: string | number;
176	};
177	"refused-web-search": {
178		search: string | number;
179	};
180	"response-body": {
181		body: string | number;
182	};
183	"response-head": {
184		status: string | number;
185		type: string | number;
186	};
187	"response-location": {
188		head: string | number;
189		location: string | number;
190	};
191	"response-preview": undefined;
192	"response-saved": {
193		kb: string | number;
194		path: string | number;
195		type: string | number;
196	};
197	"response-truncated": {
198		head: string | number;
199		mb: string | number;
200	};
201	result: {
202		n: string | number;
203		title: string | number;
204		url: string | number;
205	};
206	"result-snippet": {
207		snippet: string | number;
208	};
209	"results-none": undefined;
210	search: undefined;
211	"search.format": undefined;
212	"search.from_date": undefined;
213	"search.limit": undefined;
214	"search.query": undefined;
215	"search.time": undefined;
216	"search.to_date": undefined;
217	smallweb: undefined;
218	"smallweb.format": undefined;
219	"smallweb.limit": undefined;
220	source: {
221		env: string | number;
222		kind: string | number;
223		path: string | number;
224	};
225	status: undefined;
226	"status-none": undefined;
227	"status-refused": {
228		from: string | number;
229		problem: string | number;
230	};
231	"status-signed-in": {
232		from: string | number;
233	};
234	"status.format": undefined;
235	"stories-none": undefined;
236	"story-heading": {
237		heading: string | number;
238	};
239	"story-source": {
240		name: string | number;
241		url: string | number;
242	};
243	"story-sources": {
244		sources: string | number;
245	};
246	"story-summary": {
247		summary: string | number;
248	};
249	"with-detail": {
250		detail: string | number;
251		line: string | number;
252	};
253};
254
255export const model = new Catalog<Messages>(SOURCE);
256
../core/operations.gen.ts 63 lines
1// Generated from operations.json by `bun run build`; edit operations.json.
2import type { ModelText } from "../kit/core/model";
3import { parseOperations } from "../kit/core/operations";
4import { model } from "./model.gen";
5
6const SOURCE =
7	'{"operations":{"search":{"input":{"type":"object","properties":{"query":{"type":"string"},"time":{"type":"string","enum":["day","week","month","year"]},"from_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"to_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"required":["query"],"additionalProperties":false},"output":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"snippet":{"type":"string"},"date":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["results"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","empty-query","limit","time-and-dates","date","dates-order","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_search","readOnly":true,"openWorld":true,"alwaysLoad":true}},"batch-search":{"input":{"type":"object","properties":{"queries":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":10},"time":{"type":"string","enum":["day","week","month","year"]},"from_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"to_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"required":["queries"],"additionalProperties":false},"output":{"type":"object","properties":{"batches":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string"},"results":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"snippet":{"type":"string"},"date":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["query","results"],"additionalProperties":false}}},"required":["batches"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","queries","empty-query","limit","time-and-dates","date","dates-order","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_batch_search","readOnly":true,"openWorld":true,"alwaysLoad":true}},"quick":{"input":{"type":"object","properties":{"query":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"required":["query"],"additionalProperties":false},"output":{"type":"object","properties":{"answer":{"type":"string"},"references":{"type":"string"}},"required":["answer"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","empty-query","no-quick-answer","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_quick","readOnly":true,"openWorld":true,"alwaysLoad":true}},"status":{"input":{"type":"object","properties":{"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"signed_in":{"type":"boolean"},"from":{"type":"object","properties":{"kind":{"type":"string","enum":["env","keychain","credential-manager","secret-service","file"]},"env":{"type":"string"},"path":{"type":"string"}},"required":["kind"],"additionalProperties":false},"problem":{"type":"object","properties":{"id":{"type":"string"},"variables":{"type":"object"}},"required":["id","variables"],"additionalProperties":false}},"required":["signed_in"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","secret-store"],"vault":true,"tool":{"name":"kagi_auth_status","readOnly":true}},"news-search":{"input":{"type":"object","properties":{"query":{"type":"string"},"freshness":{"type":"string","enum":["day","week","month"]},"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"required":["query"],"additionalProperties":false},"output":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"source":{"type":"string"},"snippet":{"type":"string"},"age":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["results"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","empty-query","limit","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news_search","readOnly":true,"openWorld":true}},"news":{"input":{"type":"object","properties":{"category":{"type":"string"},"lang":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":50},"hide":{"type":"array","items":{"type":"string"}},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"stories":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"emoji":{"type":"string"},"category":{"type":"string"},"short_summary":{"type":"string"},"articles":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"link":{"type":"string"},"domain":{"type":"string"},"date":{"type":"string"}}}}},"required":["title"]}}},"required":["stories"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","limit","no-batch","no-category","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news","readOnly":true,"openWorld":true}},"news-categories":{"input":{"type":"object","properties":{"lang":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"categories":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"categoryId":{"type":"string"},"categoryName":{"type":"string"},"clusterCount":{"type":"integer"}},"required":["id","categoryId","categoryName","clusterCount"],"additionalProperties":false}}},"required":["categories"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","no-batch","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news_categories","readOnly":true,"openWorld":true}},"news-chaos":{"input":{"type":"object","properties":{"lang":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"chaosIndex":{"type":"integer"},"chaosDescription":{"type":"string"},"chaosLastUpdated":{"type":"string"}},"required":["chaosIndex","chaosDescription","chaosLastUpdated"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","no-batch","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news_chaos","readOnly":true,"openWorld":true}},"news-filter-presets":{"input":{"type":"object","properties":{"lang":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"presets":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"keywords":{"type":"array","items":{"type":"string"}}},"required":["id","label","keywords"],"additionalProperties":false}}},"required":["presets"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of"],"vault":true,"tool":{"name":"kagi_news_filter_presets","readOnly":true}},"smallweb":{"input":{"type":"object","properties":{"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"posts":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"author":{"type":"string"},"published":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["posts"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","limit","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_smallweb","readOnly":true,"openWorld":true}},"raw-read":{"input":{"type":"object","properties":{"request":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"required":["request"],"additionalProperties":false},"output":{"type":"object","properties":{"url":{"type":"string"},"status":{"type":"integer"},"content_type":{"type":"string"},"location":{"type":"string"},"body":{"type":"string"},"truncated":{"type":"boolean"},"saved":{"type":"object","properties":{"path":{"type":"string"},"type":{"type":"string"},"bytes":{"type":"integer"},"preview":{"type":"string"}},"required":["path","type","bytes"],"additionalProperties":false}},"required":["url","status","body"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","raw-read-method","raw-read-target","raw-read-sign-out","target","host","cannot-keep","secret-store","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_raw_read","readOnly":true,"openWorld":true}},"raw-write":{"input":{"type":"object","properties":{"request":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"required":["request"],"additionalProperties":false},"output":{"type":"object","properties":{"url":{"type":"string"},"status":{"type":"integer"},"content_type":{"type":"string"},"location":{"type":"string"},"body":{"type":"string"},"truncated":{"type":"boolean"},"saved":{"type":"object","properties":{"path":{"type":"string"},"type":{"type":"string"},"bytes":{"type":"integer"},"preview":{"type":"string"}},"required":["path","type","bytes"],"additionalProperties":false}},"required":["url","status","body"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","raw-write-start","raw-write-header","raw-write-cookie","target","host","cannot-keep","secret-store","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_raw_write","openWorld":true}}}}';
8
9const text = new Map<string, ModelText>();
10text.set("batch-search", model.text("batch-search"));
11text.set("batch-search.format", model.text("batch-search.format"));
12text.set("batch-search.from_date", model.text("batch-search.from_date"));
13text.set("batch-search.limit", model.text("batch-search.limit"));
14text.set("batch-search.queries", model.text("batch-search.queries"));
15text.set("batch-search.time", model.text("batch-search.time"));
16text.set("batch-search.to_date", model.text("batch-search.to_date"));
17text.set("news", model.text("news"));
18text.set("news-categories", model.text("news-categories"));
19text.set("news-categories.format", model.text("news-categories.format"));
20text.set("news-categories.lang", model.text("news-categories.lang"));
21text.set("news-chaos", model.text("news-chaos"));
22text.set("news-chaos.format", model.text("news-chaos.format"));
23text.set("news-chaos.lang", model.text("news-chaos.lang"));
24text.set("news-filter-presets", model.text("news-filter-presets"));
25text.set("news-filter-presets.format", model.text("news-filter-presets.format"));
26text.set("news-filter-presets.lang", model.text("news-filter-presets.lang"));
27text.set("news-search", model.text("news-search"));
28text.set("news-search.format", model.text("news-search.format"));
29text.set("news-search.freshness", model.text("news-search.freshness"));
30text.set("news-search.limit", model.text("news-search.limit"));
31text.set("news-search.query", model.text("news-search.query"));
32text.set("news.category", model.text("news.category"));
33text.set("news.format", model.text("news.format"));
34text.set("news.hide", model.text("news.hide"));
35text.set("news.lang", model.text("news.lang"));
36text.set("news.limit", model.text("news.limit"));
37text.set("quick", model.text("quick"));
38text.set("quick.format", model.text("quick.format"));
39text.set("quick.query", model.text("quick.query"));
40text.set("raw-read", model.text("raw-read"));
41text.set("raw-read.format", model.text("raw-read.format"));
42text.set("raw-read.request", model.text("raw-read.request"));
43text.set("raw-write", model.text("raw-write"));
44text.set("raw-write.format", model.text("raw-write.format"));
45text.set("raw-write.request", model.text("raw-write.request"));
46text.set("search", model.text("search"));
47text.set("search.format", model.text("search.format"));
48text.set("search.from_date", model.text("search.from_date"));
49text.set("search.limit", model.text("search.limit"));
50text.set("search.query", model.text("search.query"));
51text.set("search.time", model.text("search.time"));
52text.set("search.to_date", model.text("search.to_date"));
53text.set("smallweb", model.text("smallweb"));
54text.set("smallweb.format", model.text("smallweb.format"));
55text.set("smallweb.limit", model.text("smallweb.limit"));
56text.set("status", model.text("status"));
57text.set("status.format", model.text("status.format"));
58
59export const operations = parseOperations(SOURCE, text, {
60	crashed: cause => model.text("error-crashed", { cause }),
61	badArguments: problem => model.text("error-arguments", { problem }),
62});
63
../kit/core/operations.ts 173 lines
1// A plugin's operations (core/operations.json) joined with their text from core/model.ftl, as its
2// generated core/operations.gen.ts carries them, and the contract between the shells and the code
3// that performs an operation. The build has checked the file and the text (tools/build/operations.ts);
4// this reads them into types and refuses what the code can't use.
5import type { ModelText } from "./model";
6
7export type JsonSchema = Readonly<Record<string, unknown>>;
8
9/** MCP's tool annotations, and whether the tool stays in front of tool search. */
10export interface ToolHints {
11	readonly readOnly?: boolean;
12	readonly destructive?: boolean;
13	readonly idempotent?: boolean;
14	readonly openWorld?: boolean;
15	readonly alwaysLoad?: boolean;
16	/** It runs code the model wrote. */
17	readonly runsCode?: boolean;
18}
19
20export interface Tool {
21	readonly name: string;
22	readonly description: ModelText;
23	/** The operation's input with each argument's description, from model.ftl, filled in. */
24	readonly inputSchema: JsonSchema;
25	readonly outputSchema: JsonSchema;
26	readonly hints: ToolHints;
27}
28
29export interface Operation {
30	readonly id: string;
31	readonly input: JsonSchema;
32	readonly output: JsonSchema;
33	readonly errors: readonly string[];
34	/** The plugin's vault performs it: it needs a secret. */
35	readonly vault: boolean;
36	readonly tool?: Tool;
37}
38
39/** What an operation ends with: its answer, as the model reads it and as data, or a declared error. */
40export type Outcome =
41	| { readonly ok: true; readonly text: ModelText; readonly data: unknown }
42	| { readonly ok: false; readonly error: string; readonly text: ModelText };
43
44/**
45 * A call's cancellation as core sees it: the platform's AbortSignal, which aborts when the caller
46 * cancels the call. Core names only what it reads, so it typechecks where there's no AbortSignal
47 * type (a Claude Code mod); a shell that hands it to its platform checks it is one.
48 */
49export interface CallSignal {
50	readonly aborted: boolean;
51}
52
53/**
54 * Performs one operation; the input is as the caller sent it, for the handler to check. `signal`
55 * aborts when the caller cancels the call: whatever the handler started (a program, a wait) stops.
56 */
57export type Handler = (input: unknown, signal?: CallSignal) => Promise<Outcome>;
58
59export type Handlers = Readonly<Record<string, Handler>>;
60
61/**
62 * What the model reads when a tool can't answer as its operation says, from the plugin's catalog:
63 * `crashed`, error-crashed, when its code fails instead (a bug, an unexpected error), with the
64 * failure's own message; `badArguments`, error-arguments, when the arguments don't fit the tool's
65 * input schema, with what doesn't. A plugin with tools has both (tools/build/operations.ts).
66 */
67export interface ToolWords {
68	readonly crashed: (cause: string) => ModelText;
69	readonly badArguments: (problem: string) => ModelText;
70}
71
72export interface Operations extends ToolWords {
73	readonly byId: ReadonlyMap<string, Operation>;
74	/** The operations the model gets as tools, in declaration order. */
75	readonly tools: readonly (Operation & { readonly tool: Tool })[];
76}
77
78/** The failure `error` as the model reads it: the catalog's error-crashed with its message. */
79export function crashText(operations: Operations, error: unknown): ModelText {
80	return operations.crashed(error instanceof Error ? error.message : String(error));
81}
82
83/** For a plugin without tools: it never needs these words. */
84const NO_TOOLS: ToolWords = {
85	crashed: () => {
86		throw new Error("this plugin has no tools");
87	},
88	badArguments: () => {
89		throw new Error("this plugin has no tools");
90	},
91};
92
93type Fields = Readonly<Record<string, unknown>>;
94
95function isFields(value: unknown): value is Fields {
96	return typeof value === "object" && value !== null && !Array.isArray(value);
97}
98
99function fields(value: unknown, at: string): Fields {
100	if (isFields(value)) return value;
101	throw new Error(`operations.json: ${at} isn't an object`);
102}
103
104function textFor(text: ReadonlyMap<string, ModelText>, id: string): ModelText {
105	const found = text.get(id);
106	if (found === undefined) throw new Error(`model.ftl: no ${id}`);
107	return found;
108}
109
110function hints(tool: Fields): ToolHints {
111	const flag = (k: string) => (typeof tool[k] === "boolean" ? { [k]: tool[k] } : {});
112	return {
113		...flag("readOnly"),
114		...flag("destructive"),
115		...flag("idempotent"),
116		...flag("openWorld"),
117		...flag("alwaysLoad"),
118		...flag("runsCode"),
119	};
120}
121
122function toolFor(id: string, op: Fields, text: ReadonlyMap<string, ModelText>): Tool | undefined {
123	if (op.tool === undefined) return undefined;
124	const tool = fields(op.tool, `/operations/${id}/tool`);
125	if (typeof tool.name !== "string") throw new Error(`operations.json: /operations/${id}/tool/name isn't text`);
126	const input = fields(op.input, `/operations/${id}/input`);
127	const props = fields(input.properties ?? {}, `/operations/${id}/input/properties`);
128	const described = Object.fromEntries(
129		Object.entries(props).map(([arg, schema]) => [
130			arg,
131			{
132				...fields(schema, `/operations/${id}/input/properties/${arg}`),
133				description: textFor(text, `${id}.${arg}`).text,
134			},
135		]),
136	);
137	return {
138		name: tool.name,
139		description: textFor(text, id),
140		inputSchema: { ...input, properties: described },
141		outputSchema: fields(op.output, `/operations/${id}/output`),
142		hints: hints(tool),
143	};
144}
145
146/**
147 * The operations in `source` (operations.json without `$schema`), each tool given its text, with
148 * `words` for a tool that can't answer; a plugin without tools needs none.
149 */
150export function parseOperations(
151	source: string,
152	text: ReadonlyMap<string, ModelText>,
153	words: ToolWords = NO_TOOLS,
154): Operations {
155	const parsed: unknown = JSON.parse(source);
156	const byId = new Map<string, Operation>();
157	for (const [id, value] of Object.entries(fields(fields(parsed, "").operations, "/operations"))) {
158		const op = fields(value, `/operations/${id}`);
159		const errors = Array.isArray(op.errors) ? op.errors.filter((e): e is string => typeof e === "string") : [];
160		const tool = toolFor(id, op, text);
161		const base = {
162			id,
163			input: fields(op.input, `/operations/${id}/input`),
164			output: fields(op.output, `/operations/${id}/output`),
165			errors,
166			vault: op.vault === true,
167		};
168		byId.set(id, tool === undefined ? base : { ...base, tool });
169	}
170	const tools = [...byId.values()].flatMap(op => (op.tool === undefined ? [] : [{ ...op, tool: op.tool }]));
171	return { byId, tools, ...words };
172}
173
../kit/core/model.ts 100 lines
1// Everything a plugin sends a model comes from its Fluent catalog: only a Catalog can make
2// ModelText. Strict where Fluent would print a placeholder, and pure, so it runs in a Claude Code
3// mod too.
4import { FluentBundle, FluentNumber, FluentResource } from "./vendor/fluent/index.js";
5
6const MINT: unique symbol = Symbol("model text");
7
8/**
9 * Text for the model. Its brand keeps any other object with a `text` from passing as one, while
10 * every plugin's copy of the kit, and the tools that read them all, agree on the type; the build
11 * refuses the brand written anywhere but here (tools/build/purity.ts).
12 */
13export class ModelText {
14	readonly text: string;
15	readonly brand = "piebird-catalog";
16	constructor(mint: typeof MINT, text: string) {
17		if (mint !== MINT) throw new Error("ModelText comes from a Catalog");
18		this.text = text;
19	}
20	toString(): string {
21		return this.text;
22	}
23}
24
25/**
26 * Texts from the catalog one after another, each on its own line, or with an empty line between
27 * them as paragraphs. Line breaks are the one thing code adds: words come from the catalog.
28 */
29export function joinText(parts: readonly ModelText[], between: "line" | "paragraph" = "line"): ModelText {
30	return new ModelText(MINT, parts.map(p => p.text).join(between === "line" ? "\n" : "\n\n"));
31}
32
33/** The message ids formatted while a trace runs, for the "what the model sees" page. */
34let traced: string[] | undefined;
35
36/** Runs `run` and returns, with its value, the ids of the messages it formatted, in order, each once. */
37export async function traceMessages<T>(run: () => Promise<T>): Promise<{ value: T; ids: string[] }> {
38	const outer = traced;
39	traced = [];
40	try {
41		const value = await run();
42		return { value, ids: [...new Set(traced)] };
43	} finally {
44		traced = outer;
45	}
46}
47
48export type Variables = Readonly<Record<string, string | number>>;
49
50/** Numbers as written, not grouped the locale's way: a line number reads as a call takes it. */
51function plain(variables: Variables | undefined): Record<string, string | FluentNumber> | undefined {
52	if (variables === undefined) return undefined;
53	return Object.fromEntries(
54		Object.entries(variables).map(([k, v]) => [
55			k,
56			typeof v === "number" ? new FluentNumber(v, { useGrouping: false }) : v,
57		]),
58	);
59}
60
61type Args<M, K extends keyof M> = M[K] extends undefined ? [] : [variables: M[K]];
62
63/** `M` maps each message id, and `id.attribute`, to its variables, or `undefined` when it takes none. */
64export class Catalog<M extends Record<string, Variables | undefined>> {
65	readonly #bundle: FluentBundle;
66
67	constructor(source: string) {
68		// Bidi isolation marks around placeables would reach the model as text.
69		this.#bundle = new FluentBundle("en-US", { useIsolating: false });
70		// The parser skips an entry it can't read; the build refuses those (tools/build/model.ts).
71		const errors = this.#bundle.addResource(new FluentResource(source));
72		if (errors.length > 0) throw new Error(`the catalog: ${errors.map(e => e.message).join("; ")}`);
73	}
74
75	text<K extends keyof M & string>(id: K, ...args: Args<M, K>): ModelText {
76		return this.#format(id, args[0]);
77	}
78
79	/**
80	 * The message `id` chosen at run time, such as a vault's declared error. It throws, as `text`
81	 * does, when the catalog lacks the message or `variables` lacks one it needs.
82	 */
83	textById(id: string, variables?: Variables): ModelText {
84		return this.#format(id, variables);
85	}
86
87	#format(id: string, variables: Variables | undefined): ModelText {
88		const [name, attribute] = id.split(".", 2);
89		const message = this.#bundle.getMessage(name);
90		if (!message) throw new Error(`no message ${name} in the catalog`);
91		const pattern = attribute === undefined ? message.value : message.attributes[attribute];
92		if (!pattern) throw new Error(`message ${id} has no ${attribute === undefined ? "value" : "such attribute"}`);
93		const errors: Error[] = [];
94		const text = this.#bundle.formatPattern(pattern, plain(variables), errors);
95		if (errors.length > 0) throw new Error(`message ${id}: ${errors.map(e => e.message).join("; ")}`);
96		traced?.push(id);
97		return new ModelText(MINT, text);
98	}
99}
100