-
Notifications
You must be signed in to change notification settings - Fork 0
feat(later): save prompts and run them later in the session #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
359c9f2
feat(later): save prompts and run them later in the session
derogab fe2e0e5
fix(later): copy persisted prompts on state reconstruction
derogab e6e2a08
fix(later): remove saved prompt only after delivery succeeds
derogab 44da703
docs(later): note prompts are lost if a new session exits before the …
derogab 9c4898f
fix(later): preserve prompts until delivery succeeds
derogab 711282a
fix(later): handle transformed prompt delivery
derogab a649b14
feat(later): add confirm or remove choice for saved prompts
derogab fb45883
fix(later): keep queued follow-ups until their turn starts
derogab c8847e7
fix(later): distinguish queued follow-up deliveries
derogab b80fc8c
fix(later): prevent lost saved prompts
derogab 128ca01
fix(later): keep delivery tracking consistent
derogab 69f39fa
fix(later): preserve prompt delivery semantics
derogab f255157
fix(later): keep acknowledgement working after duplicate removal
derogab 62bd4b3
fix(later): stop misattributing foreign turns to idle deliveries
derogab 446c151
fix(later): remap duplicate deliveries to distinct restored prompts
derogab 0b21cad
fix(later): preserve duplicate occurrence identity across remap
derogab File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| name: Publish @derogab/pi-later package | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - master | ||
| paths: | ||
| - plugins/later/package.json | ||
| workflow_dispatch: | ||
|
|
||
| jobs: | ||
| publish: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| id-token: write | ||
| defaults: | ||
| run: | ||
| working-directory: plugins/later | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v7 | ||
| - uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: 24 | ||
| registry-url: https://registry.npmjs.org | ||
| package-manager-cache: false | ||
| - run: npm test | ||
| - run: npm pack --dry-run | ||
| - run: npm publish --provenance | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # later | ||
|
|
||
| A Pi plugin that adds `/later` to save prompts and run them later in the session. | ||
|
|
||
| ## Install | ||
|
|
||
| ### Pi | ||
|
|
||
| Install the plugin from npm: | ||
|
|
||
| ```bash | ||
| pi install npm:@derogab/pi-later | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| - `/later <prompt>`: save a prompt for later. | ||
| - `/later`: open the list of saved prompts. Pick one, then choose what to do with it: | ||
| - **Confirm**: send the prompt to the session and run it; the prompt is removed from the list once its turn actually starts, so a queued prompt that never runs is not lost. | ||
| - **Remove**: delete the prompt from the list without running it. | ||
|
|
||
| Pressing <kbd>Esc</kbd> on either dialog leaves the list unchanged. | ||
|
|
||
| After the session's first assistant response, saved prompts survive `/reload` and session resume. | ||
|
|
||
| ## Limitations | ||
|
|
||
| - Pi creates a new session file only after its first assistant response. Prompts saved before that point remain in memory and are lost if Pi exits or replaces the session with `/new`, `/resume`, or `/fork` first. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| import { randomUUID } from "node:crypto"; | ||
| import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; | ||
|
|
||
| const ENTRY_TYPE = "later"; | ||
| const MAX_LABEL_LENGTH = 80; | ||
| const ACTION_CONFIRM = "Confirm"; | ||
| const ACTION_REMOVE = "Remove"; | ||
| const MARKER_BOUNDARY = "\u2063"; | ||
| const VARIATION_SELECTOR_START = 0xfe00; | ||
| const DELIVERY_MARKER_PATTERN = /\u2063[\uFE00-\uFE0F]{32}\u2063/g; | ||
|
|
||
| interface SavedPrompt { | ||
| text: string; | ||
| } | ||
|
|
||
| interface PendingDelivery { | ||
| prompt: SavedPrompt; | ||
| idle: boolean; | ||
| marker: string; | ||
| } | ||
|
|
||
| export default function (pi: ExtensionAPI) { | ||
| // Saved prompts, oldest first. Reconstructed from session entries. | ||
| let prompts: SavedPrompt[] = []; | ||
| let pendingDeliveries: PendingDelivery[] = []; | ||
| // Tie idle sends to their input lifecycle, including overlapping sends. | ||
| let awaitingIdleInput: PendingDelivery | undefined; | ||
| let latestIdleInput: PendingDelivery | undefined; | ||
| let pendingIdleInputs: PendingDelivery[] = []; | ||
|
|
||
| const readPrompts = (ctx: ExtensionContext) => { | ||
| let restored: SavedPrompt[] = []; | ||
| for (const entry of ctx.sessionManager.getBranch()) { | ||
| if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { | ||
| const data = entry.data as { prompts?: string[] } | undefined; | ||
| // Use distinct objects so duplicate prompt text still has stable selection identity. | ||
| restored = (data?.prompts ?? []).map((text) => ({ text })); | ||
| } | ||
| } | ||
| return restored; | ||
| }; | ||
|
|
||
| const resetState = (ctx: ExtensionContext) => { | ||
| prompts = readPrompts(ctx); | ||
| pendingDeliveries = []; | ||
| awaitingIdleInput = undefined; | ||
| latestIdleInput = undefined; | ||
| pendingIdleInputs = []; | ||
| }; | ||
|
|
||
| const persist = () => { | ||
| pi.appendEntry(ENTRY_TYPE, { prompts: prompts.map((prompt) => prompt.text) }); | ||
| }; | ||
|
|
||
| const remove = (prompt: SavedPrompt) => { | ||
| const index = prompts.indexOf(prompt); | ||
| if (index === -1) return; | ||
|
|
||
| prompts.splice(index, 1); | ||
| persist(); | ||
| }; | ||
|
|
||
| const acknowledge = (delivery: PendingDelivery) => { | ||
| const index = pendingDeliveries.indexOf(delivery); | ||
| if (index === -1) return; | ||
|
|
||
| pendingDeliveries.splice(index, 1); | ||
| pendingIdleInputs = pendingIdleInputs.filter((pending) => pending !== delivery); | ||
| if (latestIdleInput === delivery) latestIdleInput = undefined; | ||
| remove(delivery.prompt); | ||
| }; | ||
|
|
||
| const createMarker = () => { | ||
| const selectors = [...randomUUID().replaceAll("-", "")] | ||
| .map((digit) => String.fromCharCode(VARIATION_SELECTOR_START + Number.parseInt(digit, 16))) | ||
| .join(""); | ||
| return `${MARKER_BOUNDARY}${selectors}${MARKER_BOUNDARY}`; | ||
| }; | ||
|
|
||
| const createDelivery = (prompt: SavedPrompt, idle: boolean): PendingDelivery => { | ||
| const delivery = { prompt, idle, marker: createMarker() }; | ||
| pendingDeliveries.push(delivery); | ||
| return delivery; | ||
| }; | ||
|
|
||
| const queueFollowUp = (prompt: SavedPrompt) => { | ||
| const delivery = createDelivery(prompt, false); | ||
| pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); | ||
| }; | ||
|
|
||
| const clearDequeuedFollowUps = (ctx: ExtensionContext) => { | ||
| if (ctx.hasPendingMessages()) return; | ||
| pendingDeliveries = pendingDeliveries.filter((delivery) => delivery.idle); | ||
| }; | ||
|
|
||
| const reconstructTreeState = (ctx: ExtensionContext) => { | ||
| const restored = readPrompts(ctx); | ||
| // Each tracked prompt claims a distinct restored copy so every pending | ||
| // delivery's acknowledgement still removes its own entry. Deliveries beyond | ||
| // the restored count keep a stale object and acknowledge as no-ops, and | ||
| // deliveries sharing one prompt object keep sharing it (one removal total). | ||
| const available = [...restored]; | ||
| const remapped = new Map<SavedPrompt, SavedPrompt>(); | ||
| for (const delivery of pendingDeliveries) { | ||
| if (!prompts.includes(delivery.prompt)) continue; | ||
|
|
||
| let replacement = remapped.get(delivery.prompt); | ||
| if (replacement === undefined) { | ||
| // Prefer the restored copy at the same same-text occurrence, so a | ||
| // delivery for a later duplicate can still share one removal with a | ||
| // re-selection of that same entry after reconstruction. | ||
| const sameText = (prompt: SavedPrompt) => prompt.text === delivery.prompt.text; | ||
| const ordinal = prompts.slice(0, prompts.indexOf(delivery.prompt)).filter(sameText).length; | ||
| const occurrence = restored.filter(sameText)[ordinal]; | ||
| let index = occurrence === undefined ? -1 : available.indexOf(occurrence); | ||
| if (index === -1) index = available.findIndex(sameText); | ||
| if (index === -1) continue; | ||
| replacement = available[index]; | ||
| available.splice(index, 1); | ||
| remapped.set(delivery.prompt, replacement); | ||
| } | ||
| delivery.prompt = replacement; | ||
| } | ||
| prompts = restored; | ||
| clearDequeuedFollowUps(ctx); | ||
| }; | ||
|
|
||
| pi.on("session_start", async (_event, ctx) => resetState(ctx)); | ||
| pi.on("session_tree", async (_event, ctx) => reconstructTreeState(ctx)); | ||
| pi.on("agent_settled", async (_event, ctx) => clearDequeuedFollowUps(ctx)); | ||
|
|
||
| const truncate = (text: string) => { | ||
| const singleLine = text.replace(/\s+/g, " ").trim(); | ||
| return singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; | ||
| }; | ||
|
|
||
| const toLabel = (prompt: SavedPrompt, index: number) => `${index + 1}. ${truncate(prompt.text)}`; | ||
|
|
||
| pi.on("input", async (event, ctx) => { | ||
| const delivery = awaitingIdleInput; | ||
| awaitingIdleInput = undefined; | ||
| const matchesIdleDelivery = | ||
| event.source === "extension" && | ||
| ctx.isIdle() && | ||
| delivery !== undefined && | ||
| event.text.includes(delivery.marker); | ||
| if (matchesIdleDelivery) { | ||
| pendingIdleInputs.push(delivery); | ||
| latestIdleInput = delivery; | ||
| return; | ||
| } | ||
|
|
||
| latestIdleInput = undefined; | ||
| const matchesFollowUp = pendingDeliveries.some( | ||
| (pending) => !pending.idle && event.text.includes(pending.marker), | ||
| ); | ||
| if (!matchesFollowUp) clearDequeuedFollowUps(ctx); | ||
| }); | ||
|
|
||
| pi.on("before_agent_start", async (event, ctx) => { | ||
| let delivery = pendingIdleInputs.find((pending) => event.prompt.includes(pending.marker)); | ||
| // If another input handler replaced the text, lifecycle order is safe only | ||
| // while this is the sole idle input waiting to start and the turn is not | ||
| // another tracked delivery's, e.g. an earlier-queued follow-up dequeued first. | ||
| if ( | ||
| delivery === undefined && | ||
| pendingIdleInputs.length === 1 && | ||
| latestIdleInput === pendingIdleInputs[0] && | ||
| !pendingDeliveries.some((pending) => event.prompt.includes(pending.marker)) | ||
| ) { | ||
| delivery = latestIdleInput; | ||
| } | ||
| if (delivery !== undefined) acknowledge(delivery); | ||
| else latestIdleInput = undefined; | ||
| clearDequeuedFollowUps(ctx); | ||
| }); | ||
|
|
||
| pi.on("message_start", async (event) => { | ||
| const message = event.message; | ||
| if (message.role !== "user" || !("content" in message) || !Array.isArray(message.content)) return; | ||
| const content = message.content; | ||
|
|
||
| const deliveries = pendingDeliveries.filter((delivery) => | ||
| content.some((part) => part.type === "text" && part.text.includes(delivery.marker)), | ||
| ); | ||
|
|
||
| // Strip even an orphaned marker so internal tracking never reaches session | ||
| // history or the model after an extension reload or state reconstruction. | ||
| message.content = content.map((part) => | ||
| part.type === "text" ? { ...part, text: part.text.replace(DELIVERY_MARKER_PATTERN, "") } : part, | ||
| ); | ||
| for (const delivery of deliveries) acknowledge(delivery); | ||
| }); | ||
|
|
||
| pi.registerCommand("later", { | ||
| description: "Save a prompt for later, or run a saved prompt", | ||
| handler: async (args, ctx) => { | ||
| clearDequeuedFollowUps(ctx); | ||
| const text = args.trim(); | ||
|
|
||
| // /later <prompt>: save it for later | ||
| if (text) { | ||
| prompts.push({ text }); | ||
| persist(); | ||
| ctx.ui.notify(`Saved for later (${prompts.length} pending)`, "info"); | ||
| return; | ||
| } | ||
|
|
||
| // /later: pick a saved prompt to run | ||
| if (prompts.length === 0) { | ||
| ctx.ui.notify("Nothing saved for later. Use /later <prompt> to save a prompt.", "info"); | ||
| return; | ||
| } | ||
|
|
||
| if (!ctx.hasUI) { | ||
| ctx.ui.notify("/later requires interactive mode to pick a saved prompt", "error"); | ||
| return; | ||
| } | ||
|
|
||
| const labels = prompts.map(toLabel); | ||
| const choice = await ctx.ui.select("Saved prompts", labels); | ||
| if (choice === undefined) return; | ||
|
|
||
| const index = labels.indexOf(choice); | ||
| const prompt = prompts[index]; | ||
| if (prompt === undefined) return; | ||
|
|
||
| const action = await ctx.ui.select(`Selected: ${truncate(prompt.text)}`, [ | ||
| ACTION_CONFIRM, | ||
| ACTION_REMOVE, | ||
| ]); | ||
|
|
||
| if (action === ACTION_REMOVE) { | ||
| remove(prompt); | ||
| ctx.ui.notify(`Removed saved prompt (${prompts.length} pending)`, "info"); | ||
| return; | ||
| } | ||
|
|
||
| if (action !== ACTION_CONFIRM) return; | ||
|
|
||
| if (ctx.isIdle()) { | ||
| if (ctx.model === undefined) { | ||
| ctx.ui.notify("Could not run saved prompt, kept in list: no model selected", "error"); | ||
| return; | ||
| } | ||
|
|
||
| const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); | ||
| if (!auth.ok) { | ||
| ctx.ui.notify(`Could not run saved prompt, kept in list: ${auth.error}`, "error"); | ||
| return; | ||
| } | ||
|
|
||
| // Authentication can yield while another turn begins. Only use an idle | ||
| // send if the agent is still idle; otherwise use a race-safe follow-up. | ||
| if (ctx.isIdle()) { | ||
| // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start | ||
| // acknowledges an immediate turn, while followUp keeps the send safe if | ||
| // streaming starts during asynchronous input handlers. | ||
| const delivery = createDelivery(prompt, true); | ||
| awaitingIdleInput = delivery; | ||
| pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // Keep the prompt in the list until its marked follow-up user message | ||
| // actually starts, so an undelivered follow-up is not lost. | ||
| queueFollowUp(prompt); | ||
| ctx.ui.notify("Queued as follow-up", "info"); | ||
| }, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| { | ||
| "name": "@derogab/pi-later", | ||
| "version": "0.1.0", | ||
| "description": "Save prompts and run them later in the session", | ||
| "type": "module", | ||
| "scripts": { | ||
| "test": "node --experimental-strip-types --test test/*.test.ts" | ||
| }, | ||
| "files": [ | ||
| "extensions", | ||
| "README.md" | ||
| ], | ||
| "keywords": [ | ||
| "pi-package" | ||
| ], | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/derogab/agent-kit.git", | ||
| "directory": "plugins/later" | ||
| }, | ||
| "publishConfig": { | ||
| "access": "public" | ||
| }, | ||
| "pi": { | ||
| "extensions": [ | ||
| "./extensions/later.ts" | ||
| ] | ||
| }, | ||
| "peerDependencies": { | ||
| "@earendil-works/pi-coding-agent": "*" | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.