@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.
- package/AGENTS.md +22 -3
- package/CHANGELOG.md +56 -0
- package/README.md +18 -2
- package/docs/ARCHITECTURE.md +58 -7
- package/docs/REFERENCE.md +125 -3
- package/docs/TELEMETRY.md +8 -0
- package/package.json +2 -2
- package/src/core/config.ts +1 -1
- package/src/core/settings.ts +251 -0
- package/src/engine/adapter.ts +1 -1
- package/src/engine/async.ts +53 -13
- package/src/engine/child.ts +1 -1
- package/src/engine/errors.ts +1 -0
- package/src/engine/inproc.ts +3 -3
- package/src/engine/stream.ts +20 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/hooks.ts +47 -12
- package/src/extension/persona-model-settings.ts +318 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension.ts +257 -50
- package/src/orchestration/sdk.ts +2 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/controller.ts +16 -3
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +7 -5
- package/src/tools/delegate-tool.ts +6 -6
- package/src/tools/delegate.ts +3 -1
- package/src/tools/exocom.ts +14 -1
- package/src/ui/persona-settings.ts +322 -0
- package/src/persona/state.ts +0 -28
package/src/extension/hooks.ts
CHANGED
|
@@ -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 {
|
|
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)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
+
}
|