Enhance your prompts using project instructions and skills

A Claude Code mod that turns a vague prompt into a specific one before you send it. One press, one model call, rewritten from the CLAUDE.md and rules files your session loaded; the inserted words are highlighted in the prompt box, the cost sits on the band, and nothing is sent until you press Send.

<sub>Full film with sound: <a href="https://github.com/sairam0424/claude-prompt-lift/releases/download/v0.4.0/prompt-lift-0.4.0-launch.mp4">MP4 on the v0.4.0 release</a>.</sub>
The band row sits above the prompt and shows where the last press stands: Enhance (e) when idle, Cancel (c) while a call runs, then Send (s) and Undo (u) (Redo (u) after an Undo) beside the result's cost (see The band, under Using it). Nothing runs while idle: no timers, no per-keystroke hooks.
/plugin install prompt-lift --marketplace sairam0424/claude-prompt-lift
Claude Code asks y to add the marketplace, then a scope (user makes it available in every project). From a shell: claude plugin marketplace add sairam0424/claude-prompt-lift && claude plugin install prompt-lift@claude-prompt-lift. From a clone, add the folder as the marketplace instead; the plugin is read live, so edits show up after /reload-plugins.
First run: /reload-plugins, then /prompt-lift probe (one line per model; ok means your account can call it). If a model shows refused, set models to the rungs you have (see Configuration).
prompt-lift is also listed in the Awesome Claude Code Mods catalogue. The two badges at the top come from that page: the access report its scan recorded and the Claude Code version it validated on.
Three ways to press Enhance:
ctrl+x b from the promptctrl+x tab to focus the band, then e/prompt-lift <your text>Send vs Enter. Both send what is in the box. Send refuses drafts with @file mentions or pasted images (press Enter to send; @files and images only travel with Enter) because a plugin-submitted prompt does not expand them.
Undo. The original is kept until you submit or /clear. Undo acts only when the box still holds the enhanced text (or is empty); otherwise it asks for a second press within 10 s. Undo is two-way (Redo).
The row leads with a headline (one glyph and a state word, in bold), then the Buttons, then the details in dim text. Every width below is the band's own: the terminal's width less the engine's margin of about five cells, or the transcript column beside a docked pane. These are the rows as the mod draws them at a width that fits them (the done row needs about 150 columns whole; a narrower band cuts its details from the right), on a chain pinned to full model ids (an alias chain on Bedrock or Vertex is unpriced, so the dollars read $? · (no price); see Cost):
| State | Row |
|---|---|
| Idle | ↥ Ready [ Enhance (e) ] ctrl+x b · or ctrl+x tab, e · prompt-lift: fable 5.1 |
| Enhancing | ↻ Enhancing 1 of 3 [ Cancel (c) ] fable 5.1 › sonnet 5.5 › haiku 5.5 |
| Done | ✓ Enhanced $0.03 [ Enhance (e) ] [ Send (s) ] [ Undo (u) ] fable 5.1 · 1 of 3 · 2.3s · 6.8k cached · 1.4k in · 310 out · +12 words · 1 skill |
| Kept as is, after a call | ✗ Kept as is [ Enhance (e) ] already specific, left as is · $0.03 |
| Kept as is, before any call | ✗ Kept as is [ Enhance (e) ] session cap $0.10 reached · /prompt-lift cap off |
| Undo on a box you edited | ↶ Undo? [ Enhance (e) ] [ Send (s) ] [ Undo (u) ] box edited since Enhance · press Undo again to discard it |
| Undone | ↶ Restored [ Enhance (e) ] [ Send (s) ] [ Redo (u) ] restored your draft |
Glyph and colour. One glyph per state, one cell wide and never an emoji: ↥ ready, ↻ enhancing, ✓ enhanced, ↶ restored or undo, ✗ kept as is, failed or cancelled. The glyph and its word take a key from your theme (claude for Ready, success for Enhanced, error for Failed, warning for the rest), never a fixed colour, so they follow a light or dark theme; the primary Button is the state's main action (Enhance when idle, Cancel while enhancing, Send once there is a result).
While enhancing. The counter is the rung running of the chain's length, and the strip names every rung: one that failed is struck through and dim, the one running now is bold in the headline's colour, the rest wait in dim text. The chain advances only when a rung fails, so a strip that has moved on means the rung before it failed. The done row then says which rung answered (1 of 3) and how long the press took.
Kept as is, failed, cancelled. A press that ends without a rewrite leaves its reason in the row, under ✗ Kept as is, ✗ Failed or ✗ Cancelled, until your next action: a press, Enter, any /prompt-lift command, a new session or the Undo button. Nothing times it out, and the toast appears as before. The reason ends with what the press cost only when it called a model: $0.03 for a priced call, $0.000 for one that cost nothing, $? · (no price) for a model without a price; a press that stopped before any call (a guard, a spend cap, no models configured) shows its reason alone. The Buttons stay the phase's own, so a refusal after a result keeps Send and Undo.
Width. The row stays on one line and narrows from the least needed part: under 100 columns the idle row drops its key hint (ctrl+x b · or ctrl+x tab, e); under 80 the done row drops the elapsed time and the three token figures, and the strip shows the running rung alone, as it does at any width where the rungs' names would not fit beside the headline and Cancel (full model ids are long); under 60 the headline keeps its glyph and drops its word (↻ 2/3, ✓ $0.03). What still does not fit is cut at the end of the details; the headline is never cut.
/prompt-lift <text> — enhance the text/prompt-lift undo — restore the original draft/prompt-lift model <id|alias|reset> — put a model first for this machine (sonnet, haiku, a full id) or reset to the configured chain/prompt-lift cap <usd|off> — cap this session's spend at <usd> (off lifts it); cap alone prints the caps. See Spend caps under Cost/prompt-lift tail <on|off|count> — control the conversation tail for this session; tail alone prints the state/prompt-lift preview <draft> — print what a press would send: the dynamic block, plus the static block's size and hash (no model call by the mod, no state change; the printed text joins the transcript, so it is capped at 2,000 characters)/prompt-lift status — the chain, the last call, session and per-day totals, the caps, the engine's session total, the static block size and hash, and a home: row when HOME's real path could not be resolved/prompt-lift diff — word-level diff of the last enhancement/prompt-lift probe — one tiny call per model in the chain, reporting ok or the refusalA sub-word is recognised only when it is the whole argument (any case): /prompt-lift diff the two configs and explain is a draft to enhance, not the diff. model, cap and tail take exactly one token; model the auth flow as a state machine is a draft too. preview takes the rest as the draft, so a draft that itself begins with the word preview can never be enhanced through the command: start it another way or use the button.
The band buttons take hotkeys only while the band has focus. Press ctrl+x tab to focus the band, then e for Enhance, s for Send, u for Undo, or c for Cancel.
From the prompt (without focusing the band first), ctrl+x b presses Enhance because the Enhance button carries the engine action app:cycleDiffBase. This chord works while the diff panel is closed; with the panel open, it cycles the diff base as the action normally does.
A single-key binding is possible through Claude Code's own keybindings file, ~/.claude/keybindings.json (the /keybindings command creates it; changes apply without a restart). Add this block inside the top-level bindings array:
{
"context": "Global",
"bindings": {
"ctrl+e": "app:cycleDiffBase"
}
}
This binds ctrl+e to the same action, which presses Enhance whenever the band is drawn. With the band hidden or the diff panel open, ctrl+e cycles the diff base. Pick a key no other binding in your file uses.
The recent conversation is excluded by default (includeConversation: false). /prompt-lift tail on|off|<N> controls it for this session (1-50 messages, each cut to 600 characters); the idle and done rows show · tail N while it is on, and status prints conversation: off or conversation: last N messages. The tail travels in the uncached block, so each press pays for it.
Outbound data leaves this machine only through $.model.complete, called with your own Claude Code model access (Bedrock, API key, or subscription, whichever the session uses). The call carries:
~/.claude/rules/*. Not the auto-memory index. Not ~/.claude/CLAUDE.md or ~/CLAUDE.md by default, as long as the mod can match them to HOME: when HOME is reached through a symlink, the engine must be able to resolve it (see $.fs.stat below); if it cannot, /prompt-lift status says so in a home: row and those two files may be sent.skillExclude)CLAUDE.md when the draft mentions a top-level directory of the project that has one, unless instructionExclude matches itincludeConversation is onThe mod touches the filesystem through $.fs.read, $.fs.list, $.fs.exists and $.fs.stat: $.fs.read reads those instruction files and a named subproject's CLAUDE.md; $.fs.list lists the top-level entries of the project root; $.fs.exists checks for that CLAUDE.md and, after a rewrite, probes any file-looking path the rewrite names that your draft and your extraRules did not (the path guard behind named a file that does not exist), absolute paths included. The model picks those paths; only existence is read, and the answer never leaves the machine. $.fs.stat resolves HOME's real path (the one path it stats, so a HOME that goes through a symlink still matches your rules files and instructionExclude); only the comparison uses the answer, which never leaves the machine. When the stat fails or the engine answers no real path (another installed plugin can withhold it), the mod compares HOME's own spelling alone, which on a symlinked HOME can miss the files above; /prompt-lift status then adds a home: <path> unresolved (<reason>) row before the instruction files, and the debug log has the reason. $.env.get reads only HOME. Session facts come from $.session.usage, $.session.messages, $.session.model, $.session.root, and $.command.list.
Writes happen through $.prompt.fill (which writes the prompt box) and $.prompt.submit (which sends only when you press Send; Enter is the engine's own path). Nothing is sent on a timer or while you type.
Persistence is local to this machine: $.state holds the session's held pair, phase, last usage, model choice, the static block's hash and size, session cap override, tail override, and lastVerdict (why the last press ended without a rewrite, kept for the band's row until your next action; session only, never written to the store); $.store holds one day row per session (spend:<day>:<writer>), summed on read and pruned after seven days, plus the pinned model.
The mod uses no $.http.fetch, no $.process.run, no clipboard access, no telemetry, no analytics, and no file writes.
Hooks: session.start, session.end, classic.SessionStart{clear|resume|fork} (a passthrough observer that clears the held pair on /clear), prompt.submit (a passthrough observer that forgets the held pair), ui.render{AbovePrompt}, command.run{prompt-lift}.
Settings live in /config under pluginConfigs → prompt-lift@claude-prompt-lift → options, or in your settings file:
{
"pluginConfigs": {
"prompt-lift@claude-prompt-lift": {
"options": {
"minWords": 4,
"skillExclude": "sparc:*,gws-*"
}
}
}
}
| Key | Type | Default | Description | |
|---|---|---|---|---|
models | string | fable,sonnet,haiku | Comma-separated models to try in order: an alias (fable, sonnet, haiku), a full model id, or session for the session's model; on Bedrock/Vertex pin full ids for prices | |
effortModels | string | fable,sonnet | Models that accept the effort parameter; they are sent effort: low | |
maxTokens | number | 2000 | Maximum tokens per model completion | |
timeoutMsFirst | number | 30000 | Timeout for the first model attempt | |
timeoutMsNext | number | 15000 | Timeout for fallback model attempts | |
minWords | number | 3 | Minimum word count to trigger enhancement | |
maxDraftChars | number | 4000 | Maximum characters in the draft prompt | |
keptShareFloor | number | 0.5 | Minimum fraction of the draft's words the rewrite must keep | |
maxGrowthChars | number | 1200 | Maximum character growth allowed | |
relevantSkills | number | 8 | Number of relevant skill descriptions to include | |
instructionFileMaxChars | number | 40000 | Maximum characters read from each instruction file | |
enhanceAction | string | app:cycleDiffBase | Engine keybinding action whose chord presses Enhance from the prompt (empty disables) | |
filePrefix | string | plain | How file paths appear in the rewrite (plain or at) | |
skillPlacement | string | mention | How a matched skill is named (mention mid-sentence, or run as a leading /name) | |
includeConversation | boolean | false | Include recent conversation history | |
conversationMessages | number | 6 | Number of recent messages to include | |
skillPrefixAllow | string | "" | Skills allowed as a leading /name under run (empty for none) | |
instructionExclude | string | ~/.claude/CLAUDE.md,~/CLAUDE.md | Instruction files to leave out (globs compared with the real paths the engine reports; ~/ expands under both spellings of HOME; an absolute pattern through a symlink, such as /tmp/u/CLAUDE.md for /private/tmp/u/CLAUDE.md, is not resolved, so write the real path; also applies to a named subproject's CLAUDE.md) | |
skillExclude | string | "" | Skill name patterns left out of the listing (* and ? wildcards); empty sends every skill Claude lists | |
extraRules | string | "" | Project preferences appended to the rewrite rules as rule 11 (max 1,000 characters; tag-like markup is neutralised). See Extra rules | |
prices | string | A JSON string (quoted and escaped, not a bare object) mapping a model id fragment to per-1M-token prices, merged over the built-in table; invalid JSON or a malformed entry is ignored with a log line. See Cost for the exact form | ||
sessionCapUsd | number | 0 | Refuse a press once this session's priced presses reach these dollars; 0 is no cap. `/prompt-lift cap <usd | off>` overrides it for the session. See Spend caps under Cost |
dayCapUsd | number | 0 | Refuse a press once today's priced presses, across sessions on this machine, reach these dollars; 0 is no cap |
The chain falls back on a refused model, an empty reply, a rate limit, a server error and a timeout; never on your Cancel, never on a 400. The toast names every attempt.
On Bedrock the aliases resolve to whatever the build maps them to, and effortModels is matched by exact id, so pin both keys to your cross-region inference profiles:
{
"pluginConfigs": {
"prompt-lift@claude-prompt-lift": {
"options": {
"models": "us.anthropic.claude-fable-5-1,us.anthropic.claude-sonnet-5-5,us.anthropic.claude-haiku-5-5",
"effortModels": "us.anthropic.claude-fable-5-1,us.anthropic.claude-sonnet-5-5"
}
}
}
}
These are the ids the cost table below prices and the rungs /prompt-lift model sonnet names. Run /prompt-lift probe once: a rung your account cannot call is reported as refused.
extraRules appends project preferences to the rewrite rules as rule 11, visible only to the model (max 1,000 characters; tag-like markup is neutralised, which also catches a comparison written as a < b and c > d — write comparisons in words or as <=/>=; changing it rebuilds the cached block once):
"extraRules": "Prefer bun over npm. Use single quotes in TypeScript."
Prices are USD per 1M tokens, Bedrock us.* profiles, verified 2026-10-16:
| Model | Input | Output | Cache Write | Cache Read |
|---|---|---|---|---|
| Fable 5.1 | 11.00 | 55.00 | 13.75 | 0.275 |
| Sonnet 5.5 | 2.20 | 11.00 | 2.75 | 0.11 |
| Haiku 5.5 | 0.11 | 0.55 | 0.1375 | 0.011 |
The table is Bedrock us.* cross-region pricing, which is Anthropic's list price plus 10%; on the Anthropic API expect about 10% less. On the Anthropic API an alias (fable, sonnet, haiku) prices as the current generation of its family. On Bedrock and Vertex the engine's alias defaults can lag a generation and the mod cannot see which id an alias called, so an alias rung is unpriced there ($?, counted as unpriced, never toward a cap) and status says so; pin full ids in models (the pluginConfigs example under Bedrock users, above) to get the current model and real dollars. A prices row keyed exactly fable, sonnet or haiku prices that alias on every platform: it is your own statement of what the alias costs you. An unpriced model shows $? and (no price).
The built-in table can be overridden with the prices option: a JSON string mapping a model id fragment to per-1M-token prices, merged over the built-in rows (fable-5-1, sonnet-5-5, haiku-5-5); invalid JSON leaves the defaults and logs prompt-lift: prices option ignored: <reason> once.
"prices": "{\"fable-5-1\": {\"input\": 10, \"output\": 50, \"cacheWrite\": 12.5, \"cacheRead\": 0.25}}"
Typical press with defaults (≈7k static tokens, ≈1.5k dynamic, ≈400 out):
The cache lives 5 minutes. Every toast that follows a model call ends with the call's real dollars (a rewrite, already specific, every refusal, a chain that failed after a paid rung), and status counts them all. /prompt-lift model haiku switches the first rung.
sessionCapUsd caps this session's priced presses (/prompt-lift cap <usd> moves it for the session, cap off lifts it); dayCapUsd caps today's presses across every session on this machine. Both are off (0) by default. A press is refused before any model call once the recorded spend meets the cap (prompt-lift: session cap $0.10 reached · /prompt-lift cap off), the check reads only the mod's own ledger (an unpriced $? press never counts), and while a cap is active the band's rows end with · $0.85 left ((day) when the day cap is tighter). status prints caps: and the engine's session total (all tools). On Bedrock and Vertex an alias chain is unpriced, so the caps count nothing until you pin full ids (Bedrock users, above). While a cap is active over such a chain, the caps: line and the rows end with · alias rungs not counted.
Each refusal is one toast and leaves the box exactly as typed. The band keeps the reason in its row (✗ Kept as is) until your next action, so it is still there to read after the toast has gone.
Before any model call (nothing is spent):
prompt-lift: commands and shell lines are sent as typed — the draft starts with /, ! or #. Press Enter: those lines run, they are not prompts.prompt-lift: that reads like an answer to Claude's question — the draft opens like a reply (yes, no, ok, sure, go ahead, option 2, …) when under 12 words, or is under 12 words right after Claude asked a question. Answer with Enter.prompt-lift: under 3 words, nothing to enhance — fewer than minWords words. Add a few, or lower minWords.prompt-lift: over 4000 characters, already specific — longer than maxDraftChars. Send it as it is, or raise the cap.prompt-lift: session cap $0.10 reached · /prompt-lift cap off — this session's priced presses have reached sessionCapUsd (or the cap /prompt-lift cap set). /prompt-lift cap off lifts it for the session, /prompt-lift cap 2.50 moves it.prompt-lift: day cap $5.00 reached — today's priced presses, across sessions, have reached dayCapUsd. Raise the option or wait for tomorrow; there is no override.After the call (the toast ends with what the call cost):
reply hit the token cap, kept your prompt — the rewrite ran to maxTokens. Shorten the draft or raise maxTokens.the model returned nothing — an empty rewrite. Press again, or /prompt-lift model sonnet.rewrite too long, kept your prompt — the rewrite is longer than the draft plus max(400, 150 + 3 × draft length) characters, capped by maxGrowthChars (1,200), or has more than three sentences beyond the draft. Raise maxGrowthChars, or start from a fuller draft.rewrite dropped too much of your prompt, kept it — fewer than keptShareFloor (50%) of your draft's words of four letters or more survived. Lower the floor if you want freer rewrites.changed a question into a task, kept your prompt / changed a task into a question, kept your prompt — the rewrite flipped your intent. Phrase the draft so the question or the instruction is unmistakable.lost a file or code reference from your draft, kept it — a path, @mention or code token from the draft is missing in the rewrite. Press again; the model is told to keep them.added a command or link you did not write, kept your prompt — the rewrite introduced rm, curl, wget, sudo or chmod, a pipe to sh, &&, a URL or an absolute path that appears neither in your draft nor in the instruction files. If you want it, type it yourself.rewrite starts with a command prefix, kept your prompt — the rewrite begins with !, # or a /word that is not a listed skill (a path such as /src/auth.ts included), which Enter would run instead of send.named a file that does not exist, kept your prompt — the rewrite names a file-looking path (rooted, with an extension, or under a top-level directory of the project) that is not on disk. Prose such as CI/CD or and/or is not checked.draft changed while enhancing, left as is — you kept typing. Press Enhance again when the draft is ready.no model answered · fable: rate_limit → sonnet: timeout → haiku: refused — every rung failed; the trail names each outcome. /prompt-lift probe tells you which models your account can call.cancelled, draft unchanged — you pressed Cancel; no fallback was tried.already specific, left as is — not a refusal: the model judged the draft fine. The call is still billed.hooks/register.tsx 77 lines1import type { Register } from "claude-code";
2import { parseOptions } from "./config";
3import { cancel, clearHeld, resetStuckPhase } from "./actions";
4import { renderBand } from "./band";
5import { runCommand } from "./commands";
6import { isCallInFlight } from "./enhance";
7import { clearVerdict } from "./verdict";
8
9export { HINT } from "./commands";
10
11export const register: Register = (on, options) => {
12 const cfg = parseOptions(options);
13
14 on("session.start", async ($, e, next) => {
15 await $.command.register({
16 name: "prompt-lift",
17 description:
18 "Enhance the prompt from the project instructions and skills",
19 argumentHint:
20 "<text> | undo | model <id> | cap <usd|off> | tail <on|off|N> | preview <draft> | status | diff | probe",
21 });
22 if (cfg.pricesIssue) {
23 $.ui.log(cfg.pricesIssue);
24 }
25 await resetStuckPhase($, isCallInFlight);
26 // A verdict the last session left is not this session's to show (R25)
27 await clearVerdict($);
28 return next(e);
29 });
30
31 // The last chance to cut a call still running when the session goes (§5.3).
32 // A /clear or a resume also forgets the held pair here, belt and braces for
33 // classic.SessionStart, which an organization-managed install never runs.
34 on("session.end", async ($, e, next) => {
35 cancel();
36 if (e.reason === "clear" || e.reason === "resume") {
37 await clearHeld($);
38 }
39 return next(e);
40 }).catch(($, e, next) => next(e));
41
42 // /clear, a resume and a fork start with nothing held (§5.3 resets); an
43 // observer, so a failure lets the event go on.
44 on(
45 "classic.SessionStart",
46 { source: ["clear", "resume", "fork"] },
47 async ($, e, next) => {
48 await clearHeld($);
49 return next(e);
50 },
51 ).catch(($, e, next) => next(e));
52
53 // C14: the one prompt.* hook, a passthrough observer. The person's own Enter
54 // ends what Undo could restore, but only once the prompt entered: a drop by
55 // a lower hook or the engine leaves the text in the box, so the pair stays
56 // and Undo still restores the pre-enhance words (G6). An engine-made submit
57 // can arrive with no origin stamped, so read it softly.
58 on("prompt.submit", async ($, e, next) => {
59 const r = await next(e);
60 if (r.drop === undefined && e.origin?.kind === "composer") {
61 await clearHeld($);
62 }
63 return r;
64 }).catch(($, e, next) => next(e));
65
66 // The band (§5.1): one row chained above whatever is drawn beneath.
67 on("ui.render", { component: "AbovePrompt" }, ($, e, next) =>
68 renderBand($, e, next, cfg),
69 );
70
71 // Any /prompt-lift command is the next action: it ends the row's verdict
72 on("command.run", { command: "prompt-lift" }, async ($, e) => {
73 await clearVerdict($);
74 return runCommand($, cfg, e.args);
75 });
76};
77hooks/config.ts 397 lines1import type { PluginOptions, ModelUsage } from "claude-code";
2
3export type Price = {
4 input: number;
5 output: number;
6 cacheWrite: number;
7 cacheRead: number;
8};
9
10export const DEFAULT_PRICES: Readonly<Record<string, Price>> = {
11 "fable-5-1": {
12 input: 11,
13 output: 55,
14 cacheWrite: 13.75,
15 cacheRead: 0.275,
16 },
17 "sonnet-5-5": {
18 input: 2.2,
19 output: 11,
20 cacheWrite: 2.75,
21 cacheRead: 0.11,
22 },
23 "haiku-5-5": {
24 input: 0.11,
25 output: 0.55,
26 cacheWrite: 0.1375,
27 cacheRead: 0.011,
28 },
29};
30
31export type Config = {
32 models: readonly string[];
33 effortModels: ReadonlySet<string>;
34 maxTokens: number;
35 timeoutMsFirst: number;
36 timeoutMsNext: number;
37 minWords: number;
38 maxDraftChars: number;
39 includeConversation: boolean;
40 conversationMessages: number;
41 instructionExclude: readonly string[];
42 instructionFileMaxChars: number;
43 skillExclude: readonly string[];
44 relevantSkills: number;
45 skillPlacement: "mention" | "run";
46 skillPrefixAllow: readonly string[];
47 filePrefix: "plain" | "at";
48 keptShareFloor: number;
49 maxGrowthChars: number;
50 enhanceAction: string;
51 extraRules: string;
52 sessionCapUsd: number;
53 dayCapUsd: number;
54 prices: Readonly<Record<string, Price>>;
55 pricesIssue: string | undefined;
56};
57
58// The defaults are the engine's own aliases, so a fresh install works on every
59// Claude surface; a Bedrock account pins its `us.*` ids in `pluginConfigs`
60// instead (README, "Bedrock users"). They mirror plugin.json.
61const DEFAULT_MODEL_CHAIN = "fable,sonnet,haiku";
62const DEFAULT_INSTRUCTION_EXCLUDE = "~/.claude/CLAUDE.md,~/CLAUDE.md";
63// Empty: every skill Claude lists goes to the model. A person leaves out their
64// own noisy namespaces (`sparc:*,gws-*`) in `pluginConfigs`.
65const DEFAULT_SKILL_EXCLUDE = "";
66
67/**
68 * An alias prices as the current generation of its family on the Anthropic
69 * API: the price-table key `priceFor` reads when the model is exactly the
70 * alias, in any case, and the platform is `anthropic` (R19).
71 */
72export const ALIAS_PRICE_KEYS: Readonly<Record<string, string>> = {
73 fable: "fable-5-1",
74 sonnet: "sonnet-5-5",
75 haiku: "haiku-5-5",
76};
77
78/** Whether `model` is exactly an alias the mod knows, in any case. */
79export function isAlias(model: string): boolean {
80 return Object.hasOwn(ALIAS_PRICE_KEYS, model.toLowerCase());
81}
82
83/**
84 * Where a session's calls go, read off its model id (R19). The engine
85 * resolves a bare alias per platform and build, so whether an alias can be
86 * priced depends on this.
87 */
88export type Platform = "anthropic" | "bedrock" | "vertex";
89
90const BEDROCK_ID = /(^|\.)anthropic\.claude-/;
91const BEDROCK_ARN = /^arn:aws(?:-[a-z]+)*:bedrock:/;
92const BEDROCK_VERSION = /-v\d+:\d+$/;
93const VERTEX_VERSION = /@\d{8}$/;
94const VERTEX_ID = /^claude-[a-z0-9.-]+@/;
95
96/**
97 * The platform a session model id names (R19): a Bedrock id carries an
98 * `anthropic.claude-` segment (`us.anthropic.claude-…`) or a `-v1:0`
99 * version, or is an inference-profile ARN (`arn:aws:bedrock:…`, a valid
100 * `ANTHROPIC_MODEL` there); a Vertex id carries a `@YYYYMMDD` version;
101 * anything else, a bare alias included, is the Anthropic API. Pure: a press
102 * and `status` read `$.session.model` once and hand the result on.
103 */
104export function platformOf(sessionModelId: string): Platform {
105 if (
106 BEDROCK_ID.test(sessionModelId) ||
107 BEDROCK_VERSION.test(sessionModelId) ||
108 BEDROCK_ARN.test(sessionModelId)
109 ) {
110 return "bedrock";
111 }
112 if (VERTEX_VERSION.test(sessionModelId) || VERTEX_ID.test(sessionModelId)) {
113 return "vertex";
114 }
115 return "anthropic";
116}
117
118/**
119 * Whether `chain` holds an alias rung the platform leaves unpriced (R19): a
120 * press on it is `$?` and counts toward no cap. `status` and the caps' texts
121 * say so rather than let a cap read as protection it is not.
122 */
123export function hasUnpricedAlias(
124 chain: readonly string[],
125 platform: Platform,
126): boolean {
127 return platform !== "anthropic" && chain.some(isAlias);
128}
129
130/**
131 * Parse a JSON string of price overrides. Returns a Record of valid Price
132 * entries, or an empty object if the JSON is invalid. When `log` is provided,
133 * invalid input triggers exactly one call with a short reason.
134 */
135export function parsePrices(
136 json: string,
137 log?: (message: string) => void,
138): Record<string, Price> {
139 if (json === "") {
140 return {};
141 }
142
143 let parsed: unknown;
144 try {
145 parsed = JSON.parse(json);
146 } catch {
147 if (log) {
148 log("prompt-lift: prices option ignored: invalid JSON");
149 }
150 return {};
151 }
152
153 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
154 if (log) {
155 log("prompt-lift: prices option ignored: not an object");
156 }
157 return {};
158 }
159
160 const result: Record<string, Price> = {};
161 const entries = Object.entries(parsed);
162
163 for (const [key, value] of entries) {
164 if (typeof value !== "object" || value === null) {
165 if (log) {
166 log("prompt-lift: prices option ignored: entry not an object");
167 }
168 return {};
169 }
170
171 const candidate = value as Record<string, unknown>;
172 const requiredFields = ["input", "output", "cacheWrite", "cacheRead"];
173
174 for (const field of requiredFields) {
175 if (!(field in candidate)) {
176 if (log) {
177 log(`prompt-lift: prices option ignored: missing ${field} field`);
178 }
179 return {};
180 }
181
182 const fieldValue = candidate[field];
183 if (typeof fieldValue !== "number") {
184 if (log) {
185 log(`prompt-lift: prices option ignored: ${field} is not a number`);
186 }
187 return {};
188 }
189
190 if (fieldValue < 0) {
191 if (log) {
192 log(`prompt-lift: prices option ignored: ${field} is negative`);
193 }
194 return {};
195 }
196 }
197
198 result[key] = {
199 input: candidate.input as number,
200 output: candidate.output as number,
201 cacheWrite: candidate.cacheWrite as number,
202 cacheRead: candidate.cacheRead as number,
203 };
204 }
205
206 return result;
207}
208
209export function splitList(value: unknown): string[] {
210 // If value is already an array, join it with commas first
211 const str = Array.isArray(value) ? value.join(",") : String(value ?? "");
212
213 // Split by comma, trim each item, filter out empty strings, and deduplicate while keeping order
214 const seen = new Set<string>();
215 const result: string[] = [];
216
217 for (const item of str.split(",")) {
218 const trimmed = item.trim();
219 if (trimmed && !seen.has(trimmed)) {
220 seen.add(trimmed);
221 result.push(trimmed);
222 }
223 }
224
225 return result;
226}
227
228/**
229 * A spend cap option (R9): dollars above zero; 0, a negative or a non-finite
230 * value is off.
231 */
232function capOption(value: unknown): number {
233 return typeof value === "number" && Number.isFinite(value) && value > 0
234 ? value
235 : 0;
236}
237
238export function parseOptions(options: PluginOptions): Config {
239 const models = splitList(options.models ?? DEFAULT_MODEL_CHAIN);
240 const effortModelsArr = splitList(
241 options.effortModels ?? models.slice(0, 2).join(","),
242 );
243
244 let pricesIssue: string | undefined;
245 const pricesJson = typeof options.prices === "string" ? options.prices : "";
246 const parsedPrices = parsePrices(pricesJson, (msg) => {
247 pricesIssue = msg;
248 });
249 const prices = mergePrices(parsedPrices);
250
251 return {
252 models,
253 effortModels: new Set(effortModelsArr),
254 maxTokens: typeof options.maxTokens === "number" ? options.maxTokens : 2000,
255 timeoutMsFirst:
256 typeof options.timeoutMsFirst === "number"
257 ? options.timeoutMsFirst
258 : 30000,
259 timeoutMsNext:
260 typeof options.timeoutMsNext === "number" ? options.timeoutMsNext : 15000,
261 minWords: typeof options.minWords === "number" ? options.minWords : 3,
262 maxDraftChars:
263 typeof options.maxDraftChars === "number" ? options.maxDraftChars : 4000,
264 includeConversation:
265 typeof options.includeConversation === "boolean"
266 ? options.includeConversation
267 : false,
268 conversationMessages:
269 typeof options.conversationMessages === "number"
270 ? options.conversationMessages
271 : 6,
272 instructionExclude: splitList(
273 options.instructionExclude ?? DEFAULT_INSTRUCTION_EXCLUDE,
274 ),
275 instructionFileMaxChars:
276 typeof options.instructionFileMaxChars === "number"
277 ? options.instructionFileMaxChars
278 : 40000,
279 skillExclude: splitList(options.skillExclude ?? DEFAULT_SKILL_EXCLUDE),
280 relevantSkills:
281 typeof options.relevantSkills === "number" ? options.relevantSkills : 8,
282 skillPlacement:
283 options.skillPlacement === "mention" || options.skillPlacement === "run"
284 ? options.skillPlacement
285 : "mention",
286 skillPrefixAllow: splitList(options.skillPrefixAllow ?? ""),
287 filePrefix:
288 options.filePrefix === "plain" || options.filePrefix === "at"
289 ? options.filePrefix
290 : "plain",
291 keptShareFloor:
292 typeof options.keptShareFloor === "number" ? options.keptShareFloor : 0.5,
293 maxGrowthChars:
294 typeof options.maxGrowthChars === "number"
295 ? options.maxGrowthChars
296 : 1200,
297 enhanceAction:
298 typeof options.enhanceAction === "string"
299 ? options.enhanceAction
300 : "app:cycleDiffBase",
301 extraRules:
302 typeof options.extraRules === "string" ? options.extraRules : "",
303 sessionCapUsd: capOption(options.sessionCapUsd),
304 dayCapUsd: capOption(options.dayCapUsd),
305 prices,
306 pricesIssue,
307 };
308}
309
310/**
311 * The price table a person's rows lead: `priceFor` scans the keys in the
312 * order they were added, so a longer user key wins over the default fragment
313 * it contains (`us.anthropic.claude-sonnet-5-5-v2` over `sonnet-5-5`); a user
314 * row named as a default (`fable-5-1`, `sonnet-5-5`, `haiku-5-5`) replaces it.
315 */
316export function mergePrices(
317 user: Readonly<Record<string, Price>>,
318): Readonly<Record<string, Price>> {
319 return Object.fromEntries([
320 ...Object.entries(user),
321 ...Object.entries(DEFAULT_PRICES).filter(
322 ([key]) => !Object.hasOwn(user, key),
323 ),
324 ]);
325}
326
327/**
328 * The price row for a model, or undefined when nothing prices it (R3). An
329 * alias on its own prices as its family's current generation on the
330 * Anthropic API only: on Bedrock and Vertex the engine's alias defaults can
331 * lag a generation and `$.model.complete` never says which id an alias
332 * called, so the alias is unpriced there (R19). Otherwise the first key that
333 * is a substring of the model, in the table's own order (`mergePrices` puts
334 * the person's rows first), on every platform alike.
335 */
336export function priceFor(
337 model: string,
338 prices: Readonly<Record<string, Price>>,
339 platform: Platform = "anthropic",
340): Price | undefined {
341 const aliasKey = isAlias(model)
342 ? ALIAS_PRICE_KEYS[model.toLowerCase()]
343 : undefined;
344 const aliased =
345 aliasKey !== undefined && platform === "anthropic"
346 ? prices[aliasKey]
347 : undefined;
348 if (aliased !== undefined) {
349 return aliased;
350 }
351 for (const key of Object.keys(prices)) {
352 if (model.includes(key)) {
353 return prices[key];
354 }
355 }
356 return undefined;
357}
358
359/**
360 * What a call cost by the price table, or undefined when the table has no
361 * row for the model (R3): an unknown price is shown as `$?`, never as $0.
362 * `platform` is where the call went (R19); the hooks pass the session's.
363 */
364export function estimateUsd(
365 usage: ModelUsage,
366 model: string,
367 prices: Readonly<Record<string, Price>>,
368 platform: Platform = "anthropic",
369): number | undefined {
370 const price = priceFor(model, prices, platform);
371 if (!price) {
372 return undefined;
373 }
374
375 const cost =
376 ((usage.input_tokens ?? 0) * price.input +
377 (usage.cache_creation_input_tokens ?? 0) * price.cacheWrite +
378 (usage.cache_read_input_tokens ?? 0) * price.cacheRead +
379 (usage.output_tokens ?? 0) * price.output) /
380 1e6;
381
382 return cost;
383}
384
385export function shortModel(id: string): string {
386 if (id.includes("fable-5-1")) {
387 return "fable 5.1";
388 }
389 if (id.includes("sonnet-5-5")) {
390 return "sonnet 5.5";
391 }
392 if (id.includes("haiku-5-5")) {
393 return "haiku 5.5";
394 }
395 return id;
396}
397hooks/actions.ts 279 lines1import { atom, read, update } from "claude-code";
2import type {
3 EngineInterface,
4 PromptFillArgs,
5 StateDollar,
6} from "claude-code";
7import { epochMs, fillBox, log, notify, readBox, redraw, submit } from "./api";
8import { armUndo, disarmUndo, undoArmedAt } from "./arm";
9import { capToast } from "./chain";
10import { cancel, TOAST_NOT_TAKEN, type DraftReader } from "./enhance";
11import { clearVerdict } from "./verdict";
12
13// Send, Undo/Redo, Cancel and the resets (spec §5.3; C3, C11, C13, C14).
14// The atoms are declared here as well as in enhance.ts: `read` and `update`
15// only validate against an atom made in the calling file, and the same
16// plugin and key name the same value.
17
18const phase = atom({ plugin: "prompt-lift", key: "phase" } as const, "idle");
19const original = atom(
20 { plugin: "prompt-lift", key: "original" } as const,
21 null,
22);
23const enhanced = atom(
24 { plugin: "prompt-lift", key: "enhanced" } as const,
25 null,
26);
27const isUndone = atom(
28 { plugin: "prompt-lift", key: "isUndone" } as const,
29 false,
30);
31const lastDecorations = atom(
32 { plugin: "prompt-lift", key: "lastDecorations" } as const,
33 null,
34);
35
36export { cancel };
37
38/** How long a second Undo press has to confirm discarding an edit (C13). */
39export const UNDO_REARM_MS = 10_000;
40
41const TOAST_NOTHING_TO_SEND = "prompt-lift: nothing to send";
42const TOAST_ENTER_ONLY =
43 "prompt-lift: press Enter to send; @files and images only travel with Enter";
44const TOAST_NOTHING_TO_UNDO = "prompt-lift: nothing to undo";
45const TOAST_UNDO_ARM =
46 "prompt-lift: box edited since Enhance; press Undo again within 10 s to discard";
47const TOAST_RESTORED = "prompt-lift: restored your draft";
48const TOAST_REAPPLIED = "prompt-lift: re-applied the enhanced prompt";
49
50/**
51 * `@file` mentions (`@src/a.ts`, `@~/notes.md`, an MCP `@server:resource`)
52 * and pasted images travel only with Enter (C10, C11); the anchor keeps an
53 * email's `@` out of it.
54 */
55const ENTER_ONLY = /(^|\s)@[\w./~:-]+|\[Image #\d+\]/;
56
57/**
58 * The prompt noun as Send drives it before it touches state, so a test's `$`
59 * can stand in; the box is read through a DraftReader, which a test stubs.
60 */
61export type SendIo = {
62 prompt: Pick<EngineInterface["prompt"], "submit" | "fill">;
63};
64
65/** What `submitDraft` came to; `send` toasts, logs and clears from it. */
66export type SendStep =
67 | { outcome: "refused"; toast: string }
68 | { outcome: "dropped"; reason: string }
69 | { outcome: "sent"; box: "cleared" | "changed" | "kept" };
70
71/** C11: the toast Send answers instead of submitting, or undefined to go on. */
72export function sendRefusal(boxText: string): string | undefined {
73 if (boxText.trim() === "") {
74 return TOAST_NOTHING_TO_SEND;
75 }
76 if (ENTER_ONLY.test(boxText)) {
77 return TOAST_ENTER_ONLY;
78 }
79 return undefined;
80}
81
82/**
83 * Send's steps 1–3 up to the box: submit the box as the person's own words
84 * and clear it only while it still holds what was sent (C11). The engine
85 * queues the prompt while a turn runs. No toast and no state here.
86 */
87export async function submitDraft(
88 $: SendIo,
89 box: DraftReader,
90): Promise<SendStep> {
91 const text = (await readBox(box)).text;
92 const refusal = sendRefusal(text);
93 if (refusal !== undefined) {
94 return { outcome: "refused", toast: refusal };
95 }
96 const entered = await submit($, { text, asUser: true });
97 if (entered.drop !== undefined) {
98 return { outcome: "dropped", reason: entered.drop };
99 }
100 if ((await readBox(box)).text !== text) {
101 return { outcome: "sent", box: "changed" };
102 }
103 const cleared = await fillBox($, { text: "", mode: "replace" });
104 return { outcome: "sent", box: cleared.isFilled ? "cleared" : "kept" };
105}
106
107/** Send (spec §5.3): the band button's path; Enter is the other one. */
108export async function send($: EngineInterface): Promise<void> {
109 const step = await submitDraft($, $);
110 if (step.outcome === "refused") {
111 notify($, step.toast);
112 return;
113 }
114 if (step.outcome === "dropped") {
115 // The engine shows a hook's reason itself; the draft stays, Undo with it.
116 log($, `prompt-lift: send dropped: ${step.reason}`, { to: "debug" });
117 return;
118 }
119 if (step.box === "kept") {
120 log($, "prompt-lift: the box kept the sent text", { to: "debug" });
121 }
122 await clearHeld($);
123}
124
125export type UndoDecision =
126 | { kind: "nothing" }
127 | { kind: "arm" }
128 | { kind: "undo"; text: string }
129 | { kind: "redo"; text: string };
130
131/**
132 * C13: Undo acts only while the box still shows what the mod last put there
133 * (or is empty); an edited box takes a second press within the window. With
134 * `isUndone` the same press is Redo, guarded the same way.
135 */
136export function decideUndo(a: {
137 boxText: string;
138 original: string | null;
139 enhanced: string | null;
140 isUndone: boolean;
141 armedAt: number | undefined;
142 now: number;
143}): UndoDecision {
144 if (a.original === null) {
145 return { kind: "nothing" };
146 }
147 // The `enhanced` write can fail after `original` was held (§12): Undo then
148 // restores the draft alone, and there is nothing to redo.
149 const target: UndoDecision = a.isUndone
150 ? a.enhanced === null
151 ? { kind: "nothing" }
152 : { kind: "redo", text: a.enhanced }
153 : { kind: "undo", text: a.original };
154 if (target.kind === "nothing") {
155 return target;
156 }
157 const shown = a.isUndone ? a.original : a.enhanced;
158 const isClean = a.boxText === "" || a.boxText === shown;
159 return !isClean && !isUndoWindowOpen(a.armedAt, a.now)
160 ? { kind: "arm" }
161 : target;
162}
163
164/**
165 * Whether a second Undo press at `now` still confirms (C13): the window opened
166 * by the press at `armedAt` lasts UNDO_REARM_MS, its last millisecond
167 * included. The band draws its `↶ Undo?` marker by the same test.
168 */
169export function isUndoWindowOpen(
170 armedAt: number | undefined,
171 now: number,
172): boolean {
173 return armedAt !== undefined && now - armedAt <= UNDO_REARM_MS;
174}
175
176/**
177 * Undo's fill, after the window was disarmed. A refusal resolves and `undo`
178 * redraws for it; a rejection (the box dropped out from under the press)
179 * escapes before that, with no state write to draw the disarm either: ask for
180 * the redraw that drops a stale `↶ Undo?` marker, then let the same error go
181 * (R27).
182 */
183async function fillOrRedraw($: EngineInterface, args: PromptFillArgs) {
184 try {
185 return await fillBox($, args);
186 } catch (err) {
187 redraw($, "ui.render");
188 throw err;
189 }
190}
191
192/** Undo / Redo (spec §5.3): the band button and `/prompt-lift undo`. */
193export async function undo($: EngineInterface): Promise<void> {
194 const now = await epochMs($);
195 const decision = decideUndo({
196 boxText: (await readBox($)).text,
197 original: await read($, original),
198 enhanced: await read($, enhanced),
199 isUndone: await read($, isUndone),
200 armedAt: undoArmedAt(),
201 now,
202 });
203 if (decision.kind === "nothing") {
204 notify($, TOAST_NOTHING_TO_UNDO);
205 return;
206 }
207 // Undo is the next action: it ends a verdict the row still shows (R25)
208 await clearVerdict($);
209 if (decision.kind === "arm") {
210 armUndo(now);
211 // The window is module state, which the band cannot see change: ask for
212 // the redraw that draws its marker (R25)
213 redraw($, "ui.render");
214 notify($, TOAST_UNDO_ARM);
215 return;
216 }
217 disarmUndo();
218 const isRedo = decision.kind === "redo";
219 const decorations = isRedo ? await read($, lastDecorations) : null;
220 const filled = await fillOrRedraw($, {
221 text: decision.text,
222 mode: "replace",
223 ...(decorations && { decorations }),
224 });
225 if (!filled.isFilled) {
226 const cause = filled.refusal ?? "refused";
227 // The disarm above is module state too, and no write follows a refused
228 // fill: ask for the redraw that drops a stale `↶ Undo?` marker (R25)
229 redraw($, "ui.render");
230 notify($, capToast(`${TOAST_NOT_TAKEN} (${cause})`));
231 return;
232 }
233 await update($, isUndone, () => !isRedo);
234 notify($, isRedo ? TOAST_REAPPLIED : TOAST_RESTORED);
235}
236
237/**
238 * What Send, Enter, /clear and a resume forget: the pair, the phase and the
239 * last press's verdict. Every Enter comes through here, so nothing held, an
240 * idle phase and no verdict write nothing.
241 */
242export async function clearHeld($: EngineInterface): Promise<void> {
243 disarmUndo();
244 await clearVerdict($);
245 const isHeld = (await read($, original)) !== null;
246 const isIdle = (await read($, phase)) === "idle";
247 if (!isHeld && isIdle) {
248 return;
249 }
250 await update($, original, () => null);
251 await update($, enhanced, () => null);
252 await update($, isUndone, () => false);
253 await update($, lastDecorations, () => null);
254 await update($, phase, () => "idle");
255}
256
257/**
258 * A phase a dropped environment left at `running` is reset at session.start
259 * and when a command runs (§5.3 resets). With a pair held (this press's or an
260 * earlier one's) the phase goes to `done`, so the Send/Undo row returns after
261 * a hot reload; otherwise to `idle`. The caller hands in how to ask whether a
262 * call is in flight (enhance.ts owns that; importing it here would make a
263 * cycle), asked after the reads: a live call's phase is never reset, not
264 * even one that began while the phase was being read.
265 */
266export async function resetStuckPhase(
267 $: StateDollar,
268 isInFlight: () => boolean,
269): Promise<void> {
270 if ((await read($, phase)) !== "running") {
271 return;
272 }
273 const isHeld = (await read($, original)) !== null;
274 if (isInFlight()) {
275 return;
276 }
277 await update($, phase, () => (isHeld ? "done" : "idle"));
278}
279hooks/band.tsx 264 lines1import { atom, read } from "claude-code";
2import type { EngineInterface, RenderElement, RenderInput } from "claude-code";
3import { epochMs, surface } from "./api";
4import type { Config } from "./config";
5import { hasUnpricedAlias } from "./config";
6import { enhance, isCallInFlight } from "./enhance";
7import { cancel, isUndoWindowOpen, send, undo } from "./actions";
8import { undoArmedAt } from "./arm";
9import { resolveChain, resolveChainWithPlatform } from "./gather";
10import { leftText, readCapStatus } from "./caps";
11import { readTailCount } from "./tail";
12import type { BandState, Face, Strip, StripRung } from "./band-row";
13import {
14 CANCEL_LABEL,
15 RUNG_SEPARATOR,
16 colorOf,
17 counterText,
18 doneCostText,
19 doneFace,
20 doneRowText,
21 headline,
22 idleRowText,
23 runningRowText,
24 stripOf,
25 tierOf,
26 verdictFace,
27} from "./band-row";
28
29// The band (spec §5.1; C12, C17, C19; R25): one row above the prompt, chained
30// above whatever the engine and the plugins beneath draw there. It redraws
31// only when a value it reads changes or the engine redraws the band; it never
32// calls the model and runs no timer: while the chain runs, the rung strip
33// moves because each rung's `runningModel` write (enhance.ts) redraws the
34// band, a refusal's reason stays because `settle` writes `lastVerdict` and
35// the next action writes it away, and the Undo window's marker (module
36// state, so no write) is drawn because `undo()` asks for the redraw (R25), and
37// dropped by the same ask when a refused restore disarms it.
38// The atoms are declared here as well as in enhance.ts: `read` validates
39// against an atom made in the calling file, and the same plugin and key name
40// the same value. The row's words and the width tiers live in band-row.tsx;
41// this file alone touches `$`.
42
43const phase = atom({ plugin: "prompt-lift", key: "phase" } as const, "idle");
44const isUndone = atom(
45 { plugin: "prompt-lift", key: "isUndone" } as const,
46 false,
47);
48const model = atom({ plugin: "prompt-lift", key: "model" } as const, "");
49const runningModel = atom(
50 { plugin: "prompt-lift", key: "runningModel" } as const,
51 "",
52);
53const lastUsage = atom(
54 { plugin: "prompt-lift", key: "lastUsage" } as const,
55 null,
56);
57const noCacheStreak = atom(
58 { plugin: "prompt-lift", key: "noCacheStreak" } as const,
59 0,
60);
61const lastVerdict = atom(
62 { plugin: "prompt-lift", key: "lastVerdict" } as const,
63 null,
64);
65
66/** With no width from the engine, the 80-column tier: hint gone, figures kept. */
67const DEFAULT_COLUMNS = 80;
68
69export type BandEvent = RenderInput<"AbovePrompt">;
70export type BandNext = (e: BandEvent) => Promise<RenderElement>;
71
72/** The `ui.render { component: 'AbovePrompt' }` hook's body. */
73export async function renderBand(
74 $: EngineInterface,
75 e: BandEvent,
76 next: BandNext,
77 cfg: Config,
78): Promise<RenderElement> {
79 if (e.props.hasSurvey) {
80 return next(e);
81 }
82 const below = await next(e);
83 const row = await drawRow($, e, cfg);
84 const { Box } = surface($, e);
85 // The mods beneath share this slot: one blank row keeps the band and their
86 // drawing apart; with nothing beneath the gap has no second child to open.
87 return (
88 <Box flexDirection="column" rowGap={1}>
89 {row}
90 {below}
91 </Box>
92 );
93}
94
95/** The first rung as the last press used it, or as the chain resolves now. */
96async function firstRung($: EngineInterface, cfg: Config): Promise<string> {
97 const used = await read($, model);
98 if (used !== "") {
99 return used;
100 }
101 return (await resolveChain($, cfg))[0] ?? "";
102}
103
104/**
105 * What a cap leaves (R9), or nothing with no cap; over a chain whose alias
106 * rungs the platform leaves unpriced it ends with the R19 note, since none
107 * of those presses count toward the cap.
108 */
109async function capLeft(
110 $: EngineInterface,
111 cfg: Config,
112): Promise<string | undefined> {
113 const caps = await readCapStatus($, cfg);
114 if (leftText(caps) === undefined) {
115 return undefined;
116 }
117 const { chain, platform } = await resolveChainWithPlatform($, cfg);
118 return leftText(caps, hasUnpricedAlias(chain, platform));
119}
120
121async function drawRow(
122 $: EngineInterface,
123 e: BandEvent,
124 cfg: Config,
125): Promise<RenderElement> {
126 const { Box, Button, Text } = surface($, e);
127 // One read of the width per draw (R25); the tiers drop parts as it falls.
128 const columns = e.props.bodyColumns ?? DEFAULT_COLUMNS;
129 const tier = tierOf(columns);
130 // The headline leads in its own non-shrinking Box, so a squeeze lands on
131 // the details Text, which truncates: the row stays one line (§5.1).
132 const headlineNode = (state: BandState, figure?: string): RenderElement => {
133 const head = headline(state, tier, figure);
134 return (
135 <Box flexShrink={0}>
136 <Text color={head.color} bold>
137 {head.text}
138 </Text>
139 </Box>
140 );
141 };
142 const detailsNode = (text: string): RenderElement => (
143 <Text dimColor wrap="truncate-end">
144 {text}
145 </Text>
146 );
147 // The strip's runs nest in the dim details Text (the d.ts allows inline
148 // elements in a Text): a failed rung struck and dim, the active one bold in
149 // the headline's colour, a pending one dim; ` › ` between them.
150 const rungNode = (run: StripRung): RenderElement =>
151 run.state === "active" ? (
152 <Text bold color={colorOf("running")} dimColor={false}>
153 {run.label}
154 </Text>
155 ) : (
156 <Text dimColor strikethrough={run.state === "failed"}>
157 {run.label}
158 </Text>
159 );
160 const stripNode = (strip: Strip): RenderElement => (
161 <Text dimColor wrap="truncate-end">
162 {strip.runs.flatMap((run, index) =>
163 index === 0 ? [rungNode(run)] : [RUNG_SEPARATOR, rungNode(run)],
164 )}
165 </Text>
166 );
167 const current = await read($, phase);
168 // A `running` no call backs is one a dropped environment left (§5.3 resets):
169 // drawn as idle here and reset by the next command; a render never writes.
170 const isRunning = current === "running" && isCallInFlight();
171 const isDone = current === "done";
172 if (isRunning) {
173 // Empty between the phase's write and the first rung's: stripOf then
174 // draws the chain's first rung, so the strip and counter lead the frame
175 const running = await read($, runningModel);
176 const strip = stripOf(await resolveChain($, cfg), running, tier, columns);
177 return (
178 <Box gap={1}>
179 {headlineNode(
180 "running",
181 strip === undefined ? undefined : counterText(strip, tier),
182 )}
183 <Button
184 key="cancel"
185 label={CANCEL_LABEL}
186 hotkey="c"
187 variant="primary"
188 onPress={cancel}
189 />
190 {strip === undefined
191 ? detailsNode(runningRowText(running))
192 : stripNode(strip)}
193 </Box>
194 );
195 }
196 const left = await capLeft($, cfg);
197 const tail = await readTailCount($, cfg);
198 const action = cfg.enhanceAction === "" ? {} : { action: cfg.enhanceAction };
199 // The primary Button per state (R25): Enhance when idle, Send once done.
200 const enhanceButton = (primary: boolean): RenderElement => (
201 <Button
202 key="enhance"
203 label="Enhance (e)"
204 hotkey="e"
205 {...action}
206 {...(primary ? { variant: "primary" } : {})}
207 onPress={() => enhance($, cfg, { kind: "box" })}
208 />
209 );
210 // A press that ended without a fill leaves its reason here until the next
211 // action (R25): the headline and details change, the Buttons do not.
212 const verdict = await read($, lastVerdict);
213 if (!isDone) {
214 const face: Face =
215 verdict === null
216 ? {
217 state: "idle",
218 details: idleRowText(await firstRung($, cfg), left, tail, tier),
219 }
220 : verdictFace(verdict);
221 return (
222 <Box gap={1}>
223 {headlineNode(face.state, face.figure)}
224 {enhanceButton(true)}
225 {detailsNode(face.details)}
226 </Box>
227 );
228 }
229 const undone = await read($, isUndone);
230 const usage = await read($, lastUsage);
231 const streak = await read($, noCacheStreak);
232 const summary =
233 usage === null ? "" : doneRowText(usage, streak, left, tail, tier);
234 // The cost leads the done headline (R25), so a squeeze never truncates it;
235 // a record that failed to write leaves the headline without one.
236 const cost = usage === null ? undefined : doneCostText(usage);
237 // The Undo window is module state, so a draw asks the clock once; nothing
238 // ticks, and a window that has closed drops its marker at this next redraw
239 const armedAt = undoArmedAt();
240 const isArmed =
241 armedAt !== undefined && isUndoWindowOpen(armedAt, await epochMs($));
242 const face = doneFace({ verdict, isArmed, isUndone: undone, cost, summary });
243 return (
244 <Box gap={1}>
245 {headlineNode(face.state, face.figure)}
246 {enhanceButton(false)}
247 <Button
248 key="send"
249 label={e.props.isWorking ? "Send (queues)" : "Send (s)"}
250 hotkey="s"
251 variant="primary"
252 onPress={() => send($)}
253 />
254 <Button
255 key="undo"
256 label={undone ? "Redo (u)" : "Undo (u)"}
257 hotkey="u"
258 onPress={() => undo($)}
259 />
260 {detailsNode(face.details)}
261 </Box>
262 );
263}
264hooks/commands.ts 346 lines1import { atom, read, update } from "claude-code";
2import type { CommandRunResult, EngineInterface } from "claude-code";
3import { ask, history, kvDel, kvSet, log, notify } from "./api";
4import { hasUnpricedAlias, type Config } from "./config";
5import { decide } from "./chain";
6import { wordDiffLines } from "./diff";
7import { enhance, isCallInFlight } from "./enhance";
8import { resetStuckPhase, undo } from "./actions";
9import {
10 configuredIds,
11 errorMessage,
12 matchRungs,
13 MODEL_STORE_KEY,
14 resolveChain,
15 resolveChainWithPlatform,
16 lastAssistantText,
17} from "./gather";
18import {
19 capPreviewBlock,
20 parseModelArg,
21 parseTailArg,
22 parsePreviewArg,
23 type ParseModelResult,
24 type TailArg,
25 type PreviewArg,
26} from "./subcommands";
27import { preGuard } from "./guards";
28import { status } from "./status";
29import {
30 capDollars,
31 capsLine,
32 parseCapArg,
33 readCapStatus,
34 CAP_USAGE,
35 type CapArg,
36} from "./caps";
37import { readTailCount } from "./tail";
38import { prepare } from "./prepare";
39import { hashBlock } from "./context";
40
41// The one registered command's sub-words (spec §5.2, C18): bare → the hint;
42// `undo`, `status`, `diff`, `probe` when the whole argument is that word
43// (any case); `model <id|alias|reset>` and `cap <usd|off>` (R9); anything
44// else → Enhance the text, so
45// "diff the two configs" is a draft, not the diff. There is no `send`
46// sub-word: a slash command replaces the box, so it can never see the draft.
47// The atoms are declared here as well as in enhance.ts: `read` and `update`
48// validate against an atom made in the calling file.
49
50const model = atom({ plugin: "prompt-lift", key: "model" } as const, "");
51const original = atom(
52 { plugin: "prompt-lift", key: "original" } as const,
53 null,
54);
55const enhanced = atom(
56 { plugin: "prompt-lift", key: "enhanced" } as const,
57 null,
58);
59const capOverride = atom(
60 { plugin: "prompt-lift", key: "capOverride" } as const,
61 null,
62);
63const tailOverride = atom(
64 { plugin: "prompt-lift", key: "tailOverride" } as const,
65 null,
66);
67
68const TAIL_USAGE =
69 "prompt-lift: tail takes on, off or a count 1-50, e.g. /prompt-lift tail 6";
70
71export const HINT =
72 "prompt-lift: type /prompt-lift <your prompt>, press ctrl+x b, or click Enhance";
73const MODEL_USAGE = "use /prompt-lift model <id|alias|reset>";
74const NOTHING_TO_DIFF = "prompt-lift: nothing to diff";
75const PROBE_PROMPT = "ping";
76const PROBE_MAX_TOKENS = 8;
77// A full model id as every Claude surface spells one: `claude-…` after an
78// optional dotted provider prefix (`us.anthropic.`, `anthropic.`). An alias
79// (`sonnet`, `haiku`, `opus`) never does; it must name a configured rung.
80const MODEL_ID_PATTERN = /^(?:[\w-]+\.)*claude-/i;
81
82export type ProbeResult = { model: string; outcome: string };
83
84export async function runCommand(
85 $: EngineInterface,
86 cfg: Config,
87 rawArgs: string,
88): Promise<CommandRunResult> {
89 // A `running` no call backs would freeze the band (§5.3 resets): idle first.
90 await resetStuckPhase($, isCallInFlight);
91 const args = rawArgs.trim();
92 if (args === "") {
93 return { text: HINT };
94 }
95
96 // `model` routes first: alone or with one argument it is the sub-command,
97 // with more words it is a draft to enhance.
98 const [first = ""] = args.split(/\s+/);
99 if (first.toLowerCase() === "model") {
100 const modelParse = parseModelArg(args);
101 if (modelParse.kind === "invalid") {
102 // For "model" alone, show the current first rung along with usage
103 // (keep existing test behavior); for invalid tokens, show the reason
104 const tokens = args.trim().split(/\s+/);
105 if (tokens.length === 1) {
106 return { text: await chooseModel($, cfg, "") };
107 }
108 return { text: modelParse.reason };
109 }
110 if (modelParse.kind === "not-a-model-command") {
111 // Prose starting with "model" → enhance the full args string
112 await enhance($, cfg, { kind: "args", text: args });
113 return {};
114 }
115 if (modelParse.kind === "reset") {
116 await kvDel($, MODEL_STORE_KEY);
117 return { text: await chainText($, cfg) };
118 }
119 // modelParse.kind === "set"
120 return { text: await chooseModel($, cfg, modelParse.model) };
121 }
122
123 // `cap` follows the same rule (R9): one argument is the cap, more is prose
124 const capArg = parseCapArg(args);
125 if (capArg.kind !== "prose") {
126 return runCap($, cfg, capArg);
127 }
128
129 // `tail` follows the same rule (R10): one argument is a control, more is prose
130 const tailArg = parseTailArg(args);
131 if (tailArg.kind !== "prose") {
132 return runTail($, cfg, tailArg);
133 }
134
135 // `preview` follows the same rule: one argument is a control, more is prose
136 const previewArg = parsePreviewArg(args);
137 if (previewArg.kind !== "prose") {
138 return runPreview($, cfg, previewArg);
139 }
140
141 switch (args.toLowerCase()) {
142 case "undo":
143 await undo($);
144 return {};
145 case "status":
146 return { text: await status($, cfg) };
147 case "diff":
148 return { text: await diffText($) };
149 case "probe":
150 return { text: await probe($, cfg) };
151 default:
152 await enhance($, cfg, { kind: "args", text: args });
153 return {};
154 }
155}
156
157/**
158 * `model <id|alias|reset>`: the choice is kept in `$.store` for this machine
159 * and leads the chain (`resolveChain`); `reset` returns to the configured
160 * chain. An alias is stored as the configured id it names (spec C1 pins ids;
161 * pricing, `effort` and the dedupe all go by the id); a choice that names no
162 * rung and is not a full id is refused and nothing is stored.
163 */
164async function chooseModel(
165 $: EngineInterface,
166 cfg: Config,
167 choice: string,
168): Promise<string> {
169 if (choice === "") {
170 const chain = await resolveChain($, cfg);
171 return `prompt-lift: first rung ${chain[0] ?? "none"} · ${MODEL_USAGE}`;
172 }
173 if (choice.toLowerCase() === "reset") {
174 await kvDel($, MODEL_STORE_KEY);
175 return chainText($, cfg);
176 }
177 const ids = await configuredIds($, cfg);
178 const matched = matchRungs(choice, ids);
179 if (matched.length > 1) {
180 return `prompt-lift: ${choice} names several rungs (${matched.join(", ")}) · ${MODEL_USAGE}`;
181 }
182 const pinned =
183 matched[0] ?? (MODEL_ID_PATTERN.test(choice) ? choice : undefined);
184 if (pinned === undefined) {
185 return `prompt-lift: ${choice} is neither a rung of ${ids.join(" → ")} nor a full model id · ${MODEL_USAGE}`;
186 }
187 await kvSet($, MODEL_STORE_KEY, pinned);
188 return chainText($, cfg);
189}
190
191/**
192 * `cap <usd|off>`: the session cap for this session only, kept in state
193 * rather than the store (a budget for now, not a setting); `cap` alone prints
194 * the caps as `status` does. The day cap is an option only (R9).
195 */
196async function runCap(
197 $: EngineInterface,
198 cfg: Config,
199 arg: Exclude<CapArg, { kind: "prose" }>,
200): Promise<CommandRunResult> {
201 if (arg.kind === "invalid") {
202 return { text: CAP_USAGE };
203 }
204 if (arg.kind === "show") {
205 // As `status` prints it: the R19 note where alias rungs never count
206 const { chain, platform } = await resolveChainWithPlatform($, cfg);
207 const uncounted = hasUnpricedAlias(chain, platform);
208 return { text: capsLine(await readCapStatus($, cfg), uncounted) };
209 }
210 await update($, capOverride, () => arg.usd);
211 notify(
212 $,
213 arg.usd === 0
214 ? "prompt-lift: session cap off for this session"
215 : `prompt-lift: session cap ${capDollars(arg.usd)} for this session`,
216 );
217 return {};
218}
219
220/** The chain as it now resolves; the `model` atom follows, so the band redraws. */
221async function chainText($: EngineInterface, cfg: Config): Promise<string> {
222 const chain = await resolveChain($, cfg);
223 const first = chain[0] ?? "";
224 await update($, model, () => first);
225 return `prompt-lift: first rung ${first || "none"} · chain ${chain.join(" → ")}`;
226}
227
228/** `diff`: the held pair as `- removed` / `+ inserted` runs of words. */
229async function diffText($: EngineInterface): Promise<string> {
230 const from = await read($, original);
231 const to = await read($, enhanced);
232 if (from === null || to === null) {
233 return NOTHING_TO_DIFF;
234 }
235 const lines = wordDiffLines(from, to);
236 return lines.length === 0 ? NOTHING_TO_DIFF : lines.join("\n");
237}
238
239export function probeText(results: readonly ProbeResult[]): string {
240 return [
241 "prompt-lift probe",
242 ...results.map((r) => `${r.model}: ${r.outcome}`),
243 ].join("\n");
244}
245
246/**
247 * `probe`: one tiny unmarked call per rung, in chain order, each judged by
248 * the chain's own `decide`; on demand only, never from a press.
249 */
250export async function probe($: EngineInterface, cfg: Config): Promise<string> {
251 const chain = await resolveChain($, cfg);
252 const results: ProbeResult[] = [];
253 for (const [index, id] of chain.entries()) {
254 results.push({ model: id, outcome: await probeRung($, cfg, id, index) });
255 }
256 return probeText(results);
257}
258
259async function probeRung(
260 $: EngineInterface,
261 cfg: Config,
262 id: string,
263 index: number,
264): Promise<string> {
265 try {
266 const reply = await ask($, {
267 model: id,
268 prompt: PROBE_PROMPT,
269 maxTokens: PROBE_MAX_TOKENS,
270 timeoutMs: index === 0 ? cfg.timeoutMsFirst : cfg.timeoutMsNext,
271 });
272 return decide(reply, undefined, false).outcome;
273 } catch (err) {
274 log($, `prompt-lift: probe ${id}: ${errorMessage(err)}`, {
275 to: "debug",
276 });
277 return "refused";
278 }
279}
280
281async function runTail(
282 $: EngineInterface,
283 cfg: Config,
284 arg: Exclude<TailArg, { kind: "prose" }>,
285): Promise<CommandRunResult> {
286 if (arg.kind === "show") {
287 const count = await readTailCount($, cfg);
288 const state =
289 count === 0 ? "conversation off" : `conversation last ${count} messages`;
290 return { text: `prompt-lift: ${state} · /prompt-lift tail on|off|<count>` };
291 }
292
293 if (arg.kind === "invalid") {
294 return { text: TAIL_USAGE };
295 }
296
297 if (arg.kind === "off") {
298 await update($, tailOverride, () => 0);
299 return { text: "prompt-lift: tail off this session" };
300 }
301
302 // arg.kind === "on" or "set"
303 const count = arg.kind === "on" ? cfg.conversationMessages : arg.count;
304 await update($, tailOverride, () => count);
305 return { text: `prompt-lift: tail on · last ${count} messages this session` };
306}
307
308async function runPreview(
309 $: EngineInterface,
310 cfg: Config,
311 arg: Exclude<PreviewArg, { kind: "prose" }>,
312): Promise<CommandRunResult> {
313 if (arg.kind === "empty") {
314 return {
315 text: "prompt-lift: preview takes a draft, e.g. /prompt-lift preview write a commit",
316 };
317 }
318
319 // arg.kind === "show"
320 const draft = arg.draft;
321 const rows = await history($);
322
323 // Run the same preGuard as enhance does
324 const guard = preGuard(draft, cfg, lastAssistantText(rows));
325 if (!guard.ok) {
326 return { text: guard.toast };
327 }
328
329 // Gather context and prepare exactly as a press would
330 const prepared = await prepare($, cfg, draft, rows);
331
332 // Format the output: the dynamic block (cut: this row joins the transcript),
333 // then the static block's size and hash
334 const hash = hashBlock(prepared.staticBlock);
335 const chars = prepared.staticBlock.length;
336
337 const output = [
338 "--- dynamic block (sent uncached) ---",
339 capPreviewBlock(prepared.dynamicBlock),
340 "",
341 `--- static block: ${chars} chars · hash ${hash} ---`,
342 ].join("\n");
343
344 return { text: output };
345}
346hooks/enhance.ts 398 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, StateValue } from "claude-code";
3import type { Verdict } from "../types";
4import { ask, epochMs, fillBox, history, log, notify, readBox } from "./api";
5import type { Config, Platform } from "./config";
6import { SYSTEM } from "./rules";
7import { postProcess, preGuard, type PostOk } from "./guards";
8import { prepare, type Prepared } from "./prepare";
9import {
10 capToast,
11 costText,
12 cut,
13 runChain,
14 ERROR_MAX,
15 type ChainFail,
16 type ChainOk,
17} from "./chain";
18import {
19 errorMessage,
20 lastAssistantText,
21 namedPathsExist,
22 resolveChainWithPlatform,
23} from "./gather";
24import {
25 assessCall,
26 attemptsUsd,
27 describeAttempt,
28 judgeCall,
29 recordOrLog,
30 recordSpend,
31 type AnsweredCall,
32} from "./stats";
33import { holdPair, recordSuccess } from "./record";
34import { capReachedToast, readCapStatus } from "./caps";
35import { clearVerdict, keepVerdict, verdictOf, type Stop } from "./verdict";
36
37// The Enhance flow (spec §5.3 steps 1–9) and the session state it writes.
38// Every atom this file reads or writes is declared here (the usage atoms live
39// in stats.ts, the held pair's in record.ts): `read` and `update` only
40// validate against an atom made in the file that calls them.
41
42export const phase = atom(
43 { plugin: "prompt-lift", key: "phase" } as const,
44 "idle",
45);
46export const model = atom({ plugin: "prompt-lift", key: "model" } as const, "");
47export const runningModel = atom(
48 { plugin: "prompt-lift", key: "runningModel" } as const,
49 "",
50);
51export const requestId = atom(
52 { plugin: "prompt-lift", key: "requestId" } as const,
53 0,
54);
55
56const TOAST_BUSY = "prompt-lift: already enhancing";
57const TOAST_FINE = "prompt-lift: already specific, left as is";
58const TOAST_DRAFT_CHANGED =
59 "prompt-lift: draft changed while enhancing, left as is";
60const TOAST_MISSING_PATH =
61 "prompt-lift: named a file that does not exist, kept your prompt";
62const TOAST_CANCELLED = "prompt-lift: cancelled, draft unchanged";
63const TOAST_NO_MODELS = "prompt-lift: no models configured";
64export const TOAST_NOT_TAKEN =
65 "prompt-lift: the prompt box did not take the text";
66
67export type EnhanceSource = { kind: "box" } | { kind: "args"; text: string };
68
69type Phase = StateValue<"prompt-lift", "phase">;
70
71let busy = false;
72let cancelled = false;
73let controller: AbortController | undefined;
74let pendingNote: Promise<void> = Promise.resolve();
75
76// Cancel (spec §5.3, C3): the flag tells an `aborted` reply from a Cancel
77// apart from a timeout, so the chain stops instead of falling back.
78export function cancel(): void {
79 if (controller === undefined) {
80 return;
81 }
82 cancelled = true;
83 controller.abort();
84}
85
86/**
87 * Whether a press is under way: its controller is set before its first write
88 * and cleared once its phase has settled; the band and the resets ask.
89 */
90export function isCallInFlight(): boolean {
91 return controller !== undefined;
92}
93
94export async function enhance(
95 $: EngineInterface,
96 cfg: Config,
97 source: EnhanceSource,
98): Promise<void> {
99 if (busy) {
100 notify($, TOAST_BUSY);
101 return;
102 }
103 busy = true;
104 cancelled = false;
105 const abort = new AbortController();
106 controller = abort;
107 // Where the phase stood before this press: a refused or failed press goes
108 // back there, so a held result keeps its Send and Undo (§5.1, C13).
109 let before: Phase = "idle";
110 // Paid for whatever comes after the answer (G5, C19): owed until a record
111 // is attempted; a throw before that leaves it to the catch.
112 let unrecorded: AnsweredCall | undefined;
113 // How the press ended if it filled nothing: said now, kept by `settle` (R25)
114 let verdict: Verdict | undefined;
115 const stop: Stop = (kind, toast, cost) => {
116 verdict = verdictOf(
117 kind,
118 toast,
119 cost === undefined ? undefined : (cost.usd ?? null),
120 );
121 notify($, cost === undefined ? capToast(toast) : withCost(toast, cost.usd));
122 };
123 try {
124 // The press's first clock read (R25): the record times the press from it
125 // with one more read, no ticks
126 const pressedAt = await epochMs($);
127 before = await read($, phase);
128 const myId = await update($, requestId, (n) => n + 1);
129 // A new press ends the last press's verdict (R25)
130 await clearVerdict($);
131 // A same-value write (over a stale `running`) still redraws the band
132 await update($, phase, () => "running");
133 const draft = await readDraft($, source);
134 const rows = await history($);
135 const guard = preGuard(draft, cfg, lastAssistantText(rows));
136 if (!guard.ok) {
137 stop("refused", guard.toast);
138 return;
139 }
140 // R9: a window at its cap refuses the press the same way, before any call
141 const capped = capReachedToast(await readCapStatus($, cfg));
142 if (capped !== undefined) {
143 stop("refused", capped);
144 return;
145 }
146 const prepared = await prepare($, cfg, draft, rows);
147 // The platform is read once per press (R19): every rung's price goes by it
148 const { chain, platform } = await resolveChainWithPlatform($, cfg);
149 const run = await callChain($, cfg, chain, prepared, abort.signal);
150 if (!run.ok) {
151 await reportFailure($, cfg, run, platform, stop);
152 return;
153 }
154 // Judged now, written once the outcome is known: after the pair on a fill.
155 // Owed from the answer on, so the read that judges the hash may throw.
156 unrecorded = judgeCall(cfg, {
157 run,
158 staticBlock: prepared.staticBlock,
159 relevantCount: prepared.relevantCount,
160 platform,
161 rungs: chain.length,
162 pressedAt,
163 });
164 const call = await assessCall($, unrecorded);
165 unrecorded = call;
166 const post = postProcess({
167 draft,
168 reply: run.text,
169 outputTokens: run.usage.output_tokens,
170 cfg,
171 knownSkills: prepared.knownSkills,
172 listedSkills: prepared.listedSkills,
173 descriptions: prepared.descriptions,
174 documents: `${prepared.documentsText}\n${prepared.userRulesText}`,
175 });
176 // A record that fails costs the bookkeeping, never the verdict's toast
177 if (!post.ok) {
178 unrecorded = undefined;
179 await recordOrLog($, call);
180 stop("refused", "fine" in post ? TOAST_FINE : post.toast, {
181 usd: call.usd,
182 });
183 return;
184 }
185 const filledText = await verifyAndFill($, {
186 source,
187 draft,
188 root: prepared.root,
189 rootDirs: prepared.rootDirs,
190 userRules: prepared.userRulesText,
191 post,
192 myId,
193 usd: call.usd,
194 stop,
195 });
196 if (filledText === undefined) {
197 unrecorded = undefined;
198 await recordOrLog($, call);
199 return;
200 }
201 await holdPair($, { draft, filledText, decorations: post.decorations });
202 unrecorded = undefined;
203 await recordSuccess($, { run, post, call });
204 } catch (err) {
205 if (unrecorded !== undefined) {
206 await recordOrLog($, unrecorded);
207 }
208 // The message is cut where it is composed (R2); capToast makes it one line
209 const message = cut(errorMessage(err), ERROR_MAX);
210 const toast = capToast(`prompt-lift: enhance failed (${message})`);
211 // Its toast names no cost; the row's figure is what a paid call cost,
212 // and with no call to pay for the row shows none
213 verdict = verdictOf(
214 "failed",
215 toast,
216 unrecorded === undefined ? undefined : (unrecorded.usd ?? null),
217 );
218 notify($, toast);
219 log($, `prompt-lift: enhance failed: ${String(err)}`, { to: "debug" });
220 } finally {
221 // In flight until the phase has settled: a command in that window must
222 // not reset this press's phase, and a press then is rightly told "busy".
223 try {
224 await settle($, before, verdict);
225 } finally {
226 busy = false;
227 controller = undefined;
228 }
229 }
230}
231
232/** The one engine noun step 2 reads, so a test can hand it a stub box. */
233export type DraftReader = { prompt: Pick<EngineInterface["prompt"], "read"> };
234
235/** Step 2: the draft is the prompt box for the button, the args for the command. */
236export async function readDraft(
237 $: DraftReader,
238 source: EnhanceSource,
239): Promise<string> {
240 return source.kind === "box" ? (await readBox($)).text : source.text;
241}
242
243/**
244 * Step 7's box re-read: the button path must still show the draft, the
245 * command path an empty box; anything else means the person kept typing.
246 */
247export function expectedBoxText(source: EnhanceSource, draft: string): string {
248 return source.kind === "box" ? draft : "";
249}
250
251/**
252 * C19: a toast that follows a model call ends with what the call cost, or
253 * `$? · (no price)` when the model has no price (R3); one line, capped (R2).
254 */
255function withCost(toast: string, usd: number | undefined): string {
256 return capToast(`${toast} · ${costText(usd)}`);
257}
258
259async function callChain(
260 $: EngineInterface,
261 cfg: Config,
262 chain: string[],
263 prepared: Prepared,
264 signal: AbortSignal,
265): Promise<ChainOk | ChainFail> {
266 await update($, model, () => chain[0] ?? "");
267 return runChain({
268 complete: (req, o) => ask($, req, o),
269 chain,
270 cfg,
271 system: SYSTEM,
272 blocks: [
273 { text: prepared.staticBlock, cache: true },
274 { text: prepared.dynamicBlock },
275 ],
276 signal,
277 isCancelled: () => cancelled,
278 onAttempt: (id) => noteRunningModel($, id),
279 });
280}
281
282/** `runChain` reports each rung synchronously; the write is awaited by `settle`. */
283function noteRunningModel($: EngineInterface, id: string): void {
284 pendingNote = update($, runningModel, () => id)
285 .then(() => undefined)
286 .catch((err: unknown) => {
287 log($, `prompt-lift: runningModel: ${errorMessage(err)}`, {
288 to: "debug",
289 });
290 });
291}
292
293/**
294 * A chain that answered nothing still paid for its rungs (G5): record that,
295 * priced on the platform the press ran on (R19), then say so.
296 */
297async function reportFailure(
298 $: EngineInterface,
299 cfg: Config,
300 run: ChainFail,
301 platform: Platform,
302 stop: Stop,
303): Promise<void> {
304 if (run.attempts.length === 0) {
305 stop("failed", TOAST_NO_MODELS);
306 return;
307 }
308 const usd = attemptsUsd(run.attempts, cfg.prices, platform);
309 await recordSpend($, usd);
310 if (run.cancelled) {
311 stop("cancelled", TOAST_CANCELLED, { usd });
312 return;
313 }
314 const trail = run.attempts.map(describeAttempt).join(" → ");
315 stop("failed", `prompt-lift: no model answered · ${trail}`, { usd });
316 log($, `prompt-lift: enhance failed: ${trail}`);
317}
318
319/** Steps 7 and 8: path guard, stale check, box re-read, fill. */
320async function verifyAndFill(
321 $: EngineInterface,
322 a: {
323 source: EnhanceSource;
324 draft: string;
325 root: string;
326 rootDirs: readonly string[];
327 userRules: string;
328 post: PostOk;
329 myId: number;
330 usd: number | undefined;
331 stop: Stop;
332 },
333): Promise<string | undefined> {
334 const hasPaths = await namedPathsExist($, {
335 root: a.root,
336 draft: a.draft,
337 userRules: a.userRules,
338 result: a.post.text,
339 rootDirs: a.rootDirs,
340 });
341 if (!hasPaths) {
342 a.stop("refused", TOAST_MISSING_PATH, { usd: a.usd });
343 return undefined;
344 }
345 if ((await read($, requestId)) !== a.myId) {
346 return undefined;
347 }
348 if ((await readBox($)).text !== expectedBoxText(a.source, a.draft)) {
349 a.stop("refused", TOAST_DRAFT_CHANGED, { usd: a.usd });
350 return undefined;
351 }
352 const filled = await fillBox($, {
353 text: a.post.text,
354 mode: "replace",
355 decorations: a.post.decorations,
356 });
357 if (!filled.isFilled) {
358 const cause = filled.refusal ?? "refused";
359 a.stop("failed", `${TOAST_NOT_TAKEN} (${cause})`, { usd: a.usd });
360 return undefined;
361 }
362 return filled.text;
363}
364
365/**
366 * Never leaves the phase at `running` once a press has settled: `done` stays
367 * `done` when a later press is refused, anything else returns to `idle`. A
368 * press that ended without a fill leaves its verdict first, so the settled
369 * row is drawn with the reason (R25). The running rung is cleared last: the
370 * running row reads it, so clearing it while the phase is still `running`
371 * would draw one frame back on the chain's first rung; the settled rows do
372 * not read it.
373 */
374async function settle(
375 $: EngineInterface,
376 before: Phase,
377 verdict: Verdict | undefined,
378): Promise<void> {
379 try {
380 await pendingNote;
381 await keepVerdict($, verdict);
382 if ((await read($, phase)) === "running") {
383 await update($, phase, () => (before === "done" ? "done" : "idle"));
384 }
385 } catch (err) {
386 log($, `prompt-lift: could not reset the phase: ${errorMessage(err)}`, {
387 to: "debug",
388 });
389 }
390 try {
391 await update($, runningModel, () => "");
392 } catch (err) {
393 log($, `prompt-lift: could not clear the rung: ${errorMessage(err)}`, {
394 to: "debug",
395 });
396 }
397}
398hooks/verdict.ts 104 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface } from "claude-code";
3import type { Verdict } from "../types";
4import { log } from "./api";
5import { capToast } from "./chain";
6import { errorMessage } from "./gather";
7
8// The verdict row's state (R25): why the last press ended without a fill,
9// kept in `lastVerdict` until the next action. `settle` writes it; a press,
10// Enter, any /prompt-lift command, session.start and the Undo button write it
11// away. Nothing times it out. The atom is declared here as well as in
12// band.tsx, which reads it: `read` and `update` validate against an atom made
13// in the calling file, and the same plugin and key name the same value.
14
15const lastVerdict = atom(
16 { plugin: "prompt-lift", key: "lastVerdict" } as const,
17 null,
18);
19
20type VerdictKind = Verdict["kind"];
21
22/**
23 * How a press that fills nothing ends (R25): the toast now, and the verdict
24 * for the row. `cost` is given when a call was made and ends the toast with
25 * its dollars (`usd` undefined: the model has no price, R3); with none, no
26 * call was made: the toast names no cost and the verdict carries none.
27 */
28export type Stop = (
29 kind: VerdictKind,
30 toast: string,
31 cost?: { usd: number | undefined },
32) => void;
33
34const PLUGIN_LEAD = "prompt-lift: ";
35/** A cancel's own word, which its headline already says (`✗ Cancelled`). */
36const CANCEL_LEAD = "cancelled, ";
37
38/**
39 * A toast's reason, less its lead: the plugin prefix for every kind, and the
40 * cancel's word only for a cancel, so a refusal or failure whose own reason
41 * begins `cancelled, ` keeps it.
42 */
43function reasonOf(kind: VerdictKind, toast: string): string {
44 const reason = toast.startsWith(PLUGIN_LEAD)
45 ? toast.slice(PLUGIN_LEAD.length)
46 : toast;
47 return kind === "cancelled" && reason.startsWith(CANCEL_LEAD)
48 ? reason.slice(CANCEL_LEAD.length)
49 : reason;
50}
51
52/**
53 * The row's copy of a toast: its reason, one line and capped as the toast is,
54 * less the lead and the cost the toast ends with. `usd` is what the press
55 * cost: left out when no call was made (the row then shows no cost), null
56 * when the model has no price (R3).
57 */
58export function verdictOf(
59 kind: VerdictKind,
60 toast: string,
61 usd?: number | null,
62): Verdict {
63 const text = capToast(reasonOf(kind, toast));
64 return usd === undefined ? { kind, text } : { kind, text, usd };
65}
66
67/**
68 * Keeps `verdict` for the row. A write that fails costs the row its reason,
69 * never the press its phase: it is logged, and the toast has said it.
70 */
71export async function keepVerdict(
72 $: EngineInterface,
73 verdict: Verdict | undefined,
74): Promise<void> {
75 if (verdict === undefined) {
76 return;
77 }
78 try {
79 await update($, lastVerdict, () => verdict);
80 } catch (err) {
81 log($, `prompt-lift: could not keep the verdict: ${errorMessage(err)}`, {
82 to: "debug",
83 });
84 }
85}
86
87/**
88 * The next action ends the verdict. Every Enter comes through here, so with
89 * none held nothing is written. A read or write that fails costs the row its
90 * clearing, never the action: it is logged, like `keepVerdict`, so a
91 * /prompt-lift command and the session start cannot fail on it.
92 */
93export async function clearVerdict($: EngineInterface): Promise<void> {
94 try {
95 if ((await read($, lastVerdict)) !== null) {
96 await update($, lastVerdict, () => null);
97 }
98 } catch (err) {
99 log($, `prompt-lift: could not clear the verdict: ${errorMessage(err)}`, {
100 to: "debug",
101 });
102 }
103}
104hooks/api.ts 145 lines1import type { EngineInterface, ResolveInput } from "claude-code";
2
3// R29, the engine seam: every call the mod makes to the engine's `$.<noun>.<fn>`
4// goes through one of these wrappers (register.tsx's own hook bodies and
5// `$.command.register` aside). `claude plugin validate` lists each API it finds
6// with the functions that call it directly, and cuts that `calls` line near
7// 1,000 characters; with the call sites spread over a dozen hooks the labels
8// ran past the cut and a listing was held for an incomplete footprint. Here
9// each API has one caller and one short label, so the whole footprint fits.
10// Nothing is hidden: every API is still named, and a wrapper only forwards.
11//
12// A wrapper takes `$` first, then the arguments the engine takes, and returns
13// what the engine returns: a promise stays the engine's promise, a rejection
14// stays its rejection. Each asks only for the slice of `$` it calls, so a
15// caller holding less than the whole engine still type-checks. The names are
16// short and unique across hooks/ on purpose (tests/api.test.ts pins the
17// length); `$.state.*` goes through the kit's `read` and `update` instead.
18//
19// Every wrapper is a function declaration spelling `$.noun.fn(...)` at its call
20// site: the engine follows `$` past an import only into a function declaration
21// (an arrow bound to a const fails the load), and reads the API off that spelling.
22//
23// `getHome` is the one wrapper that forwards no argument: `$.env.get` takes a
24// literal name and refuses a spread, so `getHome` fixes `"HOME"` inside.
25
26/** The slice of `$` a wrapper calls: member `M` of noun `N`. */
27type Slice<
28 N extends keyof EngineInterface,
29 M extends keyof EngineInterface[N],
30> = Record<N, Pick<EngineInterface[N], M>>;
31
32/** The arguments the engine's own `N.M` takes: a wrapper takes them as they are. */
33type Args<
34 N extends keyof EngineInterface,
35 M extends keyof EngineInterface[N],
36> = EngineInterface[N][M] extends (...args: infer A) => unknown ? A : never;
37
38// clock
39export function epochMs($: Slice<"clock", "now">) {
40 return $.clock.now();
41}
42
43// command
44export function cmds($: Slice<"command", "list">) {
45 return $.command.list();
46}
47
48// env
49export function getHome($: Slice<"env", "get">) {
50 return $.env.get("HOME");
51}
52
53// fs (`fsRead` is the text form: the mod never reads bytes)
54export function fsHas($: Slice<"fs", "exists">, ...a: Args<"fs", "exists">) {
55 return $.fs.exists(...a);
56}
57export function fsList($: Slice<"fs", "list">, ...a: Args<"fs", "list">) {
58 return $.fs.list(...a);
59}
60export function fsRead($: Slice<"fs", "read">, path: string) {
61 return $.fs.read(path);
62}
63export function fsStat($: Slice<"fs", "stat">, ...a: Args<"fs", "stat">) {
64 return $.fs.stat(...a);
65}
66
67// model
68export function ask(
69 $: Slice<"model", "complete">,
70 ...a: Args<"model", "complete">
71) {
72 return $.model.complete(...a);
73}
74
75// prompt
76export function fillBox(
77 $: Slice<"prompt", "fill">,
78 ...a: Args<"prompt", "fill">
79) {
80 return $.prompt.fill(...a);
81}
82export function readBox($: Slice<"prompt", "read">) {
83 return $.prompt.read();
84}
85export function submit(
86 $: Slice<"prompt", "submit">,
87 ...a: Args<"prompt", "submit">
88) {
89 return $.prompt.submit(...a);
90}
91
92// session (`history` is the no-argument form: the main conversation's rows)
93export function history($: Slice<"session", "messages">) {
94 return $.session.messages();
95}
96export function sessMod($: Slice<"session", "model">) {
97 return $.session.model();
98}
99export function rootDir($: Slice<"session", "root">) {
100 return $.session.root();
101}
102export function usageOf(
103 $: Slice<"session", "usage">,
104 ...a: Args<"session", "usage">
105) {
106 return $.session.usage(...a);
107}
108
109// store
110export function kvGet($: Slice<"store", "get">, ...a: Args<"store", "get">) {
111 return $.store.get(...a);
112}
113export function kvSet($: Slice<"store", "set">, ...a: Args<"store", "set">) {
114 return $.store.set(...a);
115}
116export function kvDel(
117 $: Slice<"store", "delete">,
118 ...a: Args<"store", "delete">
119) {
120 return $.store.delete(...a);
121}
122export function kvKeys($: Slice<"store", "keys">) {
123 return $.store.keys();
124}
125
126// ui
127export function redraw(
128 $: Slice<"ui", "invalidate">,
129 ...a: Args<"ui", "invalidate">
130) {
131 return $.ui.invalidate(...a);
132}
133export function log($: Slice<"ui", "log">, ...a: Args<"ui", "log">) {
134 return $.ui.log(...a);
135}
136export function notify($: Slice<"ui", "toast">, ...a: Args<"ui", "toast">) {
137 return $.ui.toast(...a);
138}
139export function surface<E extends ResolveInput>(
140 $: Slice<"ui", "resolve">,
141 e: E,
142) {
143 return $.ui.resolve(e);
144}
145hooks/arm.ts 21 lines1// When the last Undo press armed the C13 confirm window (spec §10 module
2// state). Its own module because actions.ts arms and reads it while
3// enhance.ts disarms it on every successful fill, and enhance.ts cannot
4// import actions.ts (which imports enhance.ts).
5
6let armedAt: number | undefined;
7
8/** The clock reading of the Undo press that armed the window, if any. */
9export function undoArmedAt(): number | undefined {
10 return armedAt;
11}
12
13export function armUndo(now: number): void {
14 armedAt = now;
15}
16
17/** A new pair, a fill or a reset: the window no longer refers to this box. */
18export function disarmUndo(): void {
19 armedAt = undefined;
20}
21hooks/chain.ts 278 lines1import type {
2 ModelCompleteRequest,
3 ModelCompleteResult,
4 ModelTextBlock,
5 ModelUsage,
6} from "claude-code";
7import type { Config } from "./config";
8import { shortModel } from "./config";
9
10/**
11 * One rung's call: what it answered and what it cost (zeros when nothing
12 * arrived).
13 */
14export type Attempt = {
15 model: string;
16 outcome: string;
17 usage: ModelUsage;
18};
19
20const NO_USAGE: ModelUsage = {
21 input_tokens: 0,
22 output_tokens: 0,
23 cache_read_input_tokens: 0,
24 cache_creation_input_tokens: 0,
25};
26
27export type ChainOk = {
28 ok: true;
29 text: string;
30 usage: ModelUsage;
31 model: string;
32 attempts: Attempt[];
33};
34
35export type ChainFail = {
36 ok: false;
37 cancelled: boolean;
38 attempts: Attempt[];
39};
40
41export type Complete = (
42 req: ModelCompleteRequest,
43 opts: { signal: AbortSignal },
44) => Promise<ModelCompleteResult>;
45
46export function decide(
47 r: ModelCompleteResult,
48 previous: Attempt | undefined,
49 isCancelled: boolean,
50): { outcome: string; action: "return" | "next" | "stop" } {
51 // Answered → return
52 if ("isAnswered" in r && r.isAnswered) {
53 return { outcome: "ok", action: "return" };
54 }
55
56 // Aborted + cancelled → stop
57 if ("reason" in r && r.reason === "aborted" && isCancelled) {
58 return { outcome: "cancelled", action: "stop" };
59 }
60
61 // Aborted (no cancel) → next (timeout)
62 if ("reason" in r && r.reason === "aborted") {
63 return { outcome: "timeout", action: "next" };
64 }
65
66 // Empty reply → next
67 if ("reason" in r && r.reason === "empty-reply") {
68 return { outcome: "no text (possible refusal)", action: "next" };
69 }
70
71 // API errors
72 if ("reason" in r && r.reason === "api-error") {
73 const { error, status } = r;
74
75 // invalid_request → stop
76 if (error === "invalid_request") {
77 return { outcome: "invalid_request", action: "stop" };
78 }
79
80 // authentication_failed: stop if previous was also authentication_failed, else next
81 if (error === "authentication_failed") {
82 if (previous?.outcome === "authentication_failed") {
83 return { outcome: "authentication_failed", action: "stop" };
84 }
85 return { outcome: "authentication_failed", action: "next" };
86 }
87
88 // Other errors: return error kind, or status if error is 'unknown' and status is non-null
89 if (error === "unknown" && status !== null) {
90 return { outcome: String(status), action: "next" };
91 }
92 return { outcome: error, action: "next" };
93 }
94
95 // Fallback (shouldn't happen)
96 return { outcome: "unknown", action: "next" };
97}
98
99export async function runChain(a: {
100 complete: Complete;
101 chain: readonly string[];
102 cfg: Config;
103 system: string;
104 blocks: readonly ModelTextBlock[];
105 signal: AbortSignal;
106 isCancelled: () => boolean;
107 onAttempt?: (model: string, index: number) => void;
108}): Promise<ChainOk | ChainFail> {
109 const attempts: Attempt[] = [];
110 let previous: Attempt | undefined = undefined;
111
112 for (const [index, model] of a.chain.entries()) {
113 // Call onAttempt callback if provided
114 if (a.onAttempt) {
115 a.onAttempt(model, index);
116 }
117
118 // Build request; the effort key only for the models that take it
119 const req: ModelCompleteRequest = {
120 model,
121 system: a.system,
122 prompt: a.blocks,
123 maxTokens: a.cfg.maxTokens,
124 timeoutMs: index === 0 ? a.cfg.timeoutMsFirst : a.cfg.timeoutMsNext,
125 ...(a.cfg.effortModels.has(model) ? { effort: "low" } : {}),
126 };
127
128 try {
129 const result = await a.complete(req, { signal: a.signal });
130 const decision = decide(result, previous, a.isCancelled());
131 const attempt: Attempt = {
132 model,
133 outcome: decision.outcome,
134 usage: result.usage,
135 };
136 attempts.push(attempt);
137
138 // Answered (`decide` says "return" for this arm alone): the reply
139 if (result.isAnswered) {
140 return {
141 ok: true,
142 text: result.text,
143 usage: result.usage,
144 model,
145 attempts,
146 };
147 }
148
149 if (decision.action === "stop") {
150 // Stop the chain
151 return {
152 ok: false,
153 cancelled: decision.outcome === "cancelled",
154 attempts,
155 };
156 }
157
158 // decision.action === "next", continue to next model
159 previous = attempt;
160 } catch {
161 // Thrown complete → attempt outcome 'refused', next
162 const attempt: Attempt = { model, outcome: "refused", usage: NO_USAGE };
163 attempts.push(attempt);
164 previous = attempt;
165 // Continue to next model
166 }
167 }
168
169 // Exhausted the chain
170 return {
171 ok: false,
172 cancelled: false,
173 attempts,
174 };
175}
176
177export function formatTokens(count: number): string {
178 if (count >= 1000) {
179 return (count / 1000).toFixed(1) + "k";
180 }
181 return String(count);
182}
183
184/** The longest a toast may be (spec §12); longer text ends in an ellipsis. */
185export const TOAST_MAX = 200;
186/** The model's `why` as the success toast shows it (R2). */
187export const WHY_MAX = 60;
188/** An error's message as a failure toast shows it (R2). */
189export const ERROR_MAX = 80;
190
191/**
192 * `text` within `max` characters, ending in `…` when it had to be cut. Counted
193 * and sliced by code point, so an emoji on the cut is kept whole rather than
194 * left as a lone surrogate before the ellipsis.
195 */
196export function cut(text: string, max: number): string {
197 const chars = Array.from(text);
198 return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : text;
199}
200
201/**
202 * Every toast that carries model- or engine-supplied text passes through
203 * here (R2): whitespace and newlines collapse to single spaces, then the one
204 * line is cut to TOAST_MAX. The fixed toasts are one short line as written.
205 */
206export function capToast(text: string): string {
207 return cut(text.replace(/\s+/g, " ").trim(), TOAST_MAX);
208}
209
210/** Dollars to the cent, to the tenth of a cent under one; `$?` when unknown. */
211export function formatDollars(usd: number | undefined): string {
212 if (usd === undefined) {
213 return "$?";
214 }
215 if (usd < 0.01) {
216 return "$" + usd.toFixed(3);
217 }
218 return "$" + usd.toFixed(2);
219}
220
221/** The dollars a toast or row shows, and why there are none (R3). */
222export function costText(usd: number | undefined): string {
223 const dollars = formatDollars(usd);
224 return usd === undefined ? `${dollars} · (no price)` : dollars;
225}
226
227export function usageText(a: {
228 model: string;
229 usage: ModelUsage;
230 usd: number | undefined;
231 why?: string;
232 hashChanged?: boolean;
233 attempts: readonly Attempt[];
234}): string {
235 const model = shortModel(a.model);
236
237 const inputTokens =
238 (a.usage.input_tokens ?? 0) + (a.usage.cache_creation_input_tokens ?? 0);
239 const cachedTokens = a.usage.cache_read_input_tokens ?? 0;
240 const outputTokens = a.usage.output_tokens ?? 0;
241
242 const parts: string[] = [
243 `prompt-lift: ${model}`,
244 `${formatTokens(cachedTokens)} cached`,
245 `${formatTokens(inputTokens)} in`,
246 `${outputTokens} out`,
247 costText(a.usd),
248 ];
249
250 let text = parts.join(" · ");
251
252 // Add optional suffixes
253 if (a.why && a.why.length > 0) {
254 text += ` · ${cut(a.why, WHY_MAX)}`;
255 }
256
257 const cacheCreation = a.usage.cache_creation_input_tokens ?? 0;
258 if (cacheCreation > 0) {
259 text += " · (cache written)";
260 } else if (cachedTokens === 0 && cacheCreation === 0) {
261 text += " · (no cache)";
262 }
263
264 if (a.hashChanged) {
265 text += " · static block changed";
266 }
267
268 // Add failed attempts
269 const failedAttempts = a.attempts.slice(0, -1); // All except the last (answering) one
270 for (const attempt of failedAttempts) {
271 if (attempt.outcome !== "ok") {
272 text += ` · via ${shortModel(attempt.model)}: ${attempt.outcome}`;
273 }
274 }
275
276 return text;
277}
278hooks/gather.ts 323 lines1import type { EngineInterface, SessionMessage } from "claude-code";
2import {
3 cmds,
4 fsHas,
5 fsList,
6 fsRead,
7 kvGet,
8 log,
9 rootDir,
10 sessMod,
11 usageOf,
12} from "./api";
13import { platformOf, type Config, type Platform } from "./config";
14import {
15 capText,
16 isExcluded,
17 selectInstructionFiles,
18 selectSkillNames,
19 subprojectDir,
20 type CommandEntry,
21 type MemoryFile,
22} from "./context";
23import { homeSpellings } from "./home";
24import { isFileLike, pathTokens } from "./tokens";
25import { readTailCount } from "./tail";
26
27// Reads what an Enhance press needs from the engine: the instruction files the
28// session loaded (minus `instructionExclude`), the skill listing, the command
29// list, the root's directories, an optional subproject CLAUDE.md (left unread
30// when `instructionExclude` matches it) and the conversation tail. No state
31// lives here.
32
33const SESSION_MODEL_TOKEN = "session";
34const SUBPROJECT_MAX_CHARS = 12_000;
35const CONVERSATION_MESSAGE_MAX_CHARS = 600;
36
37export type Document = { path: string; text: string };
38
39export type Gathered = {
40 root: string;
41 documents: Document[];
42 documentsText: string;
43 skillNames: string[];
44 commands: CommandEntry[];
45 descriptions: Map<string, string>;
46 /** The project root's directories, for the subproject and path guards. */
47 rootDirs: string[];
48 subproject: Document | undefined;
49 conversation: string[] | undefined;
50};
51
52export function errorMessage(err: unknown): string {
53 return err instanceof Error ? err.message : String(err);
54}
55
56export function basename(path: string): string {
57 return (
58 path
59 .split("/")
60 .filter((part) => part.length > 0)
61 .at(-1) ?? path
62 );
63}
64
65export function lastAssistantText(
66 rows: readonly SessionMessage[],
67): string | undefined {
68 return rows.findLast((row) => row.role === "assistant")?.text;
69}
70
71export function conversationTail(
72 rows: readonly SessionMessage[],
73 count: number,
74): string[] {
75 if (count <= 0) {
76 return [];
77 }
78 return rows
79 .slice(-count)
80 .map(
81 (row) =>
82 `${row.role}: ${capText(row.text, CONVERSATION_MESSAGE_MAX_CHARS)}`,
83 );
84}
85
86/** `$.store` key of the first rung `/prompt-lift model` chose (spec §5.2). */
87export const MODEL_STORE_KEY = "model";
88
89/** `cfg.models` with the `session` token replaced by the session's model id. */
90function withSessionModel(cfg: Config, sessionModel: string): string[] {
91 return cfg.models
92 .map((id) => (id === SESSION_MODEL_TOKEN ? sessionModel : id))
93 .filter((id) => id.length > 0);
94}
95
96/** The configured ids with the `session` token resolved, in order. */
97export async function configuredIds(
98 $: EngineInterface,
99 cfg: Config,
100): Promise<string[]> {
101 const needsSession = cfg.models.includes(SESSION_MODEL_TOKEN);
102 return withSessionModel(cfg, needsSession ? await sessMod($) : "");
103}
104
105/**
106 * The configured ids a choice names, case-insensitively: the id spelled in
107 * full when there is one, else every id containing the choice (`sonnet` →
108 * `us.anthropic.claude-sonnet-5-5`). The engine takes an alias, but the mod
109 * prices, dedupes and picks `effort` by the pinned id, so the pinned id is
110 * what gets stored and called.
111 */
112export function matchRungs(choice: string, ids: readonly string[]): string[] {
113 const wanted = choice.toLowerCase();
114 const exact = ids.filter((id) => id.toLowerCase() === wanted);
115 if (exact.length > 0) {
116 return exact;
117 }
118 return ids.filter((id) => id.toLowerCase().includes(wanted));
119}
120
121/**
122 * The configured chain with the `session` token resolved, led by the stored
123 * choice when there is one, without repeats. A stored alias (an older store)
124 * leads as the rung it names when it names exactly one.
125 */
126export async function resolveChain(
127 $: EngineInterface,
128 cfg: Config,
129): Promise<string[]> {
130 return ledByStored($, await configuredIds($, cfg));
131}
132
133/** `ids` led by the stored choice when there is one, without repeats. */
134async function ledByStored(
135 $: EngineInterface,
136 ids: readonly string[],
137): Promise<string[]> {
138 const stored = await kvGet($, MODEL_STORE_KEY);
139 if (typeof stored !== "string" || stored === "") {
140 return [...new Set(ids)];
141 }
142 const matched = matchRungs(stored, ids);
143 const lead = matched.length === 1 ? (matched[0] ?? stored) : stored;
144 return [...new Set([lead, ...ids])];
145}
146
147/** The chain a press or `status` works on and the platform it is priced on. */
148export type ResolvedChain = { chain: string[]; platform: Platform };
149
150/**
151 * The chain as `resolveChain` gives it, with the platform the session's
152 * model id names (R19). It reads `$.session.model` once per press or
153 * command, for the platform and, when `models` holds `session`, for that
154 * token too: a press prices its rungs by the platform and `status` says
155 * where an alias rung is unpriced.
156 */
157export async function resolveChainWithPlatform(
158 $: EngineInterface,
159 cfg: Config,
160): Promise<ResolvedChain> {
161 const sessionModel = await sessMod($);
162 return {
163 chain: await ledByStored($, withSessionModel(cfg, sessionModel)),
164 platform: platformOf(sessionModel),
165 };
166}
167
168async function readDocuments(
169 $: EngineInterface,
170 files: readonly MemoryFile[],
171 maxChars: number,
172): Promise<Document[]> {
173 const docs = await Promise.all(
174 files.map(async (file): Promise<Document | undefined> => {
175 try {
176 const text = await fsRead($, file.path);
177 return { path: file.path, text: capText(text, maxChars) };
178 } catch (err) {
179 log(
180 $,
181 `prompt-lift: could not read ${file.path}: ${errorMessage(err)}`,
182 { to: "debug" },
183 );
184 return undefined;
185 }
186 }),
187 );
188 return docs.filter((doc): doc is Document => doc !== undefined);
189}
190
191async function listRootDirs(
192 $: EngineInterface,
193 root: string,
194): Promise<string[]> {
195 try {
196 const entries = await fsList($, root);
197 return entries
198 .filter((entry) => entry.kind === "dir")
199 .map((entry) => entry.name);
200 } catch (err) {
201 log($, `prompt-lift: could not list ${root}: ${errorMessage(err)}`, {
202 to: "debug",
203 });
204 return [];
205 }
206}
207
208async function readSubproject(
209 $: EngineInterface,
210 root: string,
211 draft: string,
212 dirs: readonly string[],
213 patterns: readonly string[],
214 homes: readonly string[],
215): Promise<Document | undefined> {
216 const dir = subprojectDir(draft, dirs);
217 if (dir === undefined) {
218 return undefined;
219 }
220 const path = `${root}/${dir}/CLAUDE.md`;
221 // R17: `instructionExclude` covers this file as it covers the loaded
222 // instruction files, decided before any `$.fs` call, so an excluded file is
223 // neither probed nor read; the block then shows no subproject section, as
224 // for an absent file.
225 if (isExcluded(path, patterns, homes)) {
226 log($, `prompt-lift: subproject ${path} left out by instructionExclude`, {
227 to: "debug",
228 });
229 return undefined;
230 }
231 try {
232 if (!(await fsHas($, path))) {
233 return undefined;
234 }
235 const text = await fsRead($, path);
236 return { path, text: capText(text, SUBPROJECT_MAX_CHARS) };
237 } catch (err) {
238 log($, `prompt-lift: subproject lookup failed: ${errorMessage(err)}`, {
239 to: "debug",
240 });
241 return undefined;
242 }
243}
244
245export async function gatherContext(
246 $: EngineInterface,
247 cfg: Config,
248 draft: string,
249 rows: readonly SessionMessage[],
250): Promise<Gathered> {
251 const usage = await usageOf($, { breakdown: "summary" });
252 const breakdown = usage.context.breakdown;
253 const homes = await homeSpellings($);
254 const files = selectInstructionFiles(
255 breakdown?.memoryFiles ?? [],
256 cfg,
257 homes,
258 );
259 const documents = await readDocuments($, files, cfg.instructionFileMaxChars);
260 const skillNames = selectSkillNames(
261 breakdown?.skills?.skillFrontmatter ?? [],
262 cfg,
263 );
264 const commands: CommandEntry[] = (await cmds($)).map((c) => ({
265 name: c.name,
266 description: c.description,
267 source: c.source,
268 }));
269 const root = await rootDir($);
270 const rootDirs = await listRootDirs($, root);
271 const subproject = await readSubproject(
272 $,
273 root,
274 draft,
275 rootDirs,
276 cfg.instructionExclude,
277 homes,
278 );
279 const count = await readTailCount($, cfg);
280 const conversation = count > 0 ? conversationTail(rows, count) : undefined;
281 return {
282 root,
283 documents,
284 documentsText: documents.map((doc) => doc.text).join("\n"),
285 skillNames,
286 commands,
287 descriptions: new Map(commands.map((c) => [c.name, c.description])),
288 rootDirs,
289 subproject,
290 conversation,
291 };
292}
293
294/**
295 * Spec §9.11: every file the result names that the draft (or the person's own
296 * `extraRules`) did not must exist.
297 * Only file-looking tokens are asked of the filesystem (`isFileLike`), so
298 * prose such as `CI/CD` or `and/or` is not refused as a missing file.
299 */
300export async function namedPathsExist(
301 $: EngineInterface,
302 a: {
303 root: string;
304 draft: string;
305 userRules: string;
306 result: string;
307 rootDirs: readonly string[];
308 },
309): Promise<boolean> {
310 const dirs = new Set(a.rootDirs);
311 // A path in the person's own `extraRules` is theirs as much as the draft's
312 const inDraft = new Set(pathTokens(`${a.draft}\n${a.userRules}`));
313 const fresh = [...new Set(pathTokens(a.result))].filter(
314 (token) => !inDraft.has(token) && isFileLike(token, dirs),
315 );
316 const found = await Promise.all(
317 fresh.map((token) =>
318 fsHas($, token.startsWith("/") ? token : `${a.root}/${token}`),
319 ),
320 );
321 return found.every((exists) => exists);
322}
323hooks/caps.ts 182 lines1import { atom, read } from "claude-code";
2import type { EngineInterface } from "claude-code";
3import type { Config } from "./config";
4import { spentUsd, type Spent } from "./stats";
5
6// Spend caps (R9): a press is refused before any model call once the mod's
7// own ledger — never the account's rate limits — shows a window at its cap,
8// and the rows show what is left under the tighter one. The arithmetic and
9// the texts are pure over the numbers; `readCapStatus` is the one engine
10// read. The override atom is declared here as well as in commands.ts, which
11// writes it: `read` validates against an atom made in the calling file.
12
13const capOverride = atom(
14 { plugin: "prompt-lift", key: "capOverride" } as const,
15 null,
16);
17
18/** One window's cap and what is left under it, never below zero. */
19export type CapWindow = { cap: number; left: number };
20
21/**
22 * The caps as they stand: each active window (cap > 0) with what is left,
23 * and which a press would hit now, the session's named first.
24 */
25export type CapStatus = {
26 session?: CapWindow;
27 day?: CapWindow;
28 reached?: "session" | "day";
29};
30
31export type CapArg =
32 | { kind: "show" }
33 | { kind: "set"; usd: number }
34 | { kind: "invalid" }
35 | { kind: "prose" };
36
37export const CAP_USAGE =
38 "prompt-lift: cap takes dollars, e.g. /prompt-lift cap 2.50";
39
40/**
41 * The session cap in force: the `cap` command's override when it set one
42 * (0 = off for this session), else the option. The day cap has no override.
43 */
44export function sessionCap(cfg: Config, override: number | null): number {
45 return override === null ? cfg.sessionCapUsd : Math.max(0, override);
46}
47
48function windowOf(cap: number, spent: number): CapWindow | undefined {
49 return cap > 0 ? { cap, left: Math.max(0, cap - spent) } : undefined;
50}
51
52export function capStatus(
53 spent: Spent,
54 override: number | null,
55 cfg: Config,
56): CapStatus {
57 const session = windowOf(sessionCap(cfg, override), spent.session);
58 const day = windowOf(cfg.dayCapUsd, spent.day);
59 // Reached once the recorded spend meets the cap, not before
60 const reached =
61 session !== undefined && spent.session >= session.cap
62 ? "session"
63 : day !== undefined && spent.day >= day.cap
64 ? "day"
65 : undefined;
66 return {
67 ...(session === undefined ? {} : { session }),
68 ...(day === undefined ? {} : { day }),
69 ...(reached === undefined ? {} : { reached }),
70 };
71}
72
73/** The caps now, from the ledger and the override. */
74export async function readCapStatus(
75 $: EngineInterface,
76 cfg: Config,
77): Promise<CapStatus> {
78 const override = await read($, capOverride);
79 const session = sessionCap(cfg, override);
80 const day = cfg.dayCapUsd;
81 // With both caps off there is nothing to compare, so the ledger is not read.
82 if (session === 0 && day === 0) {
83 return {};
84 }
85 return capStatus(await spentUsd($), override, cfg);
86}
87
88/** Dollars as the caps show them: to the cent, always (`$0.10`). */
89export function capDollars(usd: number): string {
90 return `$${usd.toFixed(2)}`;
91}
92
93/** The refusal for the window a press would hit; undefined when none would. */
94export function capReachedToast(status: CapStatus): string | undefined {
95 if (status.reached === "session" && status.session !== undefined) {
96 return `prompt-lift: session cap ${capDollars(status.session.cap)} reached · /prompt-lift cap off`;
97 }
98 if (status.reached === "day" && status.day !== undefined) {
99 return `prompt-lift: day cap ${capDollars(status.day.cap)} reached`;
100 }
101 return undefined;
102}
103
104/**
105 * R19 meets R9: an alias rung is unpriced off the Anthropic API, so a press
106 * on it never counts toward a cap. The texts end with this while a cap is
107 * active over such a chain, so the cap is not read as protection it is not.
108 */
109const ALIAS_NOTE = "alias rungs not counted";
110
111/** `text` with the R19 note when the chain's alias rungs are `uncounted`. */
112function noted(text: string, uncounted: boolean): string {
113 return uncounted ? `${text} · ${ALIAS_NOTE}` : text;
114}
115
116/**
117 * The rows' remaining budget: the session's, or the day's marked `(day)`
118 * when that window is the tighter (or the only) one; nothing with no cap.
119 * `uncounted` (R19) appends the note to an active window's text.
120 */
121export function leftText(
122 status: CapStatus,
123 uncounted = false,
124): string | undefined {
125 const { session, day } = status;
126 if (day !== undefined && (session === undefined || day.left < session.left)) {
127 return noted(`${capDollars(day.left)} left (day)`, uncounted);
128 }
129 if (session === undefined) {
130 return undefined;
131 }
132 return noted(`${capDollars(session.left)} left`, uncounted);
133}
134
135function windowText(name: string, w: CapWindow): string {
136 return `${name} ${capDollars(w.cap)} (${capDollars(w.left)} left)`;
137}
138
139/**
140 * The `caps:` line `status` and `cap` print: `off`, or each active window,
141 * with the R19 note when the chain's alias rungs are `uncounted`.
142 */
143export function capsLine(status: CapStatus, uncounted = false): string {
144 const windows = [
145 ...(status.session === undefined
146 ? []
147 : [windowText("session", status.session)]),
148 ...(status.day === undefined ? [] : [windowText("day", status.day)]),
149 ];
150 if (windows.length === 0) {
151 return "caps: off";
152 }
153 return noted(`caps: ${windows.join(" · ")}`, uncounted);
154}
155
156/**
157 * `cap`, `cap <usd>` and `cap off` by the `model` rule: the word alone shows
158 * the caps, exactly one more token sets the session's (`off` and `0` both
159 * turn it off), anything longer is a draft.
160 */
161export function parseCapArg(args: string): CapArg {
162 const tokens = args.trim().split(/\s+/);
163 if (tokens[0]?.toLowerCase() !== "cap" || tokens.length > 2) {
164 return { kind: "prose" };
165 }
166 const value = tokens[1];
167 if (value === undefined) {
168 return { kind: "show" };
169 }
170 if (value.toLowerCase() === "off") {
171 return { kind: "set", usd: 0 };
172 }
173 // Plain decimals only: hex, scientific notation and a negative zero are invalid.
174 if (!/^\d+(\.\d+)?$/.test(value)) {
175 return { kind: "invalid" };
176 }
177 const usd = Number(value);
178 return Number.isFinite(usd) && usd >= 0
179 ? { kind: "set", usd }
180 : { kind: "invalid" };
181}
182