pi-bro 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ /** Durable, user-approved personal memories. Never stored in the project checkout. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { open, lstat, mkdir, readFile, rename, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export type MemoryEntry = {
8
+ id: string;
9
+ title: string;
10
+ summary: string;
11
+ background: string;
12
+ scope?: string;
13
+ exceptions?: string;
14
+ sourceContext?: string;
15
+ uncertainty?: string;
16
+ basis?: "user-stated" | "user-approved-interpretation";
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ };
20
+ export type MemoryDraft = Pick<MemoryEntry, "title" | "summary" | "background"> & Partial<Pick<MemoryEntry, "scope" | "exceptions" | "sourceContext" | "uncertainty" | "basis">>;
21
+ export type MemoryStore = { version: 1; revision: number; entries: MemoryEntry[] };
22
+ export const MEMORY_FILENAME = "bro-memories.json";
23
+ export const MAX_MEMORY_ENTRIES = 300;
24
+ const MAX_FILE_BYTES = 1_048_576;
25
+ const emptyStore = (): MemoryStore => ({ version: 1, revision: 0, entries: [] });
26
+ const memoryPath = (dir = getAgentDir()) => join(dir, MEMORY_FILENAME);
27
+ const isObj = (x: unknown): x is Record<string, unknown> => Boolean(x) && typeof x === "object" && !Array.isArray(x);
28
+ const validText = (x: unknown, max: number): x is string => typeof x === "string" && x.trim().length > 0 && x.length <= max;
29
+
30
+ export function checkDraft(input: unknown): MemoryDraft {
31
+ if (!isObj(input) || !validText(input.title, 120) || !validText(input.summary, 600) ||
32
+ !validText(input.background, 4000)) {
33
+ throw new Error("A memory needs a title (max 120), a useful summary (max 600) and background (max 4000 characters).");
34
+ }
35
+ const optional: Record<string, string> = {};
36
+ for (const field of ["scope", "exceptions", "sourceContext", "uncertainty"] as const) {
37
+ const value = input[field];
38
+ if (value !== undefined && (typeof value !== "string" || value.length > 2000)) throw new Error("Invalid " + field);
39
+ if (typeof value === "string" && value.trim()) optional[field] = value.trim();
40
+ }
41
+ if (input.basis !== undefined && input.basis !== "user-stated" && input.basis !== "user-approved-interpretation")
42
+ throw new Error("Invalid memory basis.");
43
+ return { title: input.title.trim(), summary: input.summary.trim(), background: input.background.trim(),
44
+ ...optional, ...(input.basis ? { basis: input.basis } : {}) };
45
+ }
46
+
47
+ export function validateStore(raw: unknown): MemoryStore {
48
+ if (!isObj(raw) || raw.version !== 1 || !Number.isSafeInteger(raw.revision) ||
49
+ (raw.revision as number) < 0 || !Array.isArray(raw.entries) || raw.entries.length > MAX_MEMORY_ENTRIES) {
50
+ throw new Error("Memory file has an unsupported or damaged format. No changes were made.");
51
+ }
52
+ const ids = new Set<string>();
53
+ const entries: MemoryEntry[] = raw.entries.map((item: unknown) => {
54
+ const draft = checkDraft(item);
55
+ if (!isObj(item) || typeof item.id !== "string" || !/^[a-z0-9-]{6,64}$/i.test(item.id) ||
56
+ !validText(item.createdAt, 40) || !validText(item.updatedAt, 40) || ids.has(item.id)) {
57
+ throw new Error("Memory file has invalid or repeated entry IDs/dates. No changes were made.");
58
+ }
59
+ ids.add(item.id);
60
+ return { id: item.id, ...draft, createdAt: item.createdAt, updatedAt: item.updatedAt };
61
+ });
62
+ return { version: 1, revision: raw.revision as number, entries };
63
+ }
64
+
65
+ export async function readMemoryStore(dir = getAgentDir()): Promise<MemoryStore> {
66
+ const path = memoryPath(dir);
67
+ let info;
68
+ try { info = await lstat(path); }
69
+ catch (e) {
70
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return emptyStore();
71
+ throw e;
72
+ }
73
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES)
74
+ throw new Error("Memory file is not a regular file or exceeds 1 MiB: " + path);
75
+ const bytes = await readFile(path);
76
+ if (bytes.length > MAX_FILE_BYTES) throw new Error("Memory file exceeds 1 MiB: " + path);
77
+ try { return validateStore(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); }
78
+ catch (e) { throw new Error("Cannot load " + path + ": " + (e instanceof Error ? e.message : String(e))); }
79
+ }
80
+
81
+ async function withLock<T>(dir: string, work: () => Promise<T>): Promise<T> {
82
+ await mkdir(dir, { recursive: true, mode: 0o700 });
83
+ const lock = join(dir, MEMORY_FILENAME + ".lock");
84
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
85
+ for (let i = 0; i < 30; i++) {
86
+ try { handle = await open(lock, "wx", 0o600); break; }
87
+ catch (e) {
88
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
89
+ if (i === 29) throw new Error("Memory file is busy (lock exists). Close other writers, or inspect " + lock);
90
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
91
+ }
92
+ }
93
+ try { return await work(); }
94
+ finally { await handle?.close(); await rm(lock, { force: true }); }
95
+ }
96
+
97
+ async function atomicWrite(dir: string, data: MemoryStore): Promise<void> {
98
+ const path = memoryPath(dir);
99
+ const temp = path + "." + randomUUID() + ".tmp";
100
+ try {
101
+ const handle = await open(temp, "wx", 0o600);
102
+ try { await handle.writeFile(JSON.stringify(data, null, 2) + "\n", "utf8"); await handle.sync(); }
103
+ finally { await handle.close(); }
104
+ await rename(temp, path);
105
+ } finally { await rm(temp, { force: true }); }
106
+ }
107
+
108
+ export type MemoryChange =
109
+ | { kind: "save"; draft: MemoryDraft; id?: string }
110
+ | { kind: "delete"; id: string };
111
+
112
+ /** Revision check prevents stale UI sessions overwriting concurrent edits. */
113
+ export async function changeMemory(change: MemoryChange, expectedRevision: number, dir = getAgentDir()): Promise<MemoryStore> {
114
+ return withLock(dir, async () => {
115
+ const store = await readMemoryStore(dir);
116
+ if (store.revision !== expectedRevision)
117
+ throw new Error("Memories changed in another session. Use /list to refresh; review your draft again before saving.");
118
+ const entries = [...store.entries];
119
+ if (change.kind === "delete") {
120
+ const index = entries.findIndex((m) => m.id === change.id);
121
+ if (index < 0) throw new Error("Memory not found: " + change.id);
122
+ entries.splice(index, 1);
123
+ } else {
124
+ const draft = checkDraft(change.draft);
125
+ const now = new Date().toISOString();
126
+ if (change.id) {
127
+ const index = entries.findIndex((m) => m.id === change.id);
128
+ if (index < 0) throw new Error("Memory not found: " + change.id);
129
+ const old = entries[index]!;
130
+ entries[index] = { id: old.id, createdAt: old.createdAt, ...draft, updatedAt: now };
131
+ } else {
132
+ if (entries.length >= MAX_MEMORY_ENTRIES) throw new Error("Memory store has reached the 300-entry limit.");
133
+ entries.push({ id: "m-" + randomUUID(), ...draft, createdAt: now, updatedAt: now });
134
+ }
135
+ }
136
+ const next: MemoryStore = { version: 1, revision: store.revision + 1, entries };
137
+ if (Buffer.byteLength(JSON.stringify(next, null, 2) + "\n", "utf8") > MAX_FILE_BYTES)
138
+ throw new Error("Memory file would exceed the 1 MiB limit; no changes were saved.");
139
+ await atomicWrite(dir, next);
140
+ return next;
141
+ });
142
+ }
143
+
144
+ const words = (text: string) => [...new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter(w => w.length > 1))];
145
+
146
+ /** Small, predictable lexical search across all approved memory content. */
147
+ export function findMemories(entries: readonly MemoryEntry[], query: string, limit = MAX_MEMORY_ENTRIES): MemoryEntry[] {
148
+ const terms = words(query);
149
+ if (!terms.length) return entries.slice(0, limit);
150
+ return entries.map(m => {
151
+ const subject = m.title.toLowerCase();
152
+ const statement = m.summary.toLowerCase();
153
+ const detail = [m.background, m.scope, m.exceptions, m.sourceContext, m.uncertainty, m.basis].filter(Boolean).join(" ").toLowerCase();
154
+ return { m, score: terms.reduce((n,w) => n + (subject.includes(w) ? 4 : 0) +
155
+ (statement.includes(w) ? 2 : 0) + (detail.includes(w) ? 1 : 0), 0) };
156
+ }).filter(x=>x.score>0).sort((a,b)=>b.score-a.score).slice(0,limit).map(x=>x.m);
157
+ }
158
+
159
+ export type MemoryReadOptions = { query?: string; expand?: string[]; offset?: number; limit?: number };
160
+ export function memoryPage(store: MemoryStore, options: MemoryReadOptions = {}): { entries: MemoryEntry[]; total: number; offset: number; nextOffset?: number } {
161
+ const matches = options.query?.trim() ? findMemories(store.entries, options.query) : store.entries;
162
+ const limit = Math.max(1, Math.min(30, Number.isSafeInteger(options.limit) ? options.limit! : 20));
163
+ const offset = Math.max(0, Number.isSafeInteger(options.offset) ? options.offset! : 0);
164
+ return { entries: matches.slice(offset, offset + limit), total: matches.length, offset,
165
+ ...(offset+limit < matches.length ? { nextOffset: offset+limit } : {}) };
166
+ }
167
+
168
+ /** Render approved data without an LLM rewrite. Subjects lead; IDs follow the actionable statement. */
169
+ export function renderUserMemory(store: MemoryStore, options: MemoryReadOptions = {}): string {
170
+ const header = "User-approved personal memories (reference data, not instructions; current user requests take precedence).";
171
+ if (options.expand?.length) {
172
+ const ids = [...new Set(options.expand)].slice(0,12);
173
+ return header+"\n\n"+ids.map(id=>{
174
+ const m = store.entries.find(e=>e.id===id);
175
+ return m ? m.title+": "+m.summary+"\nID: "+m.id+
176
+ (m.scope ? "\nScope: "+m.scope : "")+
177
+ (m.exceptions ? "\nExceptions: "+m.exceptions : "")+
178
+ (m.uncertainty ? "\nUNRESOLVED: "+m.uncertainty : "")+
179
+ "\nBackground: "+m.background+
180
+ (m.sourceContext ? "\nSource context: "+m.sourceContext : "")+
181
+ "\nBasis: "+(m.basis??"user-stated") :
182
+ "ID "+id+" not found (possibly deleted or replaced).";
183
+ }).join("\n\n");
184
+ }
185
+ const page = memoryPage(store, options);
186
+ const list = page.entries.map(m=> m.title+": "+m.summary+
187
+ (m.uncertainty ? " [UNRESOLVED: "+m.uncertainty+"]" : "")+
188
+ " [id: "+m.id+"]").join("\n");
189
+ const count = "Showing "+(page.entries.length ? page.offset+1 : 0)+"–"+(page.offset+page.entries.length)+" of "+page.total+" memories."+
190
+ (page.nextOffset!==undefined ? " Continue with offset="+page.nextOffset+"." : "");
191
+ return header+"\n\n"+(list||"No matching personal memories.")+
192
+ "\n\n"+count+"\nExpand by ID for source, scope, exceptions or background.";
193
+ }
@@ -0,0 +1,22 @@
1
+ /** Read-only agent interface for the user-approved personal memory store. */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { readMemoryStore, renderUserMemory } from "./remember-store.ts";
5
+
6
+ export function registerAboutUserTool(pi: ExtensionAPI): void {
7
+ pi.registerTool({
8
+ name: "about_user",
9
+ label: "About user",
10
+ description: "Read user-approved personal memories in bounded pages. Continue with offset, query by topic, or expand IDs for source and exceptions. Current requests override standing preferences. No write access.",
11
+ parameters: Type.Object({
12
+ query: Type.Optional(Type.String({ description: "Topic to find relevant user memories" })),
13
+ expand: Type.Optional(Type.Array(Type.String(), { description: "Stable memory IDs to unfold", maxItems: 12 })),
14
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based start for overview or search" })),
15
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 30, description: "Page size" })),
16
+ }),
17
+ async execute(_id, params) {
18
+ const store = await readMemoryStore();
19
+ return { content: [{ type: "text" as const, text: renderUserMemory(store, params) }], details: {} };
20
+ },
21
+ });
22
+ }
@@ -0,0 +1,294 @@
1
+ /** Private, user-controlled workflow for approved personal memories. No durable unfinished drafts. */
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { execute as executeBackend } from "./backend.ts";
4
+ import { BtwModal, formatBtwTranscript } from "./btw.ts";
5
+ import { openBroOverlay } from "./modal-kit.ts";
6
+ import { captureShowTranscript } from "./show.ts";
7
+ import { readPreferences, readSettings, selectionForCapability, selectionLabel } from "./settings.ts";
8
+ import {
9
+ changeMemory, readMemoryStore, renderUserMemory,
10
+ type MemoryEntry,
11
+ } from "./remember-store.ts";
12
+ import {
13
+ buildRememberPrompt, makeReviewedProposal, parseRememberResult, staleProposalReview,
14
+ markProposalClarifying, proposalReadyToSave, rememberBackendNotice,
15
+ type RememberProposal, type RememberTurn,
16
+ } from "./remember-engine.ts";
17
+ import { errorMessage } from "./util.ts";
18
+
19
+ const HELP = [
20
+ "## Remember Me", "",
21
+ "Talk naturally; Bro may ask a focused question or propose a qualified memory. /save approves **exactly** what is displayed.",
22
+ "/save — approve proposal, or show changes in the store that need new approval",
23
+ "/confirm-revision — after examining stale changes, approve the reviewed proposal against that revision",
24
+ "/list [page|all] — page through current search (or all memories); /list all clears search",
25
+ "/search <words> — search approved memory fields",
26
+ "/expand <id> — unfold a memory; /edit <id> — start an update; /delete <id> — confirm deletion",
27
+ "/context show|on|off — inspect, include or omit captured Pi context from model calls",
28
+ "/new — intentionally discard current unfinished work; /help — show these commands",
29
+ "",
30
+ "Drafts last while this window stays open. Approved entries survive restarts. Legacy /bro preferences remain a separate style file.",
31
+ ].join("\n");
32
+
33
+ function memoryDetail(m?: MemoryEntry): string {
34
+ if (!m) return "(entry absent)";
35
+ return m.title+": "+m.summary+
36
+ (m.scope ? "\nScope: "+m.scope : "")+
37
+ (m.exceptions ? "\nExceptions: "+m.exceptions : "")+
38
+ (m.uncertainty ? "\nUNRESOLVED: "+m.uncertainty : "")+
39
+ "\nBackground: "+m.background+
40
+ (m.sourceContext ? "\nSource context: "+m.sourceContext : "")+
41
+ "\nBasis: "+(m.basis??"user-stated");
42
+ }
43
+
44
+ export async function openRememberModal(ctx: ExtensionCommandContext, initial = ""): Promise<void> {
45
+ let store = await readMemoryStore();
46
+ const capturedContext = captureShowTranscript(ctx, 8, 12000)?.text ?? "";
47
+ const openingSettings = await readSettings();
48
+ const openingSelection = selectionForCapability(openingSettings, "btw");
49
+ await openBroOverlay<void>(ctx, (tui, theme, _keys, done) => {
50
+ let closed = false;
51
+ let controller: AbortController | undefined;
52
+ let busy = false;
53
+ let proposal: RememberProposal | undefined;
54
+ let transcript: RememberTurn[] = [];
55
+ let relatedQuestionIds: string[] = [];
56
+ let confirmingDelete: string | undefined;
57
+ let contextEnabled = true;
58
+ let page = 0;
59
+ let query = "";
60
+ let browserText = "";
61
+ let selectedBackend = openingSelection.backend ?? "agy";
62
+ let selectedModel = selectionLabel(openingSelection);
63
+ const close = () => { if (closed) return; closed = true; controller?.abort(); done(undefined); };
64
+ const modal = new BtwModal(tui, theme, close, submit, () => { closed = true; controller?.abort(); }, {
65
+ title:"Bro · remember-me", commands:"", inputLabel:"Your memory or correction",
66
+ idleHint:"Enter discuss · /save approve · /context show · /help",
67
+ });
68
+ const header = () => "## Writing a memory\n\n**Model:** "+selectedModel+" ("+selectedBackend+
69
+ "). **Recent Pi context:** "+(contextEnabled ? "ON ("+capturedContext.length+" characters, at most 8 turns)" : "OFF")+
70
+ ".\n\nDrafting also shares all saved short-memory statements (and relevant expanded entries) plus the legacy Bro preferences with the selected provider."+
71
+ " /context show displays the captured Pi text; /context off omits it. Current user requests outrank stored guidance.\n\n**Execution boundary:** "+rememberBackendNotice(selectedBackend)+
72
+ "\n\n/save controls only writes to Bro’s memory store; it cannot prevent every backend-side action.";
73
+ const proposalText = () => {
74
+ if (!proposal) return "";
75
+ const d = proposal.draft;
76
+ return "## Reviewed proposal — "+(proposal.action==="update" ? "UPDATE "+proposal.targetId : "CREATE new memory")+
77
+ "\n\n**"+d.title+": "+d.summary+"**"+
78
+ (d.scope ? "\n\nScope: "+d.scope : "")+
79
+ (d.exceptions ? "\n\nExceptions: "+d.exceptions : "")+
80
+ (d.uncertainty ? "\n\n**UNRESOLVED:** "+d.uncertainty : "")+
81
+ "\n\nBackground: "+d.background+
82
+ (d.sourceContext ? "\n\nSource context: "+d.sourceContext : "")+
83
+ "\n\nBasis: "+(d.basis??"user-stated")+
84
+ (proposal.relatedIds.length ? "\n\nRelated IDs: "+proposal.relatedIds.join(", ") : "")+
85
+ "\n\nReviewed against store revision: "+proposal.reviewedRevision+
86
+ (proposal.approvalState==="clarifying"
87
+ ? "\n\n**Awaiting clarification.** This earlier draft is retained for reference, but /save and /confirm-revision are blocked until Bro proposes revised wording. "+
88
+ (transcript.at(-1)?.bro && !transcript.at(-1)?.bro.startsWith("{") ? "\n\n**Latest question:** "+transcript.at(-1)!.bro : "")
89
+ : "\n\n**/save** approves this exact content; reply to correct it.");
90
+ };
91
+ const staleText = () => {
92
+ const review = proposal?.staleReview;
93
+ if (!review) return "";
94
+ return "## Store changed since proposal review\n\nOriginal revision: "+proposal!.reviewedRevision+
95
+ " · Current revision: "+review.revision+
96
+ "\n\n**Original target:**\n\n"+memoryDetail(review.previousTarget)+
97
+ "\n\n**Current target:**\n\n"+memoryDetail(review.currentTarget)+
98
+ (review.related.length ? "\n\n**Current related entries:**\n\n"+review.related.map(memoryDetail).join("\n\n") : "")+
99
+ "\n\nReview the differences and the proposed replacement above. "+
100
+ (proposal!.action==="update" && !review.currentTarget
101
+ ? "The update target was deleted; this proposal cannot overwrite it. Discuss a new create action."
102
+ : "Type **/confirm-revision** to approve against the current revision, or reply to revise. "+
103
+ "If another write occurs, you must review again.");
104
+ };
105
+ const show = (message = "", focusMessage = false) => {
106
+ const conversation = transcript.length
107
+ ? formatBtwTranscript(transcript.map(t=>({question:t.user,
108
+ answer:t.bro.startsWith('{"kind":"proposal"') ? "Bro proposed memory wording; review the current draft below." : t.bro}))) : "";
109
+ // Put proposal last: scrolling to end exposes the exact text being approved.
110
+ const questionLinks = relatedQuestionIds.length ? "Related memory IDs: "+relatedQuestionIds.join(", ") : "";
111
+ const relevant = message||browserText;
112
+ const content = (focusMessage
113
+ ? [header(), conversation, questionLinks, proposalText(), staleText(), relevant]
114
+ : proposal?.staleReview
115
+ ? [header(), conversation, questionLinks, relevant, proposalText(), staleText()]
116
+ : [header(), conversation, questionLinks, relevant, proposalText()]
117
+ ).filter(Boolean).join("\n\n---\n\n");
118
+ modal.setText(content);
119
+ modal.scrollToEnd();
120
+ };
121
+ const refresh = async (offset=0, search="") => {
122
+ const latest = await readMemoryStore();
123
+ store=latest; page=Math.max(0,offset); query=search;
124
+ // No proposal mutation on list/search/refresh. A stale proposal retains its reviewedRevision.
125
+ browserText=renderUserMemory(store,{query,offset:page,limit:20});
126
+ show("", true);
127
+ };
128
+ const save = async (reapproved = false) => {
129
+ if (!proposal || busy) { modal.setNotice("No proposal to save.", "warning"); return; }
130
+ if (!proposalReadyToSave(proposal)) {
131
+ show();
132
+ modal.setNotice("Bro is awaiting clarification. The old draft may be disputed; answer the question and review a revised proposal before saving.", "warning");
133
+ return;
134
+ }
135
+ busy = true; modal.setRunning(true);
136
+ try {
137
+ const latest=await readMemoryStore();
138
+ store=latest;
139
+ if (reapproved) {
140
+ const pending=proposal.staleReview;
141
+ if (!pending) { modal.setNotice("No changed revision under review. Use /save.", "warning"); return; }
142
+ if (pending.revision!==latest.revision) {
143
+ proposal.staleReview=staleProposalReview(proposal,latest);
144
+ browserText=""; show();
145
+ modal.setNotice("Store changed again. Compare and type /confirm-revision again.", "warning");
146
+ return;
147
+ }
148
+ } else if (latest.revision!==proposal.reviewedRevision) {
149
+ proposal.staleReview=staleProposalReview(proposal,latest);
150
+ browserText=""; show();
151
+ modal.setNotice("Store changed. Review original and current entries before /confirm-revision.", "warning");
152
+ return;
153
+ }
154
+ const expectedRevision=reapproved ? proposal.staleReview!.revision : proposal.reviewedRevision;
155
+ if (proposal.action==="update" && !latest.entries.some(m=>m.id===proposal!.targetId)) {
156
+ modal.setNotice("Update target was deleted. No write occurred; discuss creating a new entry.", "error");
157
+ return;
158
+ }
159
+ const id=proposal.targetId;
160
+ store=await changeMemory({kind:"save",draft:proposal.draft,...(id ? {id} : {})},expectedRevision);
161
+ const savedId=id??store.entries.at(-1)?.id??"";
162
+ proposal=undefined; transcript=[]; relatedQuestionIds=[]; browserText=renderUserMemory(store);
163
+ show(); modal.setNotice("Saved "+savedId+" exactly as approved.", "success");
164
+ } catch(e) { modal.setNotice(errorMessage(e), "error"); }
165
+ finally { busy=false; if(!closed)modal.setRunning(false); }
166
+ };
167
+ const ask = async (input:string) => {
168
+ if (busy||closed) return;
169
+ // A correction immediately invalidates approval, even if this model request fails.
170
+ markProposalClarifying(proposal);
171
+ busy=true;
172
+ const attempt=new AbortController();
173
+ controller=attempt; modal.setRunning(true);
174
+ try {
175
+ store=await readMemoryStore();
176
+ const [settings,preferences]=await Promise.all([readSettings(),readPreferences()]);
177
+ const selection=selectionForCapability(settings,"btw");
178
+ selectedBackend=selection.backend??"agy";
179
+ selectedModel=selectionLabel(selection);
180
+ const prompt=buildRememberPrompt({
181
+ statement:input, context:contextEnabled?capturedContext:undefined,
182
+ history:transcript, memories:store.entries, proposal, preferences
183
+ });
184
+ const pending:RememberTurn={user:input,bro:"Drafting…"};
185
+ transcript.push(pending); show();
186
+ const result=await executeBackend({
187
+ feature:"explain",access:"restricted",cwd:ctx.cwd,prompt
188
+ },selection,attempt.signal);
189
+ if(closed||attempt.signal.aborted)return;
190
+ if(result.status!=="success")throw new Error(result.message);
191
+ const parsed=parseRememberResult(result.text);
192
+ if(parsed.kind==="question") {
193
+ relatedQuestionIds=parsed.relatedIds;
194
+ pending.bro=parsed.message;
195
+ // Keep the disputed proposal for reference, but its approvalState remains clarifying.
196
+ } else {
197
+ proposal=makeReviewedProposal(parsed,store.revision,store.entries);
198
+ relatedQuestionIds=[];
199
+ // Retain exact substantive model proposal as part of the conversation history.
200
+ pending.bro=JSON.stringify(parsed);
201
+ }
202
+ browserText="";
203
+ show();
204
+ modal.setNotice(parsed.kind==="question"
205
+ ? "Answer the question; your prior proposal remains available."
206
+ : "Review the full proposal at the bottom. /save approves, or reply to refine.", "info");
207
+ } catch(e) {
208
+ if(!closed&&!attempt.signal.aborted) {
209
+ if(transcript.at(-1)?.user===input&&transcript.at(-1)?.bro==="Drafting…") transcript.pop();
210
+ modal.setNotice(errorMessage(e), "error");
211
+ if(!modal.getComposerText().trim())modal.setComposerText(input);
212
+ show();
213
+ }
214
+ } finally {
215
+ if(controller===attempt)controller=undefined;
216
+ busy=false; if(!closed)modal.setRunning(false);
217
+ }
218
+ };
219
+ function submit(raw:string):void {
220
+ const value=raw.trim();
221
+ if(!value||closed||busy)return;
222
+ modal.clearComposer();
223
+ if(confirmingDelete) {
224
+ const id=confirmingDelete; confirmingDelete=undefined;
225
+ if(value.toLowerCase()!=="yes"){modal.setNotice("Deletion cancelled.");return;}
226
+ busy=true;modal.setRunning(true);
227
+ void changeMemory({kind:"delete",id},store.revision).then(next=>{
228
+ store=next; browserText=renderUserMemory(store);show();modal.setNotice("Deleted "+id,"success");
229
+ }).catch(e=>modal.setNotice(errorMessage(e),"error")).finally(()=>{
230
+ busy=false;if(!closed)modal.setRunning(false);
231
+ });
232
+ return;
233
+ }
234
+ if(value==="/save"){void save();return;}
235
+ if(value==="/confirm-revision"){void save(true);return;}
236
+ if(value==="/help"){show(HELP, true);return;}
237
+ if(value==="/context show"){
238
+ show(capturedContext ? "## Captured Pi context (not transmitted by inspection)\n\n"+capturedContext : "No Pi context captured.", true);
239
+ return;
240
+ }
241
+ if(value==="/context off"||value==="/context on"){
242
+ contextEnabled=value.endsWith("on");show();modal.setNotice("Recent Pi context "+(contextEnabled?"included":"omitted")+" on future requests.");return;
243
+ }
244
+ if(value==="/new"){
245
+ proposal=undefined;transcript=[];relatedQuestionIds=[];browserText="";show();
246
+ modal.setNotice("Discarded unfinished draft. Saved memories are unchanged.");return;
247
+ }
248
+ if(value==="/list"||value.startsWith("/list ")){
249
+ const token=value.slice(5).trim();
250
+ if(token==="all") { void refresh(0,"").catch(e=>modal.setNotice(errorMessage(e),"error"));return; }
251
+ const pageNumber=token?Number(token):1;
252
+ if(!Number.isSafeInteger(pageNumber)||pageNumber<1){modal.setNotice("Use /list [page number] or /list all.", "warning");return;}
253
+ void refresh((pageNumber-1)*20,query).catch(e=>modal.setNotice(errorMessage(e),"error"));return;
254
+ }
255
+ if(value.startsWith("/search ")){
256
+ void refresh(0,value.slice(8).trim()).catch(e=>modal.setNotice(errorMessage(e),"error"));return;
257
+ }
258
+ if(value.startsWith("/expand ")){
259
+ const id=value.slice(8).trim();
260
+ show(renderUserMemory(store,{expand:[id]}), true);return;
261
+ }
262
+ if(value.startsWith("/edit ")){
263
+ if(proposal){modal.setNotice("Finish or /new the current proposal before starting a different edit.","warning");return;}
264
+ const id=value.slice(6).trim();
265
+ const target=store.entries.find(m=>m.id===id);
266
+ if(!target){modal.setNotice("Unknown memory ID. Use /list.","warning");return;}
267
+ proposal={
268
+ action:"update",targetId:id,reviewedRevision:store.revision,approvalState:"ready",reviewedTarget:target,
269
+ draft:{title:target.title,summary:target.summary,background:target.background,
270
+ scope:target.scope,exceptions:target.exceptions,sourceContext:target.sourceContext,
271
+ uncertainty:target.uncertainty,basis:target.basis},relatedIds:[id]
272
+ };
273
+ transcript=[];browserText="";show("Editing "+target.title+". Correct it in plain words, or /save the exact existing text.");return;
274
+ }
275
+ if(value.startsWith("/delete ")){
276
+ const id=value.slice(8).trim();
277
+ if(!store.entries.some(m=>m.id===id)){modal.setNotice("Unknown ID. Use /list.","warning");return;}
278
+ confirmingDelete=id;
279
+ modal.setNotice("Delete "+id+"? Type yes; any other answer cancels.","warning");return;
280
+ }
281
+ if(value.startsWith("/")){modal.setNotice("Unknown command. /help shows the available controls.","warning");return;}
282
+ void ask(value);
283
+ }
284
+ // Disclosure happens before any prompt. An initial command thought only pre-fills the composer.
285
+ modal.setModel(selectedModel);modal.setCustomMode("private draft · explicit save");
286
+ browserText=renderUserMemory(store);
287
+ show();
288
+ if(initial.trim()){
289
+ modal.setComposerText(initial.trim());
290
+ modal.setNotice("Review the backend and /context controls above, then press Enter to start.", "info");
291
+ }
292
+ return modal;
293
+ },{mouse:true});
294
+ }
@@ -0,0 +1,37 @@
1
+ /** Pure command parser for the one-source-at-a-time Simplify modal. */
2
+ export type SimplifyAction =
3
+ | { kind: "latest" | "copy" | "clear" | "help" | "retry" }
4
+ | { kind: "url" | "text" | "file"; input: string }
5
+ | { kind: "invalid"; message: string };
6
+
7
+ export function parseSimplifyAction(input: string): SimplifyAction {
8
+ const trimmed = input.trim();
9
+ if (!trimmed) return { kind:"invalid", message:"Enter /simplify, /url, /text, /file, /copy, /clear or /help." };
10
+ const i=trimmed.indexOf(" ");
11
+ const cmd=(i<0?trimmed:trimmed.slice(0,i)).toLowerCase();
12
+ const value=i<0?"":trimmed.slice(i+1).trim();
13
+ if (["/simplify","/copy","/clear","/help","/retry"].includes(cmd)) {
14
+ if(value) return {kind:"invalid",message:cmd+" takes no arguments."};
15
+ return {kind:({"/simplify":"latest","/copy":"copy","/clear":"clear","/help":"help","/retry":"retry"} as Record<string,"latest"|"copy"|"clear"|"help"|"retry">)[cmd]!};
16
+ }
17
+ for (const kind of ["url","text","file"] as const) {
18
+ if(cmd==="/"+kind) return value ? {kind,input:value} : {kind:"invalid",message:"Use /"+kind+" <"+(kind==="url"?"url":kind==="file"?"path":"text")+">."};
19
+ }
20
+ return {kind:"invalid",message:"Unknown Simplify command. Use /help. Ordinary chat belongs to /bro btw."};
21
+ }
22
+
23
+ export const SIMPLIFY_MODAL_HELP = [
24
+ "## Simplify · Commands",
25
+ "",
26
+ "Bro explains one source in plain language. Every new explanation replaces the last one. This is not a chat.",
27
+ "",
28
+ "- \`/simplify\` — explain the latest completed assistant reply",
29
+ "- \`/url <url>\` — explain a public webpage",
30
+ "- \`/text <text>\` — explain pasted text",
31
+ "- \`/file <path>\` — explain a workspace document",
32
+ "- \`/copy\` — copy the current explanation",
33
+ "- \`/clear\` — clear the explanation and source",
34
+ "- \`/help\` — show these commands",
35
+ "",
36
+ "With an empty composer, **M** changes explanation mode and re-explains the captured source; **R** retries. Both keep the backend configured for Explain. **Esc** closes; /bro simplify reopens this view in the current Pi session.",
37
+ ].join("\n");