@aeondave/pi-persona 1.16.3 → 1.17.1

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.
@@ -61,7 +61,7 @@ export interface HookHost {
61
61
  get telemetry(): TelemetryProducer | undefined;
62
62
  set telemetry(value: TelemetryProducer | undefined);
63
63
  readonly telemetryToolStartedAt: Map<string, { startedAt: number; name: string }>;
64
- readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number }>>;
64
+ readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number; replyTo?: string }>>;
65
65
  readonly telemetryIntercomPending: Map<string, { kind: string; to: string; size: number; replyTo?: string }>;
66
66
  readonly lastAgentProjection: Map<string, string>;
67
67
  readonly telemetryAskSenders: Map<string, string>;
@@ -306,6 +306,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
306
306
  status: "failed",
307
307
  expectsReply: false,
308
308
  size: message.size,
309
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
309
310
  });
310
311
  }
311
312
  h.telemetryExocomPending.clear();
@@ -557,6 +558,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
557
558
  if (event.toolName === "exocom_send") {
558
559
  const text = typeof input.message === "string" ? input.message : "";
559
560
  const target = typeof input.target === "string" ? input.target : "";
561
+ const replyTo = typeof input.in_reply_to === "string" && input.in_reply_to.length > 0 ? input.in_reply_to : undefined;
560
562
  let peers: DisplayPeer[] = [];
561
563
  try { peers = exocom.plane?.listPeers() ?? []; } catch { /* h.telemetry cannot break the tool */ }
562
564
  const sessions = canonicalExocomTelemetryTargets(peers, target);
@@ -566,6 +568,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
566
568
  sessionId,
567
569
  target: target === "*" ? (peers.find((peer) => peer.session_id === sessionId)?.target ?? sessionId) : target,
568
570
  size,
571
+ ...(replyTo ? { replyTo } : {}),
569
572
  }));
570
573
  if (pending.length > 0) h.telemetryExocomPending.set(event.toolCallId, pending);
571
574
  for (const message of pending) h.telemetry?.publish("message.sent", {
@@ -577,6 +580,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
577
580
  status: "queued",
578
581
  expectsReply: false,
579
582
  size: message.size,
583
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
580
584
  });
581
585
  }
582
586
  return undefined;
@@ -606,8 +610,24 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
606
610
  const pending = h.telemetryExocomPending.get(event.toolCallId) ?? [];
607
611
  h.telemetryExocomPending.delete(event.toolCallId);
608
612
  const details = event.details && typeof event.details === "object" && !Array.isArray(event.details)
609
- ? event.details as { failed?: Array<{ target?: unknown }>; failedCount?: unknown; omittedFailures?: unknown }
613
+ ? event.details as {
614
+ target?: unknown;
615
+ msg_id?: unknown;
616
+ sent?: unknown;
617
+ failed?: Array<{ target?: unknown }>;
618
+ failedCount?: unknown;
619
+ omittedFailures?: unknown;
620
+ }
610
621
  : undefined;
622
+ const receipts = Array.isArray(details?.sent)
623
+ ? details.sent.flatMap((receipt) => {
624
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return [];
625
+ const item = receipt as { target?: unknown; msg_id?: unknown };
626
+ return typeof item.target === "string" && typeof item.msg_id === "string" && item.msg_id.length > 0
627
+ ? [{ target: item.target, msgId: item.msg_id }]
628
+ : [];
629
+ })
630
+ : [];
611
631
  const sampledFailures = details?.failed ?? [];
612
632
  const failedTargets = new Set(sampledFailures.flatMap((failure) => typeof failure.target === "string" ? [failure.target] : []));
613
633
  // A broadcast samples its failures (`MAX_BROADCAST_DETAIL_ITEMS` in tools/exocom.ts) and
@@ -618,16 +638,31 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
618
638
  typeof details?.omittedFailures === "number" ? details.omittedFailures : 0,
619
639
  declaredFailures - sampledFailures.length,
620
640
  );
621
- for (const message of pending) h.telemetry?.publish("message.sent", {
622
- id: message.id,
623
- channel: "exocom",
624
- from: exocom.telemetrySessionId(ctx),
625
- to: message.sessionId,
626
- kind: "message",
627
- status: event.isError || failedTargets.has(message.target) ? "failed" : omittedFailures > 0 ? "unknown" : "delivered",
628
- expectsReply: false,
629
- size: message.size,
630
- });
641
+ for (const message of pending) {
642
+ const matchingReceipts = !event.isError && details?.target === "*"
643
+ ? receipts.filter((receipt) => receipt.target === message.target)
644
+ : [];
645
+ const failed = event.isError || failedTargets.has(message.target);
646
+ const wireId = failed ? undefined
647
+ : details?.target === message.target && typeof details.msg_id === "string" && details.msg_id.length > 0
648
+ ? details.msg_id
649
+ : matchingReceipts.length === 1 ? matchingReceipts[0]!.msgId : undefined;
650
+ h.telemetry?.publish("message.sent", {
651
+ id: message.id,
652
+ channel: "exocom",
653
+ from: exocom.telemetrySessionId(ctx),
654
+ to: message.sessionId,
655
+ kind: "message",
656
+ // A matching successful ACK receipt settles this recipient even when the bounded
657
+ // broadcast failure sample omitted failures for other peers. Without a receipt, an
658
+ // incomplete failure sample cannot prove this particular row was delivered.
659
+ status: failed ? "failed" : omittedFailures > 0 && wireId === undefined ? "unknown" : "delivered",
660
+ expectsReply: false,
661
+ size: message.size,
662
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
663
+ ...(wireId ? { wireId } : {}),
664
+ });
665
+ }
631
666
  }
632
667
  if (event.toolName === "intercom") {
633
668
  const action = typeof telemetryInput.action === "string" ? telemetryInput.action : "";
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Interactive editor for per-persona model assignments. This module owns only the settings flow;
3
+ * the extension supplies discovery, the existing participant resolver, and the canonical store.
4
+ */
5
+
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+
8
+ import type { ModelParticipant } from "../persona/model-participants.ts";
9
+ import { compactInlineText } from "../ui/presentation.ts";
10
+ import { ModelPicker, orderModelRefs } from "../ui/model-picker.ts";
11
+
12
+ export interface PersonaModelOwner {
13
+ name: string;
14
+ label: string;
15
+ }
16
+
17
+ export interface PersonaModelProfile {
18
+ name: string;
19
+ label: string;
20
+ }
21
+
22
+ export interface PersonaModelSettingsDeps {
23
+ /** Persona/profile whose saved assignment scope the user is editing. */
24
+ owners: () => readonly PersonaModelOwner[];
25
+ /** Current active persona, used only to mark the first owner option. */
26
+ activeOwner: () => string | undefined;
27
+ /** Named council profiles this owner may inspect; undefined means the owner's own profile. */
28
+ profiles: (owner: string) => readonly PersonaModelProfile[];
29
+ /** Existing participant collector/resolver, including roster and auxiliary actors. */
30
+ participants: (owner: string, profile: string | undefined) => readonly ModelParticipant[];
31
+ /** Current saved assignment map for this owner. */
32
+ saved: (owner: string) => Readonly<Record<string, string>>;
33
+ /** Applies a key patch; null means delete that exact saved assignment. */
34
+ write: (owner: string, patch: Record<string, string | null>) => void;
35
+ /** Session/persona liveness guard supplied by the extension. */
36
+ isCurrent?: (ctx: ExtensionContext) => boolean;
37
+ }
38
+
39
+ const DONE = "Done";
40
+ const BACK = "Back";
41
+ const REFRESH = "Refresh available model catalog";
42
+
43
+ /**
44
+ * Edit model assignments for a chosen owner without activating it. All changes are staged until
45
+ * the user explicitly chooses Done; canceling any dialog or changing sessions drops the draft.
46
+ */
47
+ export async function openPersonaModelSettings(ctx: ExtensionContext, deps: PersonaModelSettingsDeps): Promise<void> {
48
+ if (!ctx.hasUI) {
49
+ ctx.ui.notify("Persona model settings require an interactive TUI or RPC UI; no assignments changed.", "warning");
50
+ return;
51
+ }
52
+
53
+ const owners = deps.owners();
54
+ if (owners.length === 0) {
55
+ ctx.ui.notify("No personas are installed yet. Run /persona seed, then reopen persona settings.", "info");
56
+ return;
57
+ }
58
+
59
+ const isCurrent = (): boolean => !ctx.signal?.aborted && (deps.isCurrent?.(ctx) ?? true);
60
+ const activeOwner = deps.activeOwner();
61
+ const orderedOwners = [...owners].sort((left, right) => Number(right.name === activeOwner) - Number(left.name === activeOwner));
62
+ const ownerOptions = orderedOwners.map((owner, index) => {
63
+ const active = owner.name === activeOwner ? " · active" : "";
64
+ return `${index + 1}. ${safeInline(owner.label, 70)} (${safeInline(owner.name, 48)})${active}`;
65
+ });
66
+ const ownerChoice = await select(ctx, "Persona model owner", ownerOptions);
67
+ if (ownerChoice === undefined) return;
68
+ if (!isCurrent()) return sessionChanged(ctx);
69
+ const ownerIndex = ownerOptions.indexOf(ownerChoice);
70
+ const owner = orderedOwners[ownerIndex];
71
+ if (!owner) return;
72
+
73
+ const profiles = deps.profiles(owner.name);
74
+ const profileChoices: Array<{ profile: string | undefined; option: string }> = [
75
+ { profile: undefined, option: `1. Current profile (${safeInline(owner.name, 48)})` },
76
+ ...profiles.map((profile, index) => ({
77
+ profile: profile.name,
78
+ option: `${index + 2}. ${safeInline(profile.label, 70)} (${safeInline(profile.name, 48)})`,
79
+ })),
80
+ ];
81
+ const profileChoice = await select(ctx, "Council profile", profileChoices.map((entry) => entry.option));
82
+ if (profileChoice === undefined) return;
83
+ if (!isCurrent()) return sessionChanged(ctx);
84
+ const selectedProfile = profileChoices.find((entry) => entry.option === profileChoice);
85
+ if (!selectedProfile) return;
86
+
87
+ const profile = selectedProfile.profile;
88
+ const staged: Record<string, string | null> = {};
89
+ const getParticipants = (): readonly ModelParticipant[] => deps.participants(owner.name, profile);
90
+
91
+ for (;;) {
92
+ if (!isCurrent()) return sessionChanged(ctx);
93
+ let participants: readonly ModelParticipant[];
94
+ try {
95
+ participants = getParticipants();
96
+ } catch (error) {
97
+ if (!isCurrent()) return sessionChanged(ctx);
98
+ ctx.ui.notify(`Could not resolve model participants (${safeInline(errorMessage(error), 160)}); no assignments were written.`, "error");
99
+ return;
100
+ }
101
+ const saved = deps.saved(owner.name);
102
+ const knownKeys = new Set<string>();
103
+ for (const participant of participants) {
104
+ knownKeys.add(participant.key);
105
+ knownKeys.add(participant.agent);
106
+ }
107
+ const staleKeys = Object.keys(saved).filter((key) => !knownKeys.has(key) && staged[key] === undefined);
108
+ const rows = participants.map((participant, index) => participantRow(participant, saved, staged, index));
109
+ const otherAssignmentsOption = staleKeys.length > 0 ? `Manage other saved assignments (${staleKeys.length})…` : undefined;
110
+ const mainOptions = [...rows.map((row) => row.option), ...(otherAssignmentsOption ? [otherAssignmentsOption] : []), REFRESH, DONE];
111
+ const choice = await select(ctx, `Model settings · ${safeInline(owner.label, 48)}${profile ? ` · ${safeInline(profile, 40)}` : " · current profile"}`, mainOptions);
112
+ if (choice === undefined) return; // cancel discards the entire staged patch
113
+ if (!isCurrent()) return sessionChanged(ctx);
114
+ if (choice === DONE) {
115
+ const patch = Object.fromEntries(Object.entries(staged));
116
+ if (Object.keys(patch).length === 0) return;
117
+ try {
118
+ if (!isCurrent()) return sessionChanged(ctx);
119
+ deps.write(owner.name, patch);
120
+ ctx.ui.notify(`Saved ${Object.keys(patch).length} model assignment change(s) for "${safeInline(owner.name, 48)}"; applies to future runs.`, "info");
121
+ } catch (error) {
122
+ ctx.ui.notify(`Could not save persona model settings (${safeInline(errorMessage(error), 180)}); previous assignments were kept.`, "error");
123
+ }
124
+ return;
125
+ }
126
+ if (choice === REFRESH) {
127
+ if (!(await refreshCatalog(ctx, isCurrent))) return;
128
+ continue;
129
+ }
130
+ if (otherAssignmentsOption && choice === otherAssignmentsOption) {
131
+ if (staleKeys.length === 0) continue;
132
+ const staleOptions = staleKeys.map((key, index) => `${index + 1}. ${displayAssignmentKey(key, index)} · ${safeInline(saved[key] ?? "", 90)}`);
133
+ const staleChoice = await select(ctx, "Other saved assignment", staleOptions);
134
+ if (staleChoice === undefined) return;
135
+ if (!isCurrent()) return sessionChanged(ctx);
136
+ const index = staleOptions.indexOf(staleChoice);
137
+ const key = staleKeys[index];
138
+ if (key !== undefined) staged[key] = null;
139
+ continue;
140
+ }
141
+
142
+ const rowIndex = rows.findIndex((row) => row.option === choice);
143
+ const participant = participants[rowIndex];
144
+ if (!participant) continue;
145
+ if (participant.source === "inline") {
146
+ ctx.ui.notify(`"${safeInline(participant.label, 80)}" is pinned inline and is read-only here.`, "info");
147
+ continue;
148
+ }
149
+
150
+ const directExists = effectiveSavedValue(participant.key, saved, staged) !== undefined;
151
+ const sharedExists = participant.key !== participant.agent && effectiveSavedValue(participant.agent, saved, staged) !== undefined;
152
+ const actions = [`Set or change model for "${safeInline(participant.label, 80)}"`];
153
+ if (directExists) actions.push("Reset this participant's saved assignment");
154
+ if (sharedExists) actions.push(`Reset shared agent assignment (${safeInline(participant.agent, 48)})`);
155
+ actions.push(BACK);
156
+ const action = await select(ctx, `Edit participant · ${safeInline(participant.label, 80)}`, actions);
157
+ if (action === undefined) return;
158
+ if (!isCurrent()) return sessionChanged(ctx);
159
+ if (action === BACK) continue;
160
+ if (action === "Reset this participant's saved assignment") {
161
+ staged[participant.key] = null;
162
+ continue;
163
+ }
164
+ if (action.startsWith("Reset shared agent assignment")) {
165
+ staged[participant.agent] = null;
166
+ continue;
167
+ }
168
+ if (action !== actions[0]) continue;
169
+
170
+ const modelRefs = availableModelRefs(ctx);
171
+ if (modelRefs.length === 0) {
172
+ ctx.ui.notify("No authenticated models are available. Use /login, then choose Set or change again; saved assignments were kept.", "warning");
173
+ continue;
174
+ }
175
+ const modelTitle = compactInlineText(
176
+ `Model for ${participant.label} · current: ${participant.model ?? "none"} (${participant.source})`,
177
+ { maxChars: 180 },
178
+ );
179
+ let modelChoice: string | undefined;
180
+ try {
181
+ modelChoice = ctx.mode === "tui"
182
+ ? await ctx.ui.custom<string | undefined>(
183
+ (tui, theme, _keybindings, done) => new ModelPicker(tui, theme, modelTitle, modelRefs, ctx.model?.provider, done),
184
+ { overlay: false },
185
+ )
186
+ : await select(ctx, modelTitle, modelRefs);
187
+ } catch {
188
+ return; // a dismissed/broken picker must not commit partial edits
189
+ }
190
+ if (modelChoice === undefined) return;
191
+ if (!isCurrent()) return sessionChanged(ctx);
192
+ if (modelRefs.includes(modelChoice)) staged[participant.key] = modelChoice;
193
+ else return; // treat an unknown provider/model choice as a cancelled dialog
194
+ }
195
+ }
196
+
197
+ function participantRow(
198
+ participant: ModelParticipant,
199
+ saved: Readonly<Record<string, string>>,
200
+ staged: Readonly<Record<string, string | null>>,
201
+ index: number,
202
+ ): { option: string } {
203
+ const hasPendingDirect = Object.prototype.hasOwnProperty.call(staged, participant.key);
204
+ const pendingDirect = hasPendingDirect ? staged[participant.key] : undefined;
205
+ const hasPendingShared = participant.key !== participant.agent && Object.prototype.hasOwnProperty.call(staged, participant.agent);
206
+ const pendingShared = hasPendingShared ? staged[participant.agent] : undefined;
207
+ const directSaved = effectiveSavedValue(participant.key, saved, staged);
208
+ const sharedSaved = participant.key !== participant.agent ? effectiveSavedValue(participant.agent, saved, staged) : undefined;
209
+ let model: string;
210
+ let source: string;
211
+ if (typeof pendingDirect === "string") {
212
+ model = pendingDirect;
213
+ source = "assignment (pending)";
214
+ } else if (pendingDirect === null) {
215
+ if (sharedSaved) {
216
+ model = sharedSaved;
217
+ source = "shared assignment (pending fallback)";
218
+ } else {
219
+ model = "resolve lower precedence next run";
220
+ source = "pending reset";
221
+ }
222
+ } else if (hasPendingShared && pendingShared === null && !directSaved) {
223
+ model = "resolve lower precedence next run";
224
+ source = "shared reset pending";
225
+ } else {
226
+ model = participant.model ?? "none";
227
+ source = participant.source;
228
+ }
229
+ const sharedNote = !directSaved && sharedSaved
230
+ ? ` · shared ${safeInline(participant.agent, 40)}=${safeInline(sharedSaved, 70)}`
231
+ : hasPendingShared && pendingShared === null && directSaved
232
+ ? ` · shared ${safeInline(participant.agent, 40)} reset pending`
233
+ : "";
234
+ const pendingNote = hasPendingDirect && pendingDirect === null ? " · pending reset"
235
+ : hasPendingShared && pendingShared === null ? " · shared reset pending"
236
+ : "";
237
+ const option = `${index + 1}. ${safeInline(participant.label, 80)} · ${safeInline(model, 90)} (${safeInline(source, 24)})${sharedNote}${pendingNote}`;
238
+ return { option: compactInlineText(option, { maxChars: 200 }) };
239
+ }
240
+
241
+ function effectiveSavedValue(
242
+ key: string,
243
+ saved: Readonly<Record<string, string>>,
244
+ staged: Readonly<Record<string, string | null>>,
245
+ ): string | undefined {
246
+ if (Object.prototype.hasOwnProperty.call(staged, key)) return staged[key] ?? undefined;
247
+ return saved[key];
248
+ }
249
+
250
+ function availableModelRefs(ctx: ExtensionContext): string[] {
251
+ try {
252
+ const unique = new Set<string>();
253
+ for (const model of ctx.modelRegistry.getAvailable()) {
254
+ if (typeof model.provider !== "string" || !model.provider.trim() || typeof model.id !== "string" || !model.id.trim()) continue;
255
+ unique.add(`${model.provider}/${model.id}`);
256
+ }
257
+ return orderModelRefs([...unique], ctx.model?.provider);
258
+ } catch {
259
+ return [];
260
+ }
261
+ }
262
+
263
+ async function refreshCatalog(ctx: ExtensionContext, isCurrent: () => boolean): Promise<boolean> {
264
+ if (!ctx.modelRegistry.refresh) {
265
+ ctx.ui.notify("This Pi host cannot explicitly refresh the model catalog; existing assignments were kept.", "warning");
266
+ return true;
267
+ }
268
+ const deadline = AbortSignal.timeout(20_000);
269
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
270
+ try {
271
+ const result = await ctx.modelRegistry.refresh({ signal });
272
+ if (ctx.signal?.aborted) {
273
+ ctx.ui.notify("Model catalog refresh was cancelled; saved assignments were kept.", "info");
274
+ return false;
275
+ }
276
+ if (!isCurrent()) {
277
+ sessionChanged(ctx);
278
+ return false;
279
+ }
280
+ if (signal.aborted || result?.aborted) {
281
+ ctx.ui.notify("Model catalog refresh was cancelled or timed out; saved assignments were kept.", "warning");
282
+ return true;
283
+ }
284
+ if (result?.errors.size) {
285
+ const providers = compactInlineText([...result.errors.keys()].join(", "), { maxChars: 120 });
286
+ ctx.ui.notify(`Model catalog refresh incomplete for ${providers}; saved assignments were kept.`, "warning");
287
+ return true;
288
+ }
289
+ ctx.ui.notify("Model catalog refreshed. Choose a participant to edit; existing assignments are unchanged.", "info");
290
+ return true;
291
+ } catch (error) {
292
+ ctx.ui.notify(`Model catalog refresh failed (${safeInline(errorMessage(error), 150)}); saved assignments were kept.`, "warning");
293
+ return true;
294
+ }
295
+ }
296
+
297
+ async function select(ctx: ExtensionContext, title: string, options: string[]): Promise<string | undefined> {
298
+ return ctx.ui.select(title, options);
299
+ }
300
+
301
+ function displayAssignmentKey(key: string, index: number): string {
302
+ // Current keys are agent names or agent#32-hex-digest. Do not surface arbitrary legacy text that
303
+ // might contain an old role prompt; keep a safe stable-looking label for those records instead.
304
+ if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}(?:#[a-f0-9]{32})?$/.test(key)) return key;
305
+ return `legacy-key-${index + 1}`;
306
+ }
307
+
308
+ function safeInline(value: string, maxChars: number): string {
309
+ return compactInlineText(value, { maxChars });
310
+ }
311
+
312
+ function sessionChanged(ctx: ExtensionContext): void {
313
+ ctx.ui.notify("Persona model settings: the session changed, the command was cancelled, or the active persona changed; no staged assignments were written.", "warning");
314
+ }
315
+
316
+ function errorMessage(error: unknown): string {
317
+ return error instanceof Error ? error.message : String(error);
318
+ }