SLOPSHOPPER

prompt-lift

Enhance your prompts using project instructions and skills

newbandcommandtoastpromptmodel
v0.6.1MITupdated 2026-10-25sairam0424/claude-prompt-lift
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-lift
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prompt-lift ⎿ prompt-lift: prompt-lift: type /prompt-lift <your prompt>, press ctrl+x b, or click Enhance ↥ Ready [ Enhance (e) ] ctrl+x b · or ctrl+x tab, e · prompt-lift: fable ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
↥ Ready [ Enhance (e) ] ctrl+x b · or ctrl+x tab, e · prompt-lift: fable ⟨Claude Code's own drawing⟩
README

prompt-lift

reach validates

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.

prompt-lift demo: Enhance rewrites the draft, Undo and Redo swap it back, /prompt-lift status shows the chain

<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.

Install

/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.

Using it

Three ways to press Enhance:

  • ctrl+x b from the prompt
  • ctrl+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 band

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):

StateRow
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.

Commands

  • /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 refusal

A 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.

Keyboard

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.

Conversation tail

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.

Data handling

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:

  • Your draft
  • The instruction files the session loaded: Project, Local, Managed, and ~/.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.
  • The skill names Claude lists (minus skillExclude)
  • Up to eight matched skill descriptions
  • A named subproject's CLAUDE.md when the draft mentions a top-level directory of the project that has one, unless instructionExclude matches it
  • The recent conversation only when includeConversation is on

The 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}.

Configuration

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-*"
      }
    }
  }
}
KeyTypeDefaultDescription
modelsstringfable,sonnet,haikuComma-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
effortModelsstringfable,sonnetModels that accept the effort parameter; they are sent effort: low
maxTokensnumber2000Maximum tokens per model completion
timeoutMsFirstnumber30000Timeout for the first model attempt
timeoutMsNextnumber15000Timeout for fallback model attempts
minWordsnumber3Minimum word count to trigger enhancement
maxDraftCharsnumber4000Maximum characters in the draft prompt
keptShareFloornumber0.5Minimum fraction of the draft's words the rewrite must keep
maxGrowthCharsnumber1200Maximum character growth allowed
relevantSkillsnumber8Number of relevant skill descriptions to include
instructionFileMaxCharsnumber40000Maximum characters read from each instruction file
enhanceActionstringapp:cycleDiffBaseEngine keybinding action whose chord presses Enhance from the prompt (empty disables)
filePrefixstringplainHow file paths appear in the rewrite (plain or at)
skillPlacementstringmentionHow a matched skill is named (mention mid-sentence, or run as a leading /name)
includeConversationbooleanfalseInclude recent conversation history
conversationMessagesnumber6Number of recent messages to include
skillPrefixAllowstring""Skills allowed as a leading /name under run (empty for none)
instructionExcludestring~/.claude/CLAUDE.md,~/CLAUDE.mdInstruction 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)
skillExcludestring""Skill name patterns left out of the listing (* and ? wildcards); empty sends every skill Claude lists
extraRulesstring""Project preferences appended to the rewrite rules as rule 11 (max 1,000 characters; tag-like markup is neutralised). See Extra rules
pricesstringA 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
sessionCapUsdnumber0Refuse a press once this session's priced presses reach these dollars; 0 is no cap. `/prompt-lift cap <usdoff>` overrides it for the session. See Spend caps under Cost
dayCapUsdnumber0Refuse 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.

Bedrock users

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.

Extra rules

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."

Cost

Prices are USD per 1M tokens, Bedrock us.* profiles, verified 2026-10-16:

ModelInputOutputCache WriteCache Read
Fable 5.111.0055.0013.750.275
Sonnet 5.52.2011.002.750.11
Haiku 5.50.110.550.13750.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):

  • Fable: ≈ $0.13 cold / ≈ $0.04 warm
  • Sonnet: ≈ $0.027 / ≈ $0.009
  • Haiku: ≈ $0.0015 / ≈ $0.0006

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.

Spend caps

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.

Why was my draft refused?

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.

Updating

  • Installed from GitHub: `claude plugin update prompt-lift@claude-
Source 30 files
hooks/register.tsx 77 lines
1import 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};
77
hooks/config.ts 397 lines
1import 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}
397
hooks/actions.ts 279 lines
1import { 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}
279
hooks/band.tsx 264 lines
1import { 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}
264
hooks/commands.ts 346 lines
1import { 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}
346
hooks/enhance.ts 398 lines
1import { 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}
398
hooks/verdict.ts 104 lines
1import { 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}
104
hooks/api.ts 145 lines
1import 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}
145
hooks/arm.ts 21 lines
1// 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}
21
hooks/chain.ts 278 lines
1import 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}
278
hooks/gather.ts 323 lines
1import 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}
323
hooks/caps.ts 182 lines
1import { 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