/**
* Forced skill loading — a `/handle` the user typed is LOADED, not suggested.
*
* ## The gap this closes
*
* A skill is text in the composer (`lib/workbench/composer-skills`): the `/`
* menu writes `/pro-editing ` into the draft and that is all a skill is on the
* wire. Nothing in the system prompt says "a handle you see must be loaded", so
* whether the skill's instructions ever reached the model came down to whether
* the model felt like issuing a `read`. ONE position was exempt: a session's
* first prompt had its LEADING handle's SKILL.md pasted inline by pi's
* `formatSkillInvocation`. Everything else — a second handle in that first
* message, and EVERY handle in every later message — was a hint and nothing
* more. `pro-editing`'s whole purpose is editing a course that already exists,
* so its natural use is a follow-up in an existing conversation: it landed in
* the gap every single time.
*
* The exemption was its own bug. Two positions in the same input box behaved
* differently, and only one of them drew a loading row, so the first message and
* the fifth disagreed about what `/stage-design` even does. An input box is an
* input box: every skill of every turn now takes the one path below, and
* `skillInvocationPrompt` is no longer on the runner's path at all.
*
* ## How it works: a read that already happened
*
* The turn is delivered as `user → assistant(toolCall read) → toolResult(SKILL.md)`
* — the exact shape the transcript would have if the model had chosen to read the
* skill itself. Nothing new is invented:
*
* - pi's `agent.prompt()` accepts an array and emits `message_start`/`message_end`
* for each element, so the runner's existing subscriber persists all three into
* the entry tree with no new event type and no new storage;
* - the workbench paints a "skill loaded" card for a `read` of `
/SKILL.md`,
* so the user sees the same loading row they see when the model reads a skill;
* - the dedupe can therefore read a preload exactly as it reads a model-issued
* read, which is what makes "don't load it twice" a question about the
* transcript plus the file's current content, never about a new column;
* - and the body arrives as a TOOL RESULT, not as user speech. That matters most
* for a user-authored skill: its `wrapUserSkillContent` demotion preamble is
* preserved, and 64KiB of user-controlled text does not get promoted into the
* user turn where it would read as an instruction.
*
* ## Crash consistency: one fact, not two writes
*
* The load is three separately fenced entry appends on the runner's ordered write
* chain, and any of them can be the last one that lands. THE CARD IS NOT A FOURTH
* AND FIFTH WRITE. The workbench derives the "skill loaded" row from the durable
* message frames themselves — an assistant `read` of `/SKILL.md` opens the
* card, its tool result closes it — so the card's existence and the transcript's
* are the SAME fact. A truncated write cannot leave a card whose call is nowhere
* in the transcript, nor a card claiming "loaded" over a body that never arrived,
* because there is nothing left to disagree.
*
* What survives is the part that was never about events. Every prefix short of the
* tool result leaves a turn whose body never arrived — and worse than absent,
* because an unanswered read is materialized as "This tool call was interrupted",
* which tells the model the read FAILED. So the resume asks the turn's own question
* again and answers it with THIS SAME builder: the transcript dedupe is the
* idempotence judge, so a load that did land is a no-op and one that did not is
* delivered exactly as it would have been. One mechanism, not one patch per prefix.
*
* WHAT COUNTS AS ALREADY LOADED is a three-condition table, not a path match, and
* it lives with the evidence it reads (`readProvesCoverage` in skills.ts): the read
* must be proven to start at line 1, to reach the end of the file, and to describe
* the file AS IT IS NOW (a content hash — a skill edited by a release or by
* `patch_skill` is a different file whose new instructions have never been in the
* context). Unprovable means NOT loaded in every case. That is why the dedupe reads
* each named skill's current bytes before deciding: coverage is a claim about the
* file now, and the same read serves the injection.
*
* WHERE THE INTENT COMES FROM MATTERS. The resume reads the turn's text from the
* DURABLE `user_message` rows (or the session's own `prompt`), never from the
* compaction view: native compaction may split inside the active turn, summarizing
* the user frame away while keeping the assistant/tool suffix — and then a
* transcript-derived intent reads as empty at exactly the moment the body has also
* been dropped. The compaction view is still the right source for the OTHER
* question ("is the body currently in the model's context?"), which is what the
* dedupe asks it.
*
* ## What it deliberately does not do
*
* - **No new `user` message, ever.** The runner's follow-up delivery cursor is
* "how many `user` messages are in the transcript" (`deliveredFollowUps`). A
* fourth message with `role: 'user'` would advance that cursor by one and a real
* user message would be silently marked delivered and dropped. The synthesized
* pair is `assistant` + `toolResult` and the caller passes exactly ONE user
* message — the user's own.
* - **No orphaned tool call.** Each synthesized `toolCall` ships with its matching
* `toolResult` immediately after it, so `repairOrphanedToolCalls` finds a
* contiguous, complete group and changes nothing. An orphan here would wedge a
* session forever.
* - **No mid-run injection.** `agent.steer()` takes ONE message, so a handle typed
* while a run is live cannot be delivered as a three-message group without
* pushing an assistant frame through the steering queue. That is not attempted:
* a steered handle stays a hint for the model, exactly as today. The same
* limitation covers one crash prefix: an ORPHANED takeover whose user message
* never reached the tree continues the old transcript and injects that message
* by steering. The escape is that the runner's undelivered-message check
* requeues the row, so the next attempt takes the prompt path and does load the
* skill — asserted in the matrix rather than assumed.
*/
import { randomBytes } from 'node:crypto';
import type { AgentMessage } from '@earendil-works/pi-agent-core';
import type { AssistantMessage, ToolResultMessage } from '@earendil-works/pi-ai';
import {
readProvesCoverage,
readSkillFileText,
skillReadRecordsInTranscript,
skillSourceHash,
skillsNamedInText,
type LoadedSkill,
} from './skills';
/**
* How many skills one message may force-load.
*
* A ceiling exists because `/a /b /c /d /e` is one keystroke away and a shipped
* skill runs from 3KB to 66KB. Three is the working number: enough for the real
* combinations (a planning skill + a style skill + a DSL reference) without a
* single turn being able to spend most of the window before the model has said
* anything.
*/
export const SKILL_PRELOAD_MAX_COUNT = 3;
/**
* The byte budget one message's preloads share.
*
* Counted on the actual SKILL.md text, because count alone is the wrong unit when
* `slide-dsl` is 66KB and `deep-interactive` is 2.8KB. ~60KB is roughly 15–20k
* tokens against the driver's 128k floor — a large but recoverable bite, and
* compaction still has room to work.
*
* The FIRST named skill is admitted whatever its size. A budget that could refuse
* the only skill the user asked for would make `/slide-dsl` silently do nothing,
* which is the bug this file exists to fix.
*/
export const SKILL_PRELOAD_MAX_BYTES = 60_000;
export interface SkillPreload {
/**
* The user text to send. Identical to the input unless something was deferred,
* in which case a note naming those skills and their locations is appended.
*/
text: string;
/**
* `assistant(toolCall)` + `toolResult` pairs, in order. NEVER contains a
* message with `role: 'user'` — see the header.
*/
messages: AgentMessage[];
/**
* EVERY installed skill this turn names — forced plus handles, deduplicated, in
* order — before the transcript dedupe and the caps have their say. This is
* "what the user chose", which is a different question from "what this turn had
* to paste in", and the constraint pointer is about the first one.
*/
requested: LoadedSkill[];
/** Skills whose SKILL.md is in `messages`, in injection order. */
injected: LoadedSkill[];
/** Skills the user named that a cap pushed out; named in `text` instead. */
deferred: LoadedSkill[];
}
const EMPTY_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
} as const;
/**
* Tool-call ids for the synthesized reads.
*
* Random, and prefixed so a wedged session can be traced back to this file. It
* must be unique against every id in the conversation because `toolCallId` is the
* pairing key for `repairOrphanedToolCalls` / `trackToolCallMessage`; 96 random
* bits inside a per-session transcript makes a collision unreachable, and the
* `call_` shape matches what OpenAI-compatible gateways expect to echo back.
*/
const TOOL_CALL_ID_PREFIX = 'call_sklpre_';
function defaultToolCallId(): string {
return `${TOOL_CALL_ID_PREFIX}${randomBytes(12).toString('hex')}`;
}
/** The note that carries a deferred skill: its name, and where to read it. */
function deferredNote(deferred: readonly LoadedSkill[], injectedCount: number): string {
const list = deferred.map((skill) => `\`/${skill.name}\` (${skill.filePath})`).join(', ');
const why =
injectedCount > 0
? `this turn already preloaded ${injectedCount}`
: 'their instructions could not be loaded here';
return [
`[The user also named these skills, whose instructions were NOT preloaded because`,
`${why}: ${list}.`,
`Read the ones you need with the \`read\` tool before acting on them.]`,
].join(' ');
}
/**
* Turn the skills of one outgoing turn into reads that already happened.
*
* TWO SOURCES, ONE PATH. `text` supplies the `/handle`s the user typed; `forced`
* supplies a skill the turn carries STRUCTURALLY, with no handle in the text to
* find — the session's own `skillId`, which a launch link sets from the URL
* rather than from anything the user wrote. They merge into one ordered,
* id-deduplicated list with `forced` first, so the same caps, the same
* idempotence judge and the same three-message shape apply to a skill however it
* was chosen. There is deliberately no separate "explicit choice" delivery: an
* input box is an input box, and the first message of a session must produce the
* same loading row as the fifth.
*
* `transcript` is the context the model will see (post-compaction), and it is the
* idempotence judge: a skill already loaded there is skipped — including a
* `forced` one, so a session's skill is loaded once and not on every later run.
*
* Never throws for a content reason. An unknown handle is not a skill, a skill
* whose file cannot be read is reported as deferred (the model can still read it,
* or fail visibly doing so), and a turn with no skills at all returns its own
* text with an empty plan.
*/
export async function buildSkillPreload(input: {
text: string;
skills: readonly LoadedSkill[];
transcript: readonly AgentMessage[];
/**
* Skills this turn loads regardless of what the text says, ahead of any
* handle. The session's frozen `skillId` — the only source a `?skill=` link
* has.
*/
forced?: readonly LoadedSkill[];
/** Stamped onto the synthesized assistant frames; the run's own driver model. */
model: { api: string; provider: string; id: string };
maxCount?: number;
maxBytes?: number;
now?: () => number;
newToolCallId?: () => string;
readSkillFile?: (skill: LoadedSkill) => Promise;
onSkipped?: (skill: LoadedSkill, reason: string) => void;
}): Promise {
const empty: SkillPreload = {
text: input.text,
messages: [],
requested: [],
injected: [],
deferred: [],
};
if (input.skills.length === 0) return empty;
const requested: LoadedSkill[] = [];
const seen = new Set();
for (const skill of [...(input.forced ?? []), ...skillsNamedInText(input.text, input.skills)]) {
if (seen.has(skill.id)) continue;
seen.add(skill.id);
requested.push(skill);
}
if (requested.length === 0) return empty;
// From here on every exit reports what the turn NAMED, even when nothing had to
// be pasted in: a skill already loaded earlier is still the user's choice, and
// dropping it here is how the constraint pointer used to get stolen by a
// constraint-free skill that merely happened to be the new one.
const named: SkillPreload = { ...empty, requested };
const maxCount = input.maxCount ?? SKILL_PRELOAD_MAX_COUNT;
const maxBytes = input.maxBytes ?? SKILL_PRELOAD_MAX_BYTES;
const readFile = input.readSkillFile ?? readSkillFileText;
const newToolCallId = input.newToolCallId ?? defaultToolCallId;
const now = input.now ?? Date.now;
// Read every named skill's CURRENT content once, before deciding anything.
// Coverage is a claim about the file as it is now, not about the numbers a past
// read happened to report (`readProvesCoverage`), so the dedupe needs the same
// bytes the injection would use — and reading them once serves both.
const sources = new Map();
for (const skill of requested) {
try {
sources.set(skill.id, { text: await readFile(skill) });
} catch (error) {
sources.set(skill.id, { error });
}
}
const records = skillReadRecordsInTranscript(input.transcript, input.skills);
const candidates = requested.filter((skill) => {
const source = sources.get(skill.id);
// Unreadable: cannot prove coverage, so it stays a candidate and fails
// visibly in the loop below rather than being silently treated as loaded.
if (!source || 'error' in source) return true;
const currentHash = skillSourceHash(source.text);
return !(records.get(skill.id) ?? []).some((record) => readProvesCoverage(record, currentHash));
});
if (candidates.length === 0) return named;
const messages: AgentMessage[] = [];
const injected: LoadedSkill[] = [];
const deferred: LoadedSkill[] = [];
let spent = 0;
for (const skill of candidates) {
if (injected.length >= maxCount) {
deferred.push(skill);
continue;
}
const source = sources.get(skill.id);
if (!source || 'error' in source) {
// A skill that will not load is a degraded turn, never a failed one: the
// location is still named for the model, and the run continues.
input.onSkipped?.(
skill,
`unreadable: ${String(source && 'error' in source ? source.error : 'missing')}`,
);
deferred.push(skill);
continue;
}
const body = source.text;
// The WHOLE file, and the args say so with an explicit `limit`.
//
// This used to take pi's default 2000-line slice, reasoning that a preloaded
// body should never be more of the file than a model-issued read. That was
// the wrong invariant to protect: a model CAN read a whole file by passing a
// larger `limit`, so full coverage is a legal read shape — just not the
// default one. Taking the default slice instead made a skill longer than
// 2000 lines permanently half-loaded: the record says `lines < totalLines`,
// so `readProvesCoverage` correctly refuses it, and the NEXT turn injects the
// very same prefix again. The tail never arrived and every turn paid for the
// head twice. No shipped builtin is that long (`slide-dsl`, the largest, is
// 892 lines), but a user skill is capped in BYTES — 64KiB of short lines is
// thousands of them.
//
// The byte budget below is what bounds size, and it now weighs what is
// actually injected rather than a truncated prefix.
const lines = body.split(/\r?\n/);
const text = body;
const bytes = Buffer.byteLength(text, 'utf8');
// The first admission ignores the budget on purpose (see SKILL_PRELOAD_MAX_BYTES).
if (injected.length > 0 && spent + bytes > maxBytes) {
input.onSkipped?.(skill, `over the ${maxBytes}-byte preload budget`);
deferred.push(skill);
continue;
}
spent += bytes;
const toolCallId = newToolCallId();
const timestamp = now();
const args = { path: skill.filePath, limit: lines.length };
const result = {
content: [{ type: 'text' as const, text }],
details: {
path: skill.filePath,
offset: 1,
lines: lines.length,
totalLines: lines.length,
skill: skill.id,
// Identity of the WHOLE file, so a LATER turn can tell whether this load
// still describes the skill as it is then (`readProvesCoverage`).
sourceHash: skillSourceHash(body),
},
};
messages.push({
role: 'assistant',
content: [{ type: 'toolCall', id: toolCallId, name: 'read', arguments: args }],
api: input.model.api,
provider: input.model.provider,
model: input.model.id,
usage: { ...EMPTY_USAGE, cost: { ...EMPTY_USAGE.cost } },
stopReason: 'toolUse',
timestamp,
} as AssistantMessage as AgentMessage);
messages.push({
role: 'toolResult',
toolCallId,
toolName: 'read',
content: result.content,
details: result.details,
isError: false,
timestamp,
} as ToolResultMessage as AgentMessage);
injected.push(skill);
}
if (injected.length === 0) {
// Nothing loadable. Still name what was asked for — a silent no-op is the
// behaviour this file replaces.
return {
...named,
text: deferred.length ? `${input.text}\n\n${deferredNote(deferred, 0)}` : input.text,
deferred,
};
}
return {
text: deferred.length
? `${input.text}\n\n${deferredNote(deferred, injected.length)}`
: input.text,
messages,
requested,
injected,
deferred,
};
}
/**
* The one user message of a preloaded turn, in pi's own `prompt()` shape.
*
* Exported so the runner never hand-rolls a second definition: the array form of
* `agent.prompt()` skips pi's string normalization, and this is that
* normalization (`normalizePromptInput`) for the single text case.
*/
export function preloadUserMessage(text: string, now: () => number = Date.now): AgentMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
timestamp: now(),
} as AgentMessage;
}
/**
* Which of the turn's skills the outline constraint check should point at.
*
* TAKES WHAT THE TURN NAMED, not what it happened to inject. Those differ: a skill
* already loaded earlier in the conversation is deduped out of `injected` while
* remaining entirely the user's choice, so scoping this to `injected` let a
* constraint-free skill steal the pointer just by being the new one — write
* `/lecture-style` once, then `/lecture-style /slide-dsl`, and the constraints
* vanished on the second message.
*
* NOT simply the last one either. Only some skills carry a machine-checkable
* constraint file: taking the last would point the constraint check at
* `slide-dsl`, which has none, so the constraints the user ALSO asked for would be
* silently skipped while the guardrail looked like it ran. The last CONSTRAINED
* skill keeps the check pointed at something checkable; with none, the last named
* skill stands so the pointer still follows the user's most recent choice.
*
* The constraint file is checked against the PERSISTED stage and does not depend on
* the model having read the body, so a named skill the caps deferred is still a
* legitimate target.
*/
export function preloadConstraintTarget(named: readonly LoadedSkill[]): LoadedSkill | undefined {
return named.findLast((skill) => skill.constraints !== null) ?? named.at(-1);
}