Remove answer package

Amolith created

Delete the local answer extension because it will be replaced by another
implementation. Drop its README entry, release selector, workspace
lockfile entries, and now-unused Apache license text so the repo only
tracks the remaining packages.

Change summary

LICENSES/Apache-2.0.txt               |  73 ------
README.md                             |  20 -
bun.lock                              |  14 -
packages/answer/package.json          |  25 --
packages/answer/package.json.license  |   3 
packages/answer/src/QnAComponent.ts   | 352 -----------------------------
packages/answer/src/extract.ts        |  68 -----
packages/answer/src/index.ts          | 224 ------------------
packages/answer/src/prompt.ts         | 155 ------------
packages/answer/tsconfig.json         |   4 
packages/answer/tsconfig.json.license |   3 
scripts/resolve-release-packages.bash |   3 
12 files changed, 1 insertion(+), 943 deletions(-)

Detailed changes

LICENSES/Apache-2.0.txt đź”—

@@ -1,73 +0,0 @@
-Apache License
-Version 2.0, January 2004
-http://www.apache.org/licenses/
-
-TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
-
-"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
-
-"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
-
-"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
-
-"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
-
-"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
-
-"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
-
-"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
-
-"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
-
-"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
-
-     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
-
-     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
-
-     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
-
-     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
-
-     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
-
-8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
-APPENDIX: How to apply the Apache License to your work.
-
-To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
-
-Copyright [yyyy] [name of copyright owner]
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.

README.md đź”—

@@ -32,26 +32,6 @@ pi install npm:@amolith/pi-handoff
 > tool instead of expecting it to produce well-formed JSON, added an auto-submit
 > countdown, and make the extraction model configurable via `PI_HANDOFF_MODEL`.
 
-### `@amolith/pi-answer`
-
-[![npm](https://img.shields.io/npm/v/@amolith/pi-answer)](https://www.npmjs.com/package/@amolith/pi-answer)
-
-See that Pi spit a bunch of Markdown at you with some embedded questions, use
-`/answer` to have a lightweight subagent extract those questions so you can
-answer them in a nice form instead of mentally parsing all that LLM-speak. For
-best results, give a good description of `/answer what you want to turned into questions for answering`.
-
-```sh
-pi install npm:@amolith/pi-answer
-```
-
-> Based on [answer.ts](https://github.com/mitsuhiko/agent-stuff/blob/main/pi-extensions/answer.ts)
-> by Armin Ronacher, licensed Apache-2.0 accordingly. Reworked so the subagent
-> calls a tool instead of expecting it to produce well-formed JSON, rewrote the
-> system prompt with few-shot examples, added a notes editor (Alt+N) and
-> fallback extraction with the current model when the preferred model is
-> unavailable.
-
 ### `@amolith/pi-personas`
 
 [![npm](https://img.shields.io/npm/v/@amolith/pi-personas)](https://www.npmjs.com/package/@amolith/pi-personas)

bun.lock đź”—

@@ -11,18 +11,6 @@
         "typescript": "^6.0.3",
       },
     },
-    "packages/answer": {
-      "name": "@amolith/pi-answer",
-      "version": "0.1.0",
-      "dependencies": {
-        "typebox": "^1.1.38",
-      },
-      "peerDependencies": {
-        "@earendil-works/pi-ai": ">=0.74.0",
-        "@earendil-works/pi-coding-agent": ">=0.74.0",
-        "@earendil-works/pi-tui": ">=0.74.0",
-      },
-    },
     "packages/handoff": {
       "name": "@amolith/pi-handoff",
       "version": "0.1.0",
@@ -44,8 +32,6 @@
     },
   },
   "packages": {
-    "@amolith/pi-answer": ["@amolith/pi-answer@workspace:packages/answer"],
-
     "@amolith/pi-handoff": ["@amolith/pi-handoff@workspace:packages/handoff"],
 
     "@amolith/pi-personas": ["@amolith/pi-personas@workspace:packages/personas"],

packages/answer/package.json đź”—

@@ -1,25 +0,0 @@
-{
-	"name": "@amolith/pi-answer",
-	"version": "0.1.0",
-	"peerDependencies": {
-		"@earendil-works/pi-ai": ">=0.74.0",
-		"@earendil-works/pi-coding-agent": ">=0.74.0",
-		"@earendil-works/pi-tui": ">=0.74.0"
-	},
-	"description": "Interactive Q&A extraction from assistant responses for Pi",
-	"keywords": [
-		"pi-package"
-	],
-	"pi": {
-		"extensions": [
-			"./src/index.ts"
-		]
-	},
-	"scripts": {
-		"typecheck": "tsc --noEmit"
-	},
-	"type": "module",
-	"dependencies": {
-		"typebox": "^1.1.38"
-	}
-}

packages/answer/src/QnAComponent.ts đź”—

@@ -1,352 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-// SPDX-FileCopyrightText: Armin Ronacher <armin.ronacher@active-4.com>
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {
-	type Component,
-	Editor,
-	type EditorTheme,
-	Key,
-	matchesKey,
-	truncateToWidth,
-	type TUI,
-	visibleWidth,
-	wrapTextWithAnsi,
-} from "@earendil-works/pi-tui";
-import { escapeXml } from "./extract.js";
-import type { ExtractedQuestion } from "./prompt.js";
-
-/**
- * Interactive Q&A component for answering extracted questions
- */
-export class QnAComponent implements Component {
-	private questions: ExtractedQuestion[];
-	private answers: string[];
-	private currentIndex: number = 0;
-	private editor: Editor;
-	private tui: TUI;
-	private onDone: (result: string | null) => void;
-	private showingConfirmation: boolean = false;
-	private notesMode: boolean = false;
-	private notesText: string = "";
-	private notesEditor: Editor;
-
-	// Cache
-	private cachedWidth?: number;
-	private cachedLines?: string[];
-
-	// Colors - using proper reset sequences
-	private dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
-	private bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
-	private cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
-	private green = (s: string) => `\x1b[32m${s}\x1b[0m`;
-	private yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
-	private gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
-
-	constructor(questions: ExtractedQuestion[], tui: TUI, onDone: (result: string | null) => void) {
-		this.questions = questions;
-		this.answers = questions.map(() => "");
-		this.tui = tui;
-		this.onDone = onDone;
-
-		// Create a minimal theme for the editor
-		const editorTheme: EditorTheme = {
-			borderColor: this.dim,
-			selectList: {
-				selectedPrefix: this.cyan,
-				selectedText: (s: string) => `\x1b[44m${s}\x1b[0m`,
-				description: this.gray,
-				scrollInfo: this.dim,
-				noMatch: this.dim,
-			},
-		};
-
-		this.editor = new Editor(tui, editorTheme);
-		// Disable the editor's built-in submit (which clears the editor)
-		// We'll handle Enter ourselves to preserve the text
-		this.editor.disableSubmit = true;
-		this.editor.onChange = () => {
-			this.invalidate();
-			this.tui.requestRender();
-		};
-
-		this.notesEditor = new Editor(tui, editorTheme);
-		this.notesEditor.onSubmit = (value) => {
-			this.notesText = value.trim();
-			this.notesMode = false;
-			this.invalidate();
-			this.tui.requestRender();
-		};
-	}
-
-	private saveCurrentAnswer(): void {
-		this.answers[this.currentIndex] = this.editor.getText();
-	}
-
-	private navigateTo(index: number): void {
-		if (index < 0 || index >= this.questions.length) return;
-		this.saveCurrentAnswer();
-		this.currentIndex = index;
-		this.editor.setText(this.answers[index] || "");
-		this.invalidate();
-	}
-
-	private submit(): void {
-		this.saveCurrentAnswer();
-
-		// Build the response text
-		const parts: string[] = [];
-		parts.push("<qna>");
-		for (let i = 0; i < this.questions.length; i++) {
-			const q = this.questions[i];
-			const a = this.answers[i]?.trim() || "(no answer)";
-			parts.push(`<q n="${i}">${escapeXml(q.question)}</q>`);
-			parts.push(`<a n="${i}">${escapeXml(a)}</a>`);
-		}
-		parts.push("</qna>");
-		if (this.notesText) {
-			parts.push(`\n<note>${escapeXml(this.notesText)}</note>`);
-		}
-
-		this.onDone(parts.join("\n").trim());
-	}
-
-	private cancel(): void {
-		this.onDone(null);
-	}
-
-	invalidate(): void {
-		this.cachedWidth = undefined;
-		this.cachedLines = undefined;
-	}
-
-	handleInput(data: string): void {
-		// Notes mode: route to notes editor
-		if (this.notesMode) {
-			if (matchesKey(data, Key.escape)) {
-				this.notesMode = false;
-				this.notesEditor.setText(this.notesText);
-				this.invalidate();
-				this.tui.requestRender();
-				return;
-			}
-			this.notesEditor.handleInput(data);
-			this.invalidate();
-			this.tui.requestRender();
-			return;
-		}
-
-		// Handle confirmation dialog
-		if (this.showingConfirmation) {
-			if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
-				this.submit();
-				return;
-			}
-			if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "n") {
-				this.showingConfirmation = false;
-				this.invalidate();
-				this.tui.requestRender();
-				return;
-			}
-			return;
-		}
-
-		// Global navigation and commands
-		if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
-			this.cancel();
-			return;
-		}
-
-		// Activate notes editor (available when not typing an answer)
-		if (matchesKey(data, Key.alt("n"))) {
-			this.notesMode = true;
-			this.notesEditor.setText(this.notesText);
-			this.invalidate();
-			this.tui.requestRender();
-			return;
-		}
-
-		// Tab / Shift+Tab for navigation
-		if (matchesKey(data, Key.tab)) {
-			if (this.currentIndex < this.questions.length - 1) {
-				this.navigateTo(this.currentIndex + 1);
-				this.tui.requestRender();
-			}
-			return;
-		}
-		if (matchesKey(data, Key.shift("tab"))) {
-			if (this.currentIndex > 0) {
-				this.navigateTo(this.currentIndex - 1);
-				this.tui.requestRender();
-			}
-			return;
-		}
-
-		// Arrow up/down for question navigation when editor is empty
-		// (Editor handles its own cursor navigation when there's content)
-		if (matchesKey(data, Key.up) && this.editor.getText() === "") {
-			if (this.currentIndex > 0) {
-				this.navigateTo(this.currentIndex - 1);
-				this.tui.requestRender();
-				return;
-			}
-		}
-		if (matchesKey(data, Key.down) && this.editor.getText() === "") {
-			if (this.currentIndex < this.questions.length - 1) {
-				this.navigateTo(this.currentIndex + 1);
-				this.tui.requestRender();
-				return;
-			}
-		}
-
-		// Handle Enter ourselves (editor's submit is disabled)
-		// Plain Enter moves to next question or shows confirmation on last question
-		// Shift+Enter adds a newline (handled by editor)
-		if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) {
-			this.saveCurrentAnswer();
-			if (this.currentIndex < this.questions.length - 1) {
-				this.navigateTo(this.currentIndex + 1);
-			} else {
-				// On last question - show confirmation
-				this.showingConfirmation = true;
-			}
-			this.invalidate();
-			this.tui.requestRender();
-			return;
-		}
-
-		// Pass to editor
-		this.editor.handleInput(data);
-		this.invalidate();
-		this.tui.requestRender();
-	}
-
-	render(width: number): string[] {
-		if (this.cachedLines && this.cachedWidth === width) {
-			return this.cachedLines;
-		}
-
-		const lines: string[] = [];
-		const boxWidth = Math.min(width - 4, 120); // Allow wider box
-		const contentWidth = boxWidth - 4; // 2 chars padding on each side
-
-		// Helper to create horizontal lines (dim the whole thing at once)
-		const horizontalLine = (count: number) => "─".repeat(count);
-
-		// Helper to create a box line
-		const boxLine = (content: string, leftPad: number = 2): string => {
-			const paddedContent = " ".repeat(leftPad) + content;
-			const contentLen = visibleWidth(paddedContent);
-			const rightPad = Math.max(0, boxWidth - contentLen - 2);
-			return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│");
-		};
-
-		const emptyBoxLine = (): string => {
-			return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│");
-		};
-
-		const padToWidth = (line: string): string => {
-			const len = visibleWidth(line);
-			return line + " ".repeat(Math.max(0, width - len));
-		};
-
-		// Title
-		lines.push(padToWidth(this.dim(`â•­${horizontalLine(boxWidth - 2)}â•®`)));
-		const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`;
-		lines.push(padToWidth(boxLine(title)));
-		lines.push(padToWidth(this.dim(`├${horizontalLine(boxWidth - 2)}┤`)));
-
-		// Progress indicator
-		const progressParts: string[] = [];
-		for (let i = 0; i < this.questions.length; i++) {
-			const answered = (this.answers[i]?.trim() || "").length > 0;
-			const current = i === this.currentIndex;
-			if (current) {
-				progressParts.push(this.cyan("â—Ź"));
-			} else if (answered) {
-				progressParts.push(this.green("â—Ź"));
-			} else {
-				progressParts.push(this.dim("â—‹"));
-			}
-		}
-		lines.push(padToWidth(boxLine(progressParts.join(" "))));
-		lines.push(padToWidth(emptyBoxLine()));
-
-		// Current question
-		const q = this.questions[this.currentIndex];
-		const questionText = `${this.bold("Q:")} ${q.question}`;
-		const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth);
-		for (const line of wrappedQuestion) {
-			lines.push(padToWidth(boxLine(line)));
-		}
-
-		// Context if present
-		if (q.context) {
-			lines.push(padToWidth(emptyBoxLine()));
-			const contextText = this.gray(`> ${q.context}`);
-			const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
-			for (const line of wrappedContext) {
-				lines.push(padToWidth(boxLine(line)));
-			}
-		}
-
-		lines.push(padToWidth(emptyBoxLine()));
-
-		// Render the editor component (multi-line input) with padding
-		// Skip the first and last lines (editor's own border lines)
-		const answerPrefix = this.bold("A: ");
-		const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
-		const editorLines = this.editor.render(editorWidth);
-		for (let i = 1; i < editorLines.length - 1; i++) {
-			if (i === 1) {
-				// First content line gets the "A: " prefix
-				lines.push(padToWidth(boxLine(answerPrefix + editorLines[i])));
-			} else {
-				// Subsequent lines get padding to align with the first line
-				lines.push(padToWidth(boxLine(`   ${editorLines[i]}`)));
-			}
-		}
-
-		// Notes section
-		if (this.notesMode) {
-			lines.push(padToWidth(emptyBoxLine()));
-			const notesLabel = `${this.cyan("✎")} ${this.bold("Note:")} ${this.dim("(Enter to save, Esc to discard)")}`;
-			lines.push(padToWidth(boxLine(notesLabel)));
-			const notesEditorWidth = contentWidth - 4;
-			const notesEditorLines = this.notesEditor.render(notesEditorWidth);
-			for (let i = 1; i < notesEditorLines.length - 1; i++) {
-				lines.push(padToWidth(boxLine(`  ${notesEditorLines[i]}`)));
-			}
-		} else if (this.notesText) {
-			lines.push(padToWidth(emptyBoxLine()));
-			const savedNote = `${this.cyan("✎")} ${this.gray(this.notesText)}`;
-			const wrappedNote = wrapTextWithAnsi(savedNote, contentWidth);
-			for (const line of wrappedNote) {
-				lines.push(padToWidth(boxLine(line)));
-			}
-			lines.push(padToWidth(boxLine(this.dim("Alt+N to edit note"))));
-		} else {
-			lines.push(padToWidth(emptyBoxLine()));
-			lines.push(padToWidth(boxLine(this.dim("Alt+N to add a note"))));
-		}
-
-		lines.push(padToWidth(emptyBoxLine()));
-
-		// Confirmation dialog or footer with controls
-		if (this.showingConfirmation) {
-			lines.push(padToWidth(this.dim(`├${horizontalLine(boxWidth - 2)}┤`)));
-			const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`;
-			lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
-		} else {
-			lines.push(padToWidth(this.dim(`├${horizontalLine(boxWidth - 2)}┤`)));
-			const controls = `${this.dim("Tab/Enter")} next · ${this.dim("Shift+Tab")} prev · ${this.dim("Shift+Enter")} newline · ${this.dim("Alt+N")} note · ${this.dim("Esc")} cancel`;
-			lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
-		}
-		lines.push(padToWidth(this.dim(`╰${horizontalLine(boxWidth - 2)}╯`)));
-
-		this.cachedWidth = width;
-		this.cachedLines = lines;
-		return lines;
-	}
-}

packages/answer/src/extract.ts đź”—

@@ -1,68 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-// SPDX-FileCopyrightText: Armin Ronacher <armin.ronacher@active-4.com>
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import type { complete } from "@earendil-works/pi-ai";
-import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
-import type { ExtractedQuestion, ExtractionResult } from "./prompt.js";
-
-// Preferred model for extraction — lightweight is fine for structured JSON output.
-// Falls back to the session model if this one isn't available.
-const PREFERRED_EXTRACTION_MODEL_ID = "nemotron-3-super-120b-a12b";
-
-/**
- * Resolve the extraction model: prefer a lightweight model from the registry,
- * fall back to the current session model.
- */
-export function resolveExtractionModel(ctx: ExtensionContext, fallback: NonNullable<ExtensionContext["model"]>) {
-	const available = ctx.modelRegistry.getAvailable();
-	const preferred = available.find((m) => m.id === PREFERRED_EXTRACTION_MODEL_ID);
-	return preferred ?? fallback;
-}
-
-/**
- * Parse a tool call response into an ExtractionResult.
- */
-export function parseToolCallResult(response: Awaited<ReturnType<typeof complete>>): ExtractionResult | null {
-	const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_questions");
-
-	if (toolCall?.type !== "toolCall") {
-		console.error("Model did not call extract_questions:", response.content);
-		return null;
-	}
-
-	const args = toolCall.arguments as Record<string, unknown>;
-	if (!Array.isArray(args.questions)) {
-		console.error("[answer] expected questions array, got:", typeof args.questions, args);
-		return null;
-	}
-
-	// Validate each question item — model may return plain strings instead
-	// of objects if it ignores the schema, so we handle both gracefully.
-	const questions: ExtractedQuestion[] = [];
-	for (const item of args.questions) {
-		if (typeof item === "string") {
-			// Model returned bare string instead of object — use as question text
-			questions.push({ question: item });
-			continue;
-		}
-		if (typeof item !== "object" || item === null) continue;
-		const obj = item as Record<string, unknown>;
-		if (typeof obj.question !== "string") {
-			console.error("[answer] skipping item with no 'question' string:", Object.keys(obj));
-			continue;
-		}
-		questions.push({
-			question: obj.question,
-			context: typeof obj.context === "string" ? obj.context : undefined,
-		});
-	}
-
-	return { questions };
-}
-
-/** Escape characters that would break pseudo-XML tag boundaries. */
-export function escapeXml(s: string): string {
-	return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
-}

packages/answer/src/index.ts đź”—

@@ -1,224 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-// SPDX-FileCopyrightText: Armin Ronacher <armin.ronacher@active-4.com>
-//
-// SPDX-License-Identifier: Apache-2.0
-
-/**
- * Q&A extraction hook - extracts questions from assistant responses
- *
- * Custom interactive TUI for answering questions.
- *
- * Demonstrates the "prompt generator" pattern with custom TUI:
- * 1. /answer command gets the last assistant message
- * 2. Shows a spinner while extracting questions as structured JSON
- * 3. Presents an interactive TUI to navigate and answer questions
- * 4. Submits the compiled answers when done
- */
-
-import { complete, type UserMessage } from "@earendil-works/pi-ai";
-import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
-import { BorderedLoader } from "@earendil-works/pi-coding-agent";
-import { parseToolCallResult, resolveExtractionModel } from "./extract.js";
-import { type ExtractionResult, QUESTION_EXTRACTION_TOOL, SYSTEM_PROMPT } from "./prompt.js";
-import { QnAComponent } from "./QnAComponent.js";
-
-export default function (pi: ExtensionAPI) {
-	const answerHandler = async (ctx: ExtensionContext, instruction?: string) => {
-		if (!ctx.hasUI) {
-			ctx.ui.notify("answer requires interactive mode", "error");
-			return;
-		}
-
-		if (!ctx.model) {
-			ctx.ui.notify("No model selected", "error");
-			return;
-		}
-
-		// Find the last assistant message on the current branch
-		const branch = ctx.sessionManager.getBranch();
-		let lastAssistantText: string | undefined;
-
-		for (let i = branch.length - 1; i >= 0; i--) {
-			const entry = branch[i];
-			if (entry.type === "message") {
-				const msg = entry.message;
-				if ("role" in msg && msg.role === "assistant") {
-					if (msg.stopReason !== "stop") {
-						ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
-						return;
-					}
-					const textParts = msg.content
-						.filter((c): c is { type: "text"; text: string } => c.type === "text")
-						.map((c) => c.text);
-					if (textParts.length > 0) {
-						lastAssistantText = textParts.join("\n");
-						break;
-					}
-				}
-			}
-		}
-
-		if (!lastAssistantText) {
-			ctx.ui.notify("No assistant messages found", "error");
-			return;
-		}
-
-		// Capture narrowed values so closures can use them without non-null assertions.
-		const sessionModel = ctx.model;
-		const assistantText = lastAssistantText;
-
-		// Run extraction with loader UI.
-		// The result distinguishes user cancellation from errors so we can
-		// show the right message after the custom UI closes.
-		type ExtractionOutcome =
-			| { kind: "ok"; result: ExtractionResult }
-			| { kind: "cancelled" }
-			| { kind: "error"; message: string };
-
-		const outcome = await ctx.ui.custom<ExtractionOutcome>((tui, theme, _kb, done) => {
-			const extractionModel = resolveExtractionModel(ctx, sessionModel);
-			const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.name}...`);
-
-			// Guard against double-completion: loader.onAbort fires on user
-			// cancel, but the in-flight promise may also resolve/reject after
-			// the abort.  Only the first call to finish() takes effect.
-			let finished = false;
-			const finish = (result: ExtractionOutcome) => {
-				if (finished) return;
-				finished = true;
-				done(result);
-			};
-
-			loader.onAbort = () => finish({ kind: "cancelled" });
-
-			const tryExtract = async (model: ReturnType<typeof resolveExtractionModel>) => {
-				const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
-				if (!auth.ok) {
-					return { kind: "model_error" as const, model };
-				}
-				const instructionBlock = instruction
-					? `<user_instruction>\n${instruction}\n</user_instruction>\n\nExtract questions from the assistant's message based on the user's instruction above.`
-					: "Extract questions from the assistant's message for the user to fill out.";
-
-				const userMessage: UserMessage = {
-					role: "user",
-					content: [
-						{
-							type: "text",
-							text: `<last_assistant_message>\n${assistantText}\n</last_assistant_message>\n\n${instructionBlock}`,
-						},
-					],
-					timestamp: Date.now(),
-				};
-
-				const response = await complete(
-					model,
-					{
-						systemPrompt: SYSTEM_PROMPT,
-						messages: [userMessage],
-						tools: [QUESTION_EXTRACTION_TOOL],
-					},
-					{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
-				);
-
-				if (response.stopReason === "aborted") {
-					return { kind: "cancelled" as const };
-				}
-
-				if (response.stopReason === "error" || response.content.length === 0) {
-					return { kind: "model_error" as const, model };
-				}
-
-				const parsed = parseToolCallResult(response);
-				if (!parsed) {
-					return {
-						kind: "parse_error" as const,
-						message: `${model.name} did not call extract_questions tool`,
-					};
-				}
-				return { kind: "ok" as const, result: parsed };
-			};
-
-			const doExtract = async () => {
-				let result = await tryExtract(extractionModel);
-
-				// If the preferred model errored and it's not already the
-				// session model, fall back to the session model.
-				if (result.kind === "model_error" && extractionModel.id !== sessionModel.id) {
-					ctx.ui.notify(`${extractionModel.name} unavailable, falling back to ${sessionModel.name}...`, "warning");
-					result = await tryExtract(sessionModel);
-				}
-
-				switch (result.kind) {
-					case "ok":
-						return finish({ kind: "ok", result: result.result });
-					case "cancelled":
-						return finish({ kind: "cancelled" });
-					case "model_error":
-						return finish({
-							kind: "error",
-							message: `${result.model.name} returned an error with no content`,
-						});
-					case "parse_error":
-						return finish({ kind: "error", message: result.message });
-				}
-			};
-
-			doExtract().catch((err) =>
-				finish({
-					kind: "error",
-					message: `${err?.message ?? err}`,
-				}),
-			);
-
-			return loader;
-		});
-
-		if (outcome.kind === "cancelled") {
-			ctx.ui.notify("Cancelled", "info");
-			return;
-		}
-		if (outcome.kind === "error") {
-			ctx.ui.notify(`Extraction failed: ${outcome.message}`, "error");
-			return;
-		}
-
-		const extractionResult = outcome.result;
-
-		if (extractionResult.questions.length === 0) {
-			ctx.ui.notify("No questions found in the last message", "info");
-			return;
-		}
-
-		// Show the Q&A component
-		const answersResult = await ctx.ui.custom<string | null>((tui, _theme, _kb, done) => {
-			return new QnAComponent(extractionResult.questions, tui, done);
-		});
-
-		if (answersResult === null) {
-			ctx.ui.notify("Cancelled", "info");
-			return;
-		}
-
-		// Send the answers directly as a message and trigger a turn
-		pi.sendMessage(
-			{
-				customType: "answers",
-				content: `I answered your questions in the following way:\n\n${answersResult}`,
-				display: true,
-			},
-			{ triggerTurn: true },
-		);
-	};
-
-	pi.registerCommand("answer", {
-		description:
-			"Extract questions from last assistant message into interactive Q&A. Optional: provide instructions for what to extract.",
-		handler: (args, ctx) => answerHandler(ctx, args?.trim() || undefined),
-	});
-
-	pi.registerShortcut("ctrl+.", {
-		description: "Extract and answer questions",
-		handler: answerHandler,
-	});
-}

packages/answer/src/prompt.ts đź”—

@@ -1,155 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-// SPDX-FileCopyrightText: Armin Ronacher <armin.ronacher@active-4.com>
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import type { Tool } from "@earendil-works/pi-ai";
-import { Type } from "typebox";
-
-export interface ExtractedQuestion {
-	question: string;
-	context?: string;
-}
-
-export interface ExtractionResult {
-	questions: ExtractedQuestion[];
-}
-
-export const SYSTEM_PROMPT = `You extract questions from assistant messages so a user can answer them without reading the full message. Call the extract_questions tool with your results.
-
-Each extracted question must be a standalone object with a "question" string and an optional "context" string. The user will read ONLY the extracted questions, not the original message, so both the question and context must be fully self-contained.
-
-Rules:
-- Each question must make sense on its own without referring back to the message (no "Issue 1", "the above", "as mentioned")
-- Context should include all relevant details, options, and reasoning needed to answer — it replaces reading the original message
-- Merge duplicate or overlapping questions into one (e.g. an analysis section and a plan section about the same topic become one question)
-- Capture both explicit questions ("Which approach?") and implicit ones (proposals that need approval, recommendations that need confirmation)
-- Keep questions in logical order
-- If no questions are found, call the tool with an empty questions array
-
-<examples>
-<example>
-<description>Simple explicit question</description>
-<input>I can set up the database with either PostgreSQL or MySQL. The existing infrastructure uses PostgreSQL for two other services. Which database should I use?</input>
-<output>
-{"question": "Which database should be used for the new service?", "context": "Options are PostgreSQL or MySQL. The existing infrastructure already uses PostgreSQL for two other services."}
-</output>
-</example>
-
-<example>
-<description>Implicit proposal needing approval</description>
-<input>I looked at the failing test. The flakiness comes from a race condition in the cleanup goroutine. I think the simplest fix is to add a sync.WaitGroup so the test waits for cleanup to finish before asserting. Want me to go ahead with that?</input>
-<output>
-{"question": "Should the flaky test be fixed by adding a sync.WaitGroup to wait for the cleanup goroutine before asserting?", "context": "The test failure is caused by a race condition in the cleanup goroutine. The WaitGroup approach ensures cleanup completes before assertions run."}
-</output>
-</example>
-
-<example>
-<description>Long message with analysis and plan sections that overlap — questions must be merged, deduplicated, and fully standalone</description>
-<input>
-## Issue 1: Non-deterministic map iteration for overrides → rawArgs
-
-The problem is in RunE at line ~75: for k, vals := range overrides gives random order each run.
-
-Three good approaches:
-1. Sort the keys before iterating. Simplest, most common Go pattern.
-2. Use []restic.Flag or []struct{key, vals} instead of a map. More invasive but preserves construction order.
-3. Skip the map→rawArgs→parsePassthrough round-trip entirely. Instead of serializing overrides to rawArgs and then having runCommand parse them back, just pass the overrides map directly to runCommand and merge there. This eliminates the round-trip and the ordering problem disappears.
-
-Recommend approach 3 as best: it kills the root cause.
-
-## Issue 2: Double-pointers (**screens.Snapshot etc.)
-
-The problem is buildCommandScreens needs to both return screens AND communicate which specific screen instances were created back to runInteractive. Double-pointers are gnarly.
-
-Simpler approach: return a struct holding typed screen pointers. The ResolveFunc closure captures the struct.
-
-## Issue 3: Picker height overflow
-
-The session already subtracts chrome height via adjustedSizeMsg() before forwarding WindowSizeMsg to screens. The legacy pickerModel does its own subtraction because it runs standalone. Assessment: this finding is a false positive.
-
-## Issue 2.1: huh form boilerplate
-
-The buildForm/Init/Update boilerplate is repetitive across Overwrite, Target, Preset, Snapshot. Code is simple and clear in each screen. Recommendation: skip — borderline DRY.
-
-## Issue 2.2: drain test helpers
-
-The drain* test helpers are copy-pasted with only the type changed. Perfect case for Go generics: func drain[S ui.Screen](s S, cmd tea.Cmd) (S, tea.Cmd)
-
-## Issue 3.1: Snapshot theme handler clears user input
-
-When BackgroundColorMsg arrives during phaseSnapshotManual, buildManualForm() resets s.entered = "". Fix: preserve s.entered before rebuilding. Also needs rebuildSelectForm() call during phaseSnapshotSelecting.
-
-## Issue 4: WindowSizeMsg missed during phaseFileLoading
-
-If the screen is loading when the initial WindowSizeMsg arrives, it only goes to the spinner and the size is lost. Fix: store the last WindowSizeMsg on the FilePicker struct, apply when picker is built.
-
-## Issue 5.1: Same as Issue 1
-
-Same root cause — map iteration in RunE. Fix for Issue 1 handles this.
-
-## Issue 6.1: Duplicated constant
-
-Both internal/config and internal/restic define the same commandSuffix constant.
-
-## Issue 6.2: Inconsistent empty-string semantics
-
-rc.Environ treats "" as present (checks map key existence), but os.Getenv checks != "" so an empty env var counts as absent.
-
-## Plan
-
-1. Remove legacy restore prompts (promptRestore, promptSnapshotID, promptFileSelection, printRestoreSummary, standalone pickerModel)
-2. Eliminate map→rawArgs round-trip (Issues 1 and 5.1)
-3. Replace double-pointers with return struct (Issue 2)
-4. Extract generic drain test helper (Issue 2.2)
-5. Fix snapshot theme handler (Issue 3.1)
-6. Store WindowSizeMsg during loading (Issue 4)
-7. Share commandSuffix constant (Issue 6.1)
-8. Fix inconsistent empty-string check (Issue 6.2)
-
-NOT planning to do Issue 2.1 (huh form boilerplate extraction).
-</input>
-<output>
-{"question": "How should the non-deterministic map iteration in RunE be fixed, where 'for k, vals := range overrides' produces random argument order each run?", "context": "Three approaches: (1) Sort map keys before iterating — simplest, small code change. (2) Replace map[string][]string with a slice of key-value pairs to preserve construction order. (3) Eliminate the map→rawArgs→parsePassthrough round-trip entirely by passing overrides directly to runCommand — this also fixes the identical issue in rawArgs parsing. Recommended: approach 3."}
-{"question": "Should the double-pointer output parameters (**screens.Snapshot, etc.) in buildCommandScreens be replaced with a returned struct?", "context": "Currently uses double-pointers so runInteractive can read results from specific screen instances. A commandScreens struct holding typed screen pointers would be returned instead, and the ResolveFunc closure captures it. Idiomatic Go — return a struct instead of output parameters."}
-{"question": "The picker height overflow appears to be a false positive because the session already subtracts chrome height via adjustedSizeMsg() before forwarding WindowSizeMsg to screens. Safe to skip?", "context": "session.go subtracts 5 chrome lines (breadcrumb + title + help bar) before forwarding. The legacy pickerModel does its own subtraction because it runs standalone with tea.NewProgram. The screen adapter doesn't need additional subtraction."}
-{"question": "Should the repeated huh form boilerplate (buildForm/Init/Update) across Overwrite, Target, Preset, and Snapshot screens be extracted into a shared helper?", "context": "Each screen repeats same form setup pattern with slight behavior variations. Code is currently simple and self-contained in each screen. Recommended: skip — borderline DRY."}
-{"question": "Should the copy-pasted type-specific drain test helpers be replaced with a single generic drain[S ui.Screen] function?", "context": "Multiple test files have identical drain helpers differing only in type parameter (e.g. drainSnapshot, drainFilePicker). A single generic function would replace all of them."}
-{"question": "Should the snapshot theme handler be fixed to preserve user input when the terminal theme changes?", "context": "When BackgroundColorMsg arrives during phaseSnapshotManual, buildManualForm() resets s.entered to empty, wiping user input. Fix: preserve s.entered before rebuild. Also needs rebuildSelectForm() call during phaseSnapshotSelecting."}
-{"question": "Should FilePicker store the last WindowSizeMsg to prevent size loss during the loading phase?", "context": "If the screen is loading when the initial WindowSizeMsg arrives, the message only goes to the spinner and size is lost. When buildPicker runs later, picker doesn't know terminal size. Fix: add lastSize field, apply when picker built."}
-{"question": "Should the duplicated commandSuffix constant be extracted to a shared package?", "context": "Both internal/config and internal/restic define the same constant."}
-{"question": "Should os.Getenv() != '' checks be replaced with os.LookupEnv so empty environment variables count as present?", "context": "rc.Environ treats empty string as present (checks map key existence), but os.Getenv checks != '' so an empty env var counts as absent. Using os.LookupEnv would make behavior consistent — explicitly setting RESTIC_REPOSITORY='' would count as configured."}
-{"question": "Should the legacy interactive restore prompts (promptRestore, promptSnapshotID, promptFileSelection, printRestoreSummary, standalone pickerModel) be removed from root.go?", "context": "The TUI session now handles the full restore flow through screens, making these standalone prompt functions unused."}
-</output>
-</example>
-</examples>`;
-
-/**
- * Tool definition for structured question extraction. Models produce
- * structured tool-call arguments far more reliably than free-form JSON
- * in a text response.
- */
-export const QUESTION_EXTRACTION_TOOL: Tool = {
-	name: "extract_questions",
-	description:
-		"Extract questions from the assistant's message. Each question is a self-contained object the user will read instead of the original message.",
-	parameters: Type.Object({
-		questions: Type.Array(
-			Type.Object({
-				question: Type.String({
-					description: "A complete, standalone question. Must make sense without reading the original message.",
-				}),
-				context: Type.Optional(
-					Type.String({
-						description:
-							"Self-contained context with all details, options, and reasoning needed to answer the question.",
-					}),
-				),
-			}),
-			{
-				description:
-					"Array of question objects. Merge overlapping questions. Each item MUST be an object with 'question' and optional 'context' strings, NOT a plain string.",
-			},
-		),
-	}),
-};

scripts/resolve-release-packages.bash đź”—

@@ -6,11 +6,10 @@
 
 set -euo pipefail
 
-valid_packages="answer, handoff, personas"
+valid_packages="handoff, personas"
 
 resolve_package_dir() {
 	case "$1" in
-		answer|pi-answer|@amolith/pi-answer) printf '%s\n' packages/answer ;;
 		handoff|pi-handoff|@amolith/pi-handoff) printf '%s\n' packages/handoff ;;
 		personas|pi-personas|@amolith/pi-personas) printf '%s\n' packages/personas ;;
 		*)