pi-bro 0.26.0 → 0.27.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,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
+ }