SLOPSHOPPER

com.anthropic.claudecode

piebird-sessions's mod: the session_read tool, and a line in the context naming this session

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 33 files
hooks/register.ts 98 lines
1// piebird-sessions's Claude Code part, a mod: the session_read tool, and a block in the
2// conversation's context that names this session and its journal, for the agent to read or cite.
3// Claude Code keeps a session's journal as <id>.jsonl in its project's folder under projects/.
4import type { Api, Register } from "claude-code";
5import type { Io, Place } from "../../kit/core/io";
6import { contextBlock } from "../../kit/claude/context";
7import { FS_READ_LIMIT, printerOf, stdoutPieces } from "../../kit/claude/pieces";
8import { dispatch, toolArguments, toolNames, toolSpecs } from "../../kit/claude/tools";
9import { claudeProjectFolder, type Roots, rootsOf } from "../../core/lookup";
10import { operations } from "../../core/operations.gen";
11import { readHandler } from "../../core/read";
12import { selfLine } from "../../core/self";
13
14const PLUGIN = "piebird-sessions";
15
16/** Core's file reads, folder listings and program runs, made of this session's `$`. */
17function ioOf($: Api): Io {
18	return {
19		async readText(path) {
20			try {
21				return await $.fs.read(path);
22			} catch (error) {
23				// $.fs.read rejects a missing file as it does any other failure ("… failed: ENOENT").
24				if (!(await $.fs.exists(path))) return undefined;
25				throw error;
26			}
27		},
28		async readPieces(path) {
29			if (!(await $.fs.exists(path))) return undefined;
30			// Nothing is read or spawned until the first piece is asked for.
31			return (async function* () {
32				if ((await $.fs.stat(path)).size < FS_READ_LIMIT) {
33					yield await $.fs.read(path);
34					return;
35				}
36				const printer = printerOf(path, (await $.env.get("OS")) === "Windows_NT");
37				yield* stdoutPieces($.process.spawn(printer), printer);
38			})();
39		},
40		async list(path) {
41			try {
42				return (await $.fs.list(path)).map(entry => ({
43					name: entry.name,
44					directory: entry.kind === "dir",
45					modified: entry.kind === "file" ? entry.mtimeMs : 0,
46				}));
47			} catch (error) {
48				if (!(await $.fs.exists(path))) return undefined;
49				throw error;
50			}
51		},
52		// Rejects when the program can't start or outlives timeoutMs (default 30 s, at most 10 min).
53		run: (argv, options) => $.process.run(argv, { ...options }),
54	};
55}
56
57/** The person's home, the session's project root (it follows /cd), and the plugin's folder. */
58async function placeOf($: Api): Promise<Place> {
59	const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
60	if (home === undefined) throw new Error("neither HOME nor USERPROFILE is set");
61	return { home, project: await $.session.root(), plugin: $.plugin.root };
62}
63
64/** Where every agent keeps its journals, and this session's journal with the folder it's in. */
65async function sessionOf(
66	$: Api,
67	io: Io,
68	home: string,
69): Promise<{ roots: Roots; folder: string; id: string; file: string }> {
70	const env = { CLAUDE_CONFIG_DIR: await $.env.get("CLAUDE_CONFIG_DIR"), CODEX_HOME: await $.env.get("CODEX_HOME") };
71	const roots = rootsOf(home, env);
72	const folder = await claudeProjectFolder(io, roots.claude, await $.session.cwd());
73	const id = await $.session.id();
74	return { roots, folder, id, file: `${folder}/${id}.jsonl` };
75}
76
77export const register: Register = on => {
78	on("session.start", async ($, e, next) => {
79		for (const spec of toolSpecs(operations)) await $.tool.register(spec);
80		return next(e);
81	});
82	on("prompt.context", async ($, e, next) => {
83		const r = await next(e);
84		const { home } = await placeOf($);
85		const at = await sessionOf($, ioOf($), home);
86		return { ...r, blocks: [...r.blocks, contextBlock(PLUGIN, selfLine({ id: at.id, file: at.file }, home))] };
87	});
88	on("tool.call", { tool: toolNames(PLUGIN, operations) }, async ($, e, next) => {
89		const io = ioOf($);
90		const { home } = await placeOf($);
91		const at = await sessionOf($, io, home);
92		const roots = [at.roots.omp, at.roots.claude, at.roots.codex];
93		const list = { kind: "folder" as const, folder: at.folder, current: at.file };
94		const handlers = { read: readHandler(io, { roots, list, home }) };
95		return (await dispatch(operations, handlers, e.tool, toolArguments(e))) ?? next(e);
96	});
97};
98
../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
../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
../core/lookup.ts 131 lines
1// Where each agent keeps its journals, a session's id from a journal's name, and the one journal
2// a session argument names.
3import type { Io } from "../kit/core/io";
4import { joinText, type ModelText } from "../kit/core/model";
5import { headLines, type Opener } from "./formats/journal";
6import { asString } from "./json";
7import { model } from "./model.gen";
8import { joinItems, ReadError, shownPath } from "./text";
9
10/** The folders each agent keeps its journals in, from the environment the shell reads. */
11export interface Roots {
12	omp: string;
13	claude: string;
14	codex: string;
15}
16
17export function rootsOf(home: string, env: { CLAUDE_CONFIG_DIR?: string; CODEX_HOME?: string }): Roots {
18	return {
19		omp: `${home}/.omp/agent/sessions`,
20		claude: `${env.CLAUDE_CONFIG_DIR || `${home}/.claude`}/projects`,
21		codex: `${env.CODEX_HOME || `${home}/.codex`}/sessions`,
22	};
23}
24
25/** Claude Code cuts a project's folder name at this length and ends it with a hash of the path. */
26const CLAUDE_NAME_LIMIT = 200;
27
28/** The folder under `projects` where Claude Code keeps the journals of sessions started in `cwd`. */
29export async function claudeProjectFolder(io: Io, projects: string, cwd: string): Promise<string> {
30	const name = cwd.replace(/[^a-zA-Z0-9]/g, "-");
31	if (name.length <= CLAUDE_NAME_LIMIT) return `${projects}/${name}`;
32	// The hash is Claude Code's own, so a long one is found by the name's start.
33	const start = name.slice(0, CLAUDE_NAME_LIMIT);
34	const found = ((await io.list(projects)) ?? []).find(e => e.directory && e.name.startsWith(`${start}-`));
35	return `${projects}/${found?.name ?? start}`;
36}
37
38/** Characters read at most from a journal's start for its title and first prompt. */
39export const HEAD = 512 * 1024;
40
41/** Opens a journal found earlier for each pass over it. */
42export function openerOf(io: Io, file: string): Opener {
43	return async () => {
44		const pieces = await io.readPieces(file);
45		if (pieces === undefined) throw new Error(`${file} was removed while it was read`);
46		return pieces;
47	};
48}
49
50/**
51 * The session id in a journal's name: omp's `<time>_<id>.jsonl`, Codex's
52 * `rollout-<time>-<id>.jsonl`, Claude Code's `<id>.jsonl` (not a copy it set aside).
53 */
54export function sessionIdOf(name: string): string | undefined {
55	if (!name.endsWith(".jsonl") || name.includes(".orphaned-")) return undefined;
56	const codex = /^rollout-\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d-(.+)\.jsonl$/.exec(name);
57	if (codex) return codex[1];
58	const omp = /^[^_]+_(.+)\.jsonl$/.exec(name);
59	return omp ? omp[1] : name.slice(0, -".jsonl".length);
60}
61
62/** The journals under `roots` whose session id starts with `id`, sorted. */
63export async function findSessionFiles(io: Io, id: string, roots: readonly string[]): Promise<string[]> {
64	const matches = new Set<string>();
65	const stack = [...roots];
66	for (let dir = stack.pop(); dir !== undefined; dir = stack.pop()) {
67		for (const entry of (await io.list(dir)) ?? []) {
68			const path = `${dir}/${entry.name}`;
69			if (entry.directory) stack.push(path);
70			else if (sessionIdOf(entry.name)?.startsWith(id)) matches.add(path);
71		}
72	}
73	return [...matches].toSorted();
74}
75
76/** A session's title, the one the agent gave it, when the journal's start holds it. */
77export async function sessionTitle(open: Opener): Promise<string | undefined> {
78	for await (const { entries } of headLines(open, HEAD)) {
79		for (const entry of entries) {
80			const title = entry.type === "title" || entry.type === "session" ? asString(entry.title) : undefined;
81			if (title) return title;
82		}
83	}
84	return undefined;
85}
86
87/** At most this many sessions, and their titles, are read and listed when an id prefix names several. */
88export const AMBIGUOUS_LIMIT = 10;
89
90async function ambiguous(io: Io, session: string, files: string[]): Promise<ReadError> {
91	const listed = files.slice(0, AMBIGUOUS_LIMIT);
92	const lines: ModelText[] = [
93		model.text("error-ambiguous", {
94			session,
95			total: files.length,
96			shown: listed.length,
97			more: files.length > listed.length ? "yes" : "no",
98		}),
99	];
100	for (const file of listed) {
101		const title = await sessionTitle(openerOf(io, file));
102		lines.push(title ? model.text("ambiguous-file-titled", { file, title }) : model.text("ambiguous-file", { file }));
103	}
104	return new ReadError("ambiguous", joinText(lines));
105}
106
107/**
108 * The one journal a session id, id prefix or `.jsonl` path names. A path may start with `~`, as
109 * the self line shows a journal under `home`.
110 */
111export async function resolveSession(
112	io: Io,
113	session: string,
114	at: { roots: readonly string[]; home: string },
115): Promise<string> {
116	const given = /^~(?=$|[\\/])/.test(session) ? `${at.home}${session.slice(1)}` : session;
117	if (given.endsWith(".jsonl") && (await io.readPieces(given)) !== undefined) return given;
118	const files = await findSessionFiles(io, session, at.roots);
119	const [only] = files;
120	if (only === undefined) {
121		const roots =
122			joinItems(
123				at.roots.map(root => shownPath(root, at.home)),
124				"join-or",
125			)?.text ?? "";
126		throw new ReadError("not-found", model.text("error-not-found", { session, roots }));
127	}
128	if (files.length > 1) throw await ambiguous(io, session, files);
129	return only;
130}
131
../core/operations.gen.ts 25 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":{"read":{"input":{"type":"object","properties":{"session":{"type":"string"},"line":{"type":"integer","minimum":1},"before":{"type":"integer","minimum":1},"after":{"type":"integer","minimum":1},"find":{"type":"string"},"user":{"type":"boolean"},"thinking":{"type":"boolean"},"tools":{"type":"boolean"},"raw":{"type":"boolean"}},"additionalProperties":false},"output":{"type":"object"},"errors":["not-found","ambiguous","past-end","before-without-line","find-with-page","list-with-params","user-with-details","bad-argument"],"tool":{"name":"session_read","readOnly":true,"alwaysLoad":true}}}}';
8
9const text = new Map<string, ModelText>();
10text.set("read", model.text("read"));
11text.set("read.after", model.text("read.after"));
12text.set("read.before", model.text("read.before"));
13text.set("read.find", model.text("read.find"));
14text.set("read.line", model.text("read.line"));
15text.set("read.raw", model.text("read.raw"));
16text.set("read.session", model.text("read.session"));
17text.set("read.thinking", model.text("read.thinking"));
18text.set("read.tools", model.text("read.tools"));
19text.set("read.user", model.text("read.user"));
20
21export const operations = parseOperations(SOURCE, text, {
22	crashed: cause => model.text("error-crashed", { cause }),
23	badArguments: problem => model.text("error-arguments", { problem }),
24});
25
../core/read.ts 79 lines
1// The read operation: a line with what it relates to (line alone), a page around a line (before,
2// after, or any flag), a search (find), or without a session this project's sessions (with find,
3// the list to pick the session to search from).
4import type { Io } from "../kit/core/io";
5import { joinText, type ModelText } from "../kit/core/model";
6import type { Handler } from "../kit/core/operations";
7import { citeLine } from "./cite";
8import { findEntries } from "./find";
9import { Journal } from "./journal";
10import { type ListSource, listSessions } from "./list";
11import { openerOf, resolveSession } from "./lookup";
12import { model } from "./model.gen";
13import { PAGE, pageEntries } from "./page";
14import { flagsOf, type ReadParams, readParams } from "./params";
15import { ReadError } from "./text";
16
17export interface ReadContext {
18	/** Folders searched for a session id: every agent's journals, and this session's folder. */
19	roots: readonly string[];
20	/** Where this project's sessions are, for a call without a session. */
21	list: ListSource;
22	home: string;
23}
24
25function check(params: ReadParams): void {
26	if (params.user === true && (params.thinking === true || params.tools === true))
27		throw new ReadError("user-with-details", model.text("error-user-with-details"));
28	if (params.find !== undefined && (params.before !== undefined || params.after !== undefined))
29		throw new ReadError("find-with-page", model.text("error-find-with-page"));
30	if (params.line === undefined && params.before !== undefined)
31		throw new ReadError("before-without-line", model.text("error-before-without-line"));
32}
33
34/** A line alone, without before, after or a flag: the line with what it relates to. */
35function citesLine(params: ReadParams): params is ReadParams & { line: number } {
36	const plain = params.before === undefined && params.after === undefined;
37	return params.line !== undefined && plain && !Object.values(flagsOf(params)).includes(true);
38}
39
40/** The operation's text for `params`. Only reads journals. */
41export async function readSession(io: Io, params: ReadParams, context: ReadContext): Promise<ModelText> {
42	if (params.session === undefined) {
43		const { session: _, find, ...rest } = params;
44		if (Object.values(rest).some(value => value !== undefined))
45			throw new ReadError("list-with-params", model.text("error-list-with-params"));
46		const list = await listSessions(io, context.list, context.home);
47		// find searches one session: the list to pick it from, and how to search it.
48		return find === undefined ? list : joinText([model.text("find-without-session", { find }), list]);
49	}
50	check(params);
51	const file = await resolveSession(io, params.session, context);
52	const journal = new Journal(file, openerOf(io, file));
53	try {
54		if (params.find !== undefined) return await findEntries(journal, params);
55		if (citesLine(params)) return await citeLine(journal, params);
56		// Without a line, a page from the start.
57		return await pageEntries(
58			journal,
59			params.line === undefined ? { ...params, after: params.after ?? PAGE } : params,
60		);
61	} finally {
62		await journal.close();
63	}
64}
65
66/** The read operation's handler, for the session `context` describes. */
67export function readHandler(io: Io, context: ReadContext): Handler {
68	return async input => {
69		try {
70			const params = readParams(input);
71			const text = await readSession(io, params, context);
72			return { ok: true, text, data: { session: params.session } };
73		} catch (error) {
74			if (error instanceof ReadError) return { ok: false, error: error.id, text: error.said };
75			throw error;
76		}
77	};
78}
79
../core/self.ts 47 lines
1// The agent's own session: no agent tells the model its session id, so a line names it (id,
2// journal, the session it was forked from) for the agent to read or cite it with session_read.
3import type { ModelText } from "../kit/core/model";
4import { model } from "./model.gen";
5import { shownPath } from "./text";
6
7/** What a branch entry looks like to the check for an earlier line (omp's session entries). */
8export interface BranchEntry {
9	type: string;
10	customType?: string;
11	content?: unknown;
12}
13
14/** The `customType` of omp's message carrying the line; omp shows it as the card's heading. */
15export const SELF_TYPE = "sessions.self";
16
17/**
18 * The line for a session. `parent` is an id after a fork or move, or the source journal's path for
19 * a branched omp session (both `/` and `\` separate its folders).
20 */
21export function selfLine(at: { id: string; file: string; parent?: string }, home: string): ModelText {
22	const journal = shownPath(at.file, home);
23	const parent =
24		at.parent !== undefined && /[\\/]/.test(at.parent)
25			? at.parent
26					.replace(/^.*[\\/]/, "")
27					.replace(/\.jsonl$/, "")
28					.replace(/^.*_/, "")
29			: at.parent;
30	return parent === undefined
31		? model.text("self", { id: at.id, journal })
32		: model.text("self-forked", { id: at.id, journal, parent });
33}
34
35/**
36 * Whether the line is new in this branch: a fork or a move to a new file changes it, and a
37 * compaction drops the last one. Read on every attempt, as omp may discard a message and ask again.
38 */
39export function isNewIn(text: ModelText, branch: readonly BranchEntry[]): boolean {
40	let last: unknown;
41	for (const entry of branch) {
42		if (entry.type === "compaction") last = undefined;
43		else if (entry.type === "custom_message" && entry.customType === SELF_TYPE) last = entry.content;
44	}
45	return text.text !== last;
46}
47
../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
../core/formats/journal.ts 165 lines
1// A journal's lines, from any agent, as omp entries: omp's lines as they are, Claude Code's and
2// Codex's converted (claude.ts, codex.ts). Each keeps its own line number and text, so a page cites
3// the file as it is and `raw` shows it unchanged. Journals run to hundreds of megabytes, so they are
4// read as a stream of lines, never held whole; a Codex journal takes a first pass for what its
5// response items carry and, after a rollback, one for the turns it undid.
6import { asObject, asString, type JsonObject, parseLine } from "../json";
7import { claudeEntries, type ToolNames } from "./claude";
8import { type Canonical, emptyCanonical, eventMessages, noteCanonical, responseMessages } from "./codex";
9
10export type Format = "omp" | "claude" | "codex";
11
12/** Opens the journal's text in pieces (Io.readPieces), once for each pass over it. */
13export type Opener = () => Promise<AsyncIterable<string>>;
14
15export interface Line {
16	/** 1-based. */
17	readonly line: number;
18	readonly raw: string;
19	/** The omp entries the line stands for: none for a line no reader shows, several for some. */
20	readonly entries: readonly JsonObject[];
21}
22
23/** The text's lines without their line ends, as its pieces come. */
24export async function* linesOf(pieces: AsyncIterable<string>): AsyncGenerator<string> {
25	let held: string[] = [];
26	for await (const piece of pieces) {
27		let start = 0;
28		for (let end = piece.indexOf("\n"); end >= 0; end = piece.indexOf("\n", start)) {
29			held.push(piece.slice(start, end));
30			yield held.join("");
31			held = [];
32			start = end + 1;
33		}
34		if (start < piece.length) held.push(piece.slice(start));
35	}
36	if (held.length > 0) yield held.join("");
37}
38
39const CODEX_TYPES = new Set(["session_meta", "response_item", "event_msg", "turn_context"]);
40/** Records read at most to tell which agent wrote a journal. */
41const FORMAT_RECORDS = 50;
42
43function formatOfRecord(record: JsonObject | undefined): Format | undefined {
44	if (record === undefined) return undefined;
45	if (CODEX_TYPES.has(asString(record.type) ?? "")) return "codex";
46	if (typeof record.sessionId === "string") return "claude";
47	return record.type === "session" ? "omp" : undefined;
48}
49
50/** Which agent wrote the journal, from its first records. */
51async function formatOf(open: Opener): Promise<Format> {
52	let read = 0;
53	for await (const raw of linesOf(await open())) {
54		const format = formatOfRecord(parseLine(raw));
55		if (format !== undefined) return format;
56		if (++read >= FORMAT_RECORDS) break;
57	}
58	return "omp";
59}
60
61function codexEntries(record: JsonObject, tools: ToolNames, canonical: Canonical): JsonObject[] {
62	const payload = asObject(record.payload);
63	if (payload === undefined) return [];
64	if (record.type === "event_msg" && payload.type === "thread_name_updated") {
65		const title = asString(payload.thread_name);
66		return title ? [{ type: "title", title }] : [];
67	}
68	const messages =
69		record.type === "response_item"
70			? responseMessages(payload, tools)
71			: record.type === "event_msg"
72				? eventMessages(payload, tools, canonical)
73				: [];
74	return messages.map(message => ({ type: "message", timestamp: record.timestamp, message }));
75}
76
77/** What each record stands for, in one pass that learns the journal's tool names as it goes. */
78function converter(format: Format, canonical: Canonical): (record: JsonObject) => JsonObject[] {
79	const tools: ToolNames = new Map();
80	if (format === "omp") return record => [record];
81	if (format === "claude") return record => claudeEntries(record, tools);
82	return record => codexEntries(record, tools, canonical);
83}
84
85async function* convertedLines(
86	open: Opener,
87	convert: (record: JsonObject) => JsonObject[],
88	dropped: ReadonlySet<number>,
89): AsyncGenerator<Line> {
90	let line = 0;
91	for await (const raw of linesOf(await open())) {
92		line++;
93		const record = parseLine(raw);
94		const entries = record === undefined ? [] : convert(record);
95		yield { line, raw, entries: dropped.has(line) ? [] : entries };
96	}
97}
98
99function rollbackOf(record: JsonObject): number {
100	const p = record.type === "event_msg" ? asObject(record.payload) : undefined;
101	return p?.type === "thread_rolled_back" && typeof p.num_turns === "number" ? p.num_turns : 0;
102}
103
104/** Codex's first pass: what its response items carry, and whether a rollback undid turns. */
105async function codexPlan(open: Opener): Promise<{ canonical: Canonical; rollbacks: boolean }> {
106	const canonical = emptyCanonical();
107	let rollbacks = false;
108	for await (const raw of linesOf(await open())) {
109		const record = parseLine(raw);
110		if (record === undefined) continue;
111		noteCanonical(canonical, record);
112		rollbacks ||= rollbackOf(record) > 0;
113	}
114	return { canonical, rollbacks };
115}
116
117/** The lines Codex's rollbacks undid: each takes back its last turns, back to their user message. */
118async function droppedLines(open: Opener, canonical: Canonical): Promise<Set<number>> {
119	const convert = converter("codex", canonical);
120	const live: { line: number; user: boolean }[] = [];
121	const dropped = new Set<number>();
122	let line = 0;
123	for await (const raw of linesOf(await open())) {
124		line++;
125		const record = parseLine(raw);
126		if (record === undefined) continue;
127		let left = rollbackOf(record);
128		while (left > 0) {
129			const undone = live.pop();
130			if (undone === undefined) break;
131			dropped.add(undone.line);
132			if (undone.user) left--;
133		}
134		const entries = convert(record);
135		if (entries.length > 0) live.push({ line, user: entries.some(e => asObject(e.message)?.role === "user") });
136	}
137	return dropped;
138}
139
140/** The journal's lines with what each stands for. */
141export async function* journalLines(open: Opener): AsyncGenerator<Line> {
142	const format = await formatOf(open);
143	if (format !== "codex") {
144		yield* convertedLines(open, converter(format, emptyCanonical()), new Set());
145		return;
146	}
147	const { canonical, rollbacks } = await codexPlan(open);
148	const dropped = rollbacks ? await droppedLines(open, canonical) : new Set<number>();
149	yield* convertedLines(open, converter("codex", canonical), dropped);
150}
151
152/**
153 * The journal's first lines, until `limit` characters are read, converted without Codex's passes
154 * over the whole journal: enough for a title or the first prompt, where a repeat doesn't matter.
155 */
156export async function* headLines(open: Opener, limit: number): AsyncGenerator<Line> {
157	const format = await formatOf(open);
158	let read = 0;
159	for await (const line of convertedLines(open, converter(format, emptyCanonical()), new Set())) {
160		yield line;
161		read += line.raw.length + 1;
162		if (read >= limit) return;
163	}
164}
165