@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.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
21
21
|
import type { AgentConfig } from "./agents/agent.ts";
|
|
22
22
|
import { installBridge, isBridgeMode } from "./bridge.ts";
|
|
23
23
|
import { resolveConfig } from "./core/config.ts";
|
|
24
|
+
import { resolvePersonaConfig } from "./core/settings.ts";
|
|
24
25
|
import { type ContractDef, installedContractNames } from "./core/contract.ts";
|
|
25
26
|
import { type DataRootMigrationResult, migrateLegacyDataRoot } from "./core/data-root.ts";
|
|
26
27
|
import {
|
|
@@ -77,6 +78,9 @@ export {
|
|
|
77
78
|
} from "./extension/shared.ts";
|
|
78
79
|
import { installExocom } from "./exocom/install.ts";
|
|
79
80
|
import { installIdentity } from "./extension/identity.ts";
|
|
81
|
+
import { registerPersonaSettings } from "./extension/persona-settings.ts";
|
|
82
|
+
import { openPersonaModelSettings } from "./extension/persona-model-settings.ts";
|
|
83
|
+
import { collectCompanionSettings } from "./extension/companion-settings.ts";
|
|
80
84
|
import { parseExocomArgv } from "./exocom/activation.ts";
|
|
81
85
|
import { formatLedgerStatus } from "./exocom/status.ts";
|
|
82
86
|
import { registerDelegateTool } from "./tools/delegate-tool.ts";
|
|
@@ -87,7 +91,7 @@ import { createMonitorSession, type MonitorSession } from "./monitor/session.ts"
|
|
|
87
91
|
import { registerCouncilTool } from "./tools/council.ts";
|
|
88
92
|
import { registerFlowTool } from "./tools/flow.ts";
|
|
89
93
|
import { registerModelsTool } from "./tools/models.ts";
|
|
90
|
-
import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekAlert, buildPeekDigest, buildRetentionOverflowNote, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
|
|
94
|
+
import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekAlert, buildPeekDigest, buildRetentionOverflowNote, isAsyncRunStalled, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
|
|
91
95
|
import { emptyUsage, type ToolEvent } from "./engine/stream.ts";
|
|
92
96
|
import { brokerEndpoint } from "./bus/broker/paths.ts";
|
|
93
97
|
import { InProcessBus } from "./bus/inproc.ts";
|
|
@@ -108,17 +112,15 @@ import {
|
|
|
108
112
|
pendingParticipants,
|
|
109
113
|
type ModelParticipant,
|
|
110
114
|
} from "./persona/model-participants.ts";
|
|
111
|
-
import { expandCouncilPreset, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
|
|
115
|
+
import { expandCouncilPreset, resolveCouncilInvocation, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
|
|
112
116
|
import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
|
|
113
|
-
import {
|
|
117
|
+
import { migratePersonaSettings, patchPersonaSettings, readPersonaSettings, type PersonaSettings } from "./persona/settings-store.ts";
|
|
114
118
|
import { type AgentKind as TelemetryAgentKind, type AgentStatus as TelemetryAgentStatus } from "./telemetry/contract.ts";
|
|
115
119
|
import { TelemetryProducer, type TelemetryAgentInput } from "./telemetry/producer.ts";
|
|
116
120
|
import {
|
|
117
121
|
type PersonaConfigStore,
|
|
118
122
|
personaModels,
|
|
119
|
-
readPersonaConfigs,
|
|
120
123
|
withPersonaModels,
|
|
121
|
-
writePersonaConfigs,
|
|
122
124
|
} from "./persona/config-store.ts";
|
|
123
125
|
import { shortModel } from "./tools/delegate.ts";
|
|
124
126
|
import { formatInbox } from "./tools/intercom.ts";
|
|
@@ -183,6 +185,7 @@ interface NudgeEntry {
|
|
|
183
185
|
|
|
184
186
|
interface CheckInEntry {
|
|
185
187
|
content: string;
|
|
188
|
+
kind?: "routine" | "stall";
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
/**
|
|
@@ -279,7 +282,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
279
282
|
return;
|
|
280
283
|
}
|
|
281
284
|
|
|
282
|
-
|
|
285
|
+
let config = resolveConfig(process.env);
|
|
283
286
|
if (config.disabled) return;
|
|
284
287
|
const exocomArgs = parseExocomArgv(options.exocomArgv ?? process.argv);
|
|
285
288
|
const registerFollowUpRenderer = (customType: "pi-persona" | "exocom_received", label: string): void => {
|
|
@@ -342,7 +345,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
342
345
|
pi.registerEntryRenderer("pi-persona-status", (entry, { expanded }, theme) => {
|
|
343
346
|
const data = entry.data as CheckInEntry;
|
|
344
347
|
const full = sanitizeTerminalText(data.content || "Async activity · routine check-in");
|
|
345
|
-
const title = theme.fg("accent", theme.bold("pi-persona · async status"));
|
|
348
|
+
const title = theme.fg(data.kind === "stall" ? "warning" : "accent", theme.bold(data.kind === "stall" ? "pi-persona · possible stall" : "pi-persona · async status"));
|
|
346
349
|
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
|
|
347
350
|
// The entry title already labels this as async status. Spend the compact preview on the
|
|
348
351
|
// aggregate and first run row so the operator sees the selected alias and usage label.
|
|
@@ -355,8 +358,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
355
358
|
const appendNudgeEntry = (content: string): void => {
|
|
356
359
|
pi.appendEntry("pi-persona-nudge", { content } satisfies NudgeEntry);
|
|
357
360
|
};
|
|
358
|
-
const appendCheckInEntry = (content: string): void => {
|
|
359
|
-
pi.appendEntry("pi-persona-status", { content } satisfies CheckInEntry);
|
|
361
|
+
const appendCheckInEntry = (content: string, kind: "routine" | "stall" = "routine"): void => {
|
|
362
|
+
pi.appendEntry("pi-persona-status", { content, kind } satisfies CheckInEntry);
|
|
360
363
|
};
|
|
361
364
|
const appendCommandResult = (
|
|
362
365
|
label: string,
|
|
@@ -390,7 +393,37 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
390
393
|
return { moved: [], kept: [], warnings: [`data root migration failed: ${message}`] };
|
|
391
394
|
}
|
|
392
395
|
})();
|
|
396
|
+
// One persistent settings owner. The root migration must run before importing the two
|
|
397
|
+
// retired stores; fork-guarded children returned before any filesystem access above.
|
|
398
|
+
const settingsFile = join(personaDataDir(), "settings.json");
|
|
399
|
+
let savedSettings: PersonaSettings = { version: 1, preferences: {}, personas: {}, lastPersona: null };
|
|
400
|
+
const settingsWarnings: string[] = [];
|
|
401
|
+
const reportedSettingsWarnings = new Set<string>();
|
|
402
|
+
try {
|
|
403
|
+
const result = migratePersonaSettings(settingsFile, {
|
|
404
|
+
legacyConfigFile: join(personaDataDir(), "config.json"),
|
|
405
|
+
legacyStateFile: config.stateFile ?? join(personaDataDir(), "state.json"),
|
|
406
|
+
});
|
|
407
|
+
savedSettings = result.settings;
|
|
408
|
+
settingsWarnings.push(...result.warnings);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
settingsWarnings.push(`could not load settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
411
|
+
}
|
|
412
|
+
config = resolvePersonaConfig(savedSettings.preferences, process.env);
|
|
413
|
+
function reportSettingsWarning(ctx: ExtensionContext | undefined, message: string): void {
|
|
414
|
+
if (reportedSettingsWarnings.has(message)) return;
|
|
415
|
+
reportedSettingsWarnings.add(message);
|
|
416
|
+
const text = `pi-persona settings: ${compactInlineText(message, { maxChars: 500 })}`;
|
|
417
|
+
try {
|
|
418
|
+
if (ctx?.hasUI) ctx.ui.notify(text, "warning");
|
|
419
|
+
else process.stderr.write(`${text}\n`);
|
|
420
|
+
} catch {
|
|
421
|
+
// A failed UI must not turn recoverable configuration trouble into activation failure.
|
|
422
|
+
try { process.stderr.write(`${text}\n`); } catch { /* Non-fatal diagnostic. */ }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
393
425
|
const reportDataRootMigration = (ctx: ExtensionContext): void => {
|
|
426
|
+
for (const warning of settingsWarnings) reportSettingsWarning(ctx, warning);
|
|
394
427
|
if (dataRootMigration.moved.length > 0) {
|
|
395
428
|
const message = `pi-persona: moved ${dataRootMigration.moved.length} file(s) from the legacy pi-persona storage root into ${personaDataDir()}`;
|
|
396
429
|
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
@@ -549,7 +582,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
549
582
|
// rather than to a "subagent" literal that names nobody. Bounded; cleared on teardown.
|
|
550
583
|
const telemetryAskSenders = new Map<string, string>();
|
|
551
584
|
const MAX_TRACKED_ASK_SENDERS = 256;
|
|
552
|
-
type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number };
|
|
585
|
+
type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number; replyTo?: string };
|
|
553
586
|
const telemetryExocomPending = new Map<string, PendingExocomTelemetry[]>();
|
|
554
587
|
// …and the same for intercom: both channels open at tool_call and close at tool_result, so both are
|
|
555
588
|
// left hanging when Pi resolves a call as "immediate" and never reaches a result.
|
|
@@ -849,21 +882,36 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
849
882
|
let seedSourceCollisions: string[] = [];
|
|
850
883
|
|
|
851
884
|
// Remembered selection lives in the persona data folder; only user gestures write it.
|
|
852
|
-
const stateFile = config.stateFile ?? join(personaDataDir(), "state.json");
|
|
853
885
|
const readRememberedPersona = (): string | undefined => {
|
|
854
886
|
if (!config.persist) return undefined;
|
|
855
|
-
|
|
887
|
+
try {
|
|
888
|
+
savedSettings = readPersonaSettings(settingsFile);
|
|
889
|
+
return savedSettings.lastPersona ?? undefined;
|
|
890
|
+
} catch (error) {
|
|
891
|
+
reportSettingsWarning(lastCtx, `could not read remembered persona: ${error instanceof Error ? error.message : String(error)}`);
|
|
892
|
+
return undefined;
|
|
893
|
+
}
|
|
856
894
|
};
|
|
857
895
|
const persist = (name: string | undefined): void => {
|
|
858
|
-
if (config.persist)
|
|
896
|
+
if (!config.persist) return;
|
|
897
|
+
try {
|
|
898
|
+
savedSettings = patchPersonaSettings(settingsFile, { lastPersona: name ?? null });
|
|
899
|
+
} catch (error) {
|
|
900
|
+
reportSettingsWarning(lastCtx, `could not save remembered persona: ${error instanceof Error ? error.message : String(error)}`);
|
|
901
|
+
}
|
|
859
902
|
};
|
|
860
903
|
|
|
861
|
-
//
|
|
862
|
-
const configFile = join(personaDataDir(), "config.json");
|
|
904
|
+
// Model preferences share the canonical file with extension preferences and selection.
|
|
863
905
|
const readConfigStore = (): PersonaConfigStore => {
|
|
864
|
-
|
|
906
|
+
try {
|
|
907
|
+
savedSettings = readPersonaSettings(settingsFile);
|
|
908
|
+
return savedSettings.personas;
|
|
909
|
+
} catch (error) {
|
|
910
|
+
reportSettingsWarning(lastCtx, `could not read model settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
911
|
+
return savedSettings.personas;
|
|
912
|
+
}
|
|
865
913
|
};
|
|
866
|
-
let personaConfigs: PersonaConfigStore =
|
|
914
|
+
let personaConfigs: PersonaConfigStore = savedSettings.personas;
|
|
867
915
|
// A participant is asked for a model at most ONCE per session — dismissal included, so a
|
|
868
916
|
// closed picker never becomes a nag. Keyed by persona + assignment key, NOT by persona: a new
|
|
869
917
|
// roster (or a new strategy's arbiter) is a genuinely new participant and may ask the first time
|
|
@@ -1013,9 +1061,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1013
1061
|
try {
|
|
1014
1062
|
const all = lastCtx?.modelRegistry.getAll() ?? [];
|
|
1015
1063
|
const m = all.find((x) => x.provider === handle.provider && x.id === handle.id);
|
|
1016
|
-
|
|
1064
|
+
return m ? await pi.setModel(m) : false;
|
|
1017
1065
|
} catch {
|
|
1018
|
-
|
|
1066
|
+
return false; // keep current, but let the controller report that the switch failed
|
|
1019
1067
|
}
|
|
1020
1068
|
},
|
|
1021
1069
|
setStatus: (text) => {
|
|
@@ -1223,13 +1271,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1223
1271
|
return;
|
|
1224
1272
|
}
|
|
1225
1273
|
const now = Date.now();
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
lastPeekAt = now;
|
|
1274
|
+
const waitingRuns = waitingAsyncRunIds();
|
|
1275
|
+
const fast = config.peekEveryMs > 0;
|
|
1276
|
+
const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
|
|
1277
|
+
const appendStatus = (content: string, kind: "routine" | "stall"): void => {
|
|
1231
1278
|
try {
|
|
1232
|
-
appendCheckInEntry(
|
|
1279
|
+
appendCheckInEntry(content, kind);
|
|
1233
1280
|
} catch (error) {
|
|
1234
1281
|
const detail = compactInlineText(error instanceof Error ? error.message : String(error), { maxChars: 120 });
|
|
1235
1282
|
const warning = `pi-persona: could not append async status entry${detail ? ` (${detail})` : ""}`;
|
|
@@ -1241,27 +1288,30 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1241
1288
|
try { process.stderr.write(`[pi-persona] ${warning}\n`); } catch { /* diagnostics must not block an actionable wake */ }
|
|
1242
1289
|
}
|
|
1243
1290
|
}
|
|
1291
|
+
};
|
|
1292
|
+
// Every new stall episode is durable operator history, including while the model is busy.
|
|
1293
|
+
if (stuck.length > 0) appendStatus(buildCheckIn(stuck, { now, stallMs: STALL_FLAG_MS, reason: "stall", waitingForSupervisor: waitingRuns }), "stall");
|
|
1294
|
+
const dueCheckIn = config.checkInEveryMs > 0 && now - lastPeekAt >= config.checkInEveryMs;
|
|
1295
|
+
if (dueCheckIn) {
|
|
1296
|
+
lastPeekAt = now;
|
|
1297
|
+
appendStatus(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }), "routine");
|
|
1244
1298
|
}
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1299
|
+
// Interactive stalls are operator-only. Headless sessions may consume one model wake per
|
|
1300
|
+
// run lifetime; unread non-ask messages retain their existing actionable route.
|
|
1247
1301
|
if (!canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true) || lastCtx?.hasPendingMessages?.() === true) return;
|
|
1248
|
-
// The watchdog is not a model polling loop: the FAST path wakes on newly-stalled legs or unread
|
|
1249
|
-
// progress messages. Completions arrive separately through completionNotifier; `/peek` remains on demand.
|
|
1250
|
-
const fast = config.peekEveryMs > 0;
|
|
1251
|
-
const waitingRuns = waitingAsyncRunIds();
|
|
1252
|
-
const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
|
|
1253
|
-
// Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
|
|
1254
|
-
// notifier and left for the `intercom inbox` tool — so peek never double-shows them.
|
|
1255
1302
|
const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
|
|
1256
|
-
|
|
1303
|
+
const wakeCandidates = fast && lastCtx?.hasUI === false
|
|
1304
|
+
? peekWatcher.firstWakeCandidates(runs.filter((run) => isAsyncRunStalled(run, now, STALL_FLAG_MS)))
|
|
1305
|
+
: [];
|
|
1306
|
+
if (wakeCandidates.length === 0 && unread.length === 0) return;
|
|
1257
1307
|
const parts: string[] = [];
|
|
1258
|
-
if (
|
|
1308
|
+
if (wakeCandidates.length > 0) parts.push(buildPeekAlert(wakeCandidates, { now }));
|
|
1259
1309
|
if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
|
|
1260
|
-
if (stuck.length > 0) lastPeekAt = now; // message-only wakes do not postpone the routine status cadence
|
|
1261
1310
|
try {
|
|
1262
1311
|
sendPersonaFollowUp(pi, `[pi-persona] ${parts.join("\n\n")}`);
|
|
1312
|
+
peekWatcher.acknowledgeModelWake(wakeCandidates.map((run) => run.id));
|
|
1263
1313
|
} catch {
|
|
1264
|
-
|
|
1314
|
+
peekWatcher.retryReports(wakeCandidates);
|
|
1265
1315
|
}
|
|
1266
1316
|
}, tickMs);
|
|
1267
1317
|
peekTimer.unref?.();
|
|
@@ -1360,8 +1410,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1360
1410
|
* Every participant a run really spawns — roster members (inline ones included) plus the
|
|
1361
1411
|
* auxiliary actors its strategy declares — with the model each one resolves to.
|
|
1362
1412
|
*/
|
|
1363
|
-
function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }): ModelParticipant[] {
|
|
1364
|
-
const persona = controller.activePersona?.name;
|
|
1413
|
+
function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }, persona = controller.activePersona?.name): ModelParticipant[] {
|
|
1365
1414
|
return collectModelParticipants({
|
|
1366
1415
|
members: opts.members,
|
|
1367
1416
|
strategy: opts.strategy,
|
|
@@ -1383,16 +1432,56 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1383
1432
|
* authenticated model to choose between.
|
|
1384
1433
|
*/
|
|
1385
1434
|
async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
|
|
1386
|
-
const
|
|
1387
|
-
if (!
|
|
1388
|
-
const missing = pendingParticipants(participants, { prompted: promptedFor(persona, participants) });
|
|
1435
|
+
const scope = modelSelectionScope(ctx);
|
|
1436
|
+
if (!scope || !ctx.hasUI) return;
|
|
1437
|
+
const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
|
|
1389
1438
|
if (missing.length === 0) return;
|
|
1390
1439
|
const available = configuredModels(ctx);
|
|
1391
|
-
if (available.length < 2) return; //
|
|
1440
|
+
if (available.length < 2) return; // automatic setup cannot diversify a single model
|
|
1441
|
+
const chosen = await choosePersonaModels(ctx, missing, available, scope);
|
|
1442
|
+
if (chosen && Object.keys(chosen).length > 0) savePersonaModels(ctx, scope.persona, chosen);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
type ModelSelectionScope = { persona: string; isCurrent(): boolean };
|
|
1446
|
+
function modelSelectionScope(ctx: ExtensionContext): ModelSelectionScope | undefined {
|
|
1447
|
+
const active = controller.activePersona;
|
|
1448
|
+
if (!active) return undefined;
|
|
1449
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
1450
|
+
return { persona: active.name, isCurrent: () => !disposed && controller.activePersona === active &&
|
|
1451
|
+
(sessionId === undefined || lastCtx?.sessionManager?.getSessionId?.() === sessionId) };
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** An explicit user edit is allowed to replace defaults, unlike automatic first-run setup. */
|
|
1455
|
+
async function editPersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], scope: ModelSelectionScope): Promise<void> {
|
|
1456
|
+
const editable = participants.filter((p) => p.source !== "inline");
|
|
1457
|
+
if (editable.length === 0) {
|
|
1458
|
+
ctx.ui.notify("persona models: no editable council participants (inline model pins are unchanged).", "info");
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
// Trust fresh availability here, not the discovery union with provisional catalog entries.
|
|
1462
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
1463
|
+
if (available.length === 0) {
|
|
1464
|
+
ctx.ui.notify("persona models: no available models. Use /login, then retry /persona models.", "warning");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const chosen = await choosePersonaModels(ctx, editable, available, scope);
|
|
1468
|
+
if (!chosen) return;
|
|
1469
|
+
if (Object.keys(chosen).length === 0) {
|
|
1470
|
+
ctx.ui.notify("persona models: no model assignments changed; previous choices kept.", "info");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (savePersonaModels(ctx, scope.persona, chosen)) {
|
|
1474
|
+
ctx.ui.notify(`persona models: updated ${Object.keys(chosen).length} model assignment(s) for "${scope.persona}"; applies to future runs.`, "info");
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
|
|
1479
|
+
async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available: Array<{ provider: string; id: string }>, scope: ModelSelectionScope): Promise<Record<string, string> | undefined> {
|
|
1480
|
+
const { persona } = scope;
|
|
1392
1481
|
const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
|
|
1393
1482
|
const chosen: Record<string, string> = {};
|
|
1394
1483
|
try {
|
|
1395
|
-
for (const p of
|
|
1484
|
+
for (const p of participants) {
|
|
1396
1485
|
// Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
|
|
1397
1486
|
// for this session, exactly like an answer is not asked twice.
|
|
1398
1487
|
promptedParticipants.add(`${persona}\0${p.key}`);
|
|
@@ -1403,7 +1492,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1403
1492
|
const role = p.param ? ` · ${p.param}` : "";
|
|
1404
1493
|
// A participant label carries a role prompt (free prose from a team file or a
|
|
1405
1494
|
// caller) — sanitised here so a title can never repaint the TUI or forge a row.
|
|
1406
|
-
const
|
|
1495
|
+
const current = p.assigned && p.model ? ` · current: ${p.model}` : "";
|
|
1496
|
+
const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}${current}`, { maxChars: 200 });
|
|
1407
1497
|
// In the TUI: a searchable picker (type to filter) whose viewport follows the
|
|
1408
1498
|
// selection — the built-in select can't scroll a hundreds-long provider list
|
|
1409
1499
|
// usefully. Outside the TUI (RPC), fall back to the built-in select.
|
|
@@ -1415,24 +1505,38 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1415
1505
|
{ overlay: true },
|
|
1416
1506
|
)
|
|
1417
1507
|
: await ctx.ui.select(title, options);
|
|
1418
|
-
if (
|
|
1508
|
+
if (!scope.isCurrent()) {
|
|
1509
|
+
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (pick && options.includes(pick)) chosen[p.key] = pick;
|
|
1419
1513
|
}
|
|
1420
1514
|
} catch {
|
|
1421
1515
|
/* dismissed / no UI → fall back to the default model */
|
|
1422
1516
|
}
|
|
1423
|
-
if (
|
|
1517
|
+
if (!scope.isCurrent()) {
|
|
1518
|
+
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
return chosen;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function savePersonaModels(ctx: ExtensionContext, persona: string, chosen: Record<string, string>): boolean {
|
|
1424
1525
|
// The picks are live for this session either way (the in-memory store is updated first), so
|
|
1425
1526
|
// a save failure costs only PERSISTENCE — but silently swallowing it would re-prompt on every
|
|
1426
1527
|
// future session with no hint that the assignment never landed.
|
|
1427
1528
|
personaConfigs = withPersonaModels(personaConfigs, persona, chosen);
|
|
1428
1529
|
try {
|
|
1429
|
-
|
|
1530
|
+
savedSettings = patchPersonaSettings(settingsFile, { models: { [persona]: chosen } });
|
|
1531
|
+
personaConfigs = savedSettings.personas;
|
|
1430
1532
|
} catch (err) {
|
|
1431
1533
|
ctx.ui.notify(
|
|
1432
1534
|
`pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
|
|
1433
1535
|
"error",
|
|
1434
1536
|
);
|
|
1537
|
+
return false;
|
|
1435
1538
|
}
|
|
1539
|
+
return true;
|
|
1436
1540
|
}
|
|
1437
1541
|
|
|
1438
1542
|
/** A core's declared verticalization ("Propulsore", "Critico") — the lens it argues from, so a
|
|
@@ -1861,6 +1965,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1861
1965
|
function doctorReport(): string {
|
|
1862
1966
|
const lines: string[] = [];
|
|
1863
1967
|
lines.push(`pi-persona — active: ${controller.activePersona?.label ?? "none"}`);
|
|
1968
|
+
lines.push(`settings: ${settingsFile} (/persona-settings; preferences: next session or Pi /reload)`);
|
|
1864
1969
|
lines.push(`engine backend: ${config.engine === "child" ? "child-process" : "in-process"}`);
|
|
1865
1970
|
const piVersion = installedPiVersion();
|
|
1866
1971
|
lines.push(`pi: ${piVersion ?? "unknown"} (requires ≥ ${MIN_PI_VERSION})${piVersion && !satisfiesFloor(piVersion, MIN_PI_VERSION) ? " — BELOW FLOOR" : ""}`);
|
|
@@ -2137,12 +2242,114 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2137
2242
|
},
|
|
2138
2243
|
});
|
|
2139
2244
|
|
|
2245
|
+
// The dedicated settings surface never replaces Pi's /settings and never calls /reload.
|
|
2246
|
+
registerPersonaSettings(pi, {
|
|
2247
|
+
file: settingsFile,
|
|
2248
|
+
companions: () => collectCompanionSettings(pi.events),
|
|
2249
|
+
activeConfig: config,
|
|
2250
|
+
env: process.env,
|
|
2251
|
+
read: () => {
|
|
2252
|
+
savedSettings = readPersonaSettings(settingsFile);
|
|
2253
|
+
personaConfigs = savedSettings.personas;
|
|
2254
|
+
return savedSettings;
|
|
2255
|
+
},
|
|
2256
|
+
write: (patch) => {
|
|
2257
|
+
savedSettings = patchPersonaSettings(settingsFile, patch);
|
|
2258
|
+
personaConfigs = savedSettings.personas;
|
|
2259
|
+
},
|
|
2260
|
+
isCurrent: (ctx) => !disposed && !ctx.signal?.aborted &&
|
|
2261
|
+
(ctx.sessionManager?.getSessionId?.() === undefined ||
|
|
2262
|
+
lastCtx?.sessionManager?.getSessionId?.() === ctx.sessionManager.getSessionId()),
|
|
2263
|
+
diagnostics: doctorReport,
|
|
2264
|
+
models: async (ctx) => {
|
|
2265
|
+
lastCtx = ctx;
|
|
2266
|
+
personaConfigs = readConfigStore();
|
|
2267
|
+
const session = ctx.sessionManager?.getSessionId?.();
|
|
2268
|
+
await openPersonaModelSettings(ctx, {
|
|
2269
|
+
owners: () => personas.map((persona) => ({ name: persona.name, label: persona.label })),
|
|
2270
|
+
activeOwner: () => controller.activePersona?.name,
|
|
2271
|
+
profiles: (owner) => {
|
|
2272
|
+
const persona = personas.find((p) => p.name === owner);
|
|
2273
|
+
return [
|
|
2274
|
+
...(persona?.orchestration?.strategy ? [{ name: "@orchestration", label: "Automatic orchestration" }] : []),
|
|
2275
|
+
...personas.filter((p) => p.name !== owner && (p.council?.strategy || p.council?.members || p.council?.membersProblem))
|
|
2276
|
+
.map((p) => ({ name: p.name, label: `Council: ${p.label}` })),
|
|
2277
|
+
];
|
|
2278
|
+
},
|
|
2279
|
+
participants: (owner, profile) => {
|
|
2280
|
+
const persona = personas.find((p) => p.name === owner);
|
|
2281
|
+
if (!persona) throw new Error(`persona "${owner}" is no longer installed`);
|
|
2282
|
+
const orchestrationStrategy = persona.orchestration?.strategy;
|
|
2283
|
+
if (profile === "@orchestration" && orchestrationStrategy && persona.orchestration) {
|
|
2284
|
+
const orchestration = persona.orchestration;
|
|
2285
|
+
const panel = resolveOrchestrationMembers(orchestration, teams);
|
|
2286
|
+
return runParticipants(ctx, { strategy: orchestrationStrategy, members: panel,
|
|
2287
|
+
...(orchestration.params ? { params: orchestration.params } : {}) }, owner);
|
|
2288
|
+
}
|
|
2289
|
+
const resolved = resolveCouncilInvocation(personas, persona, profile ? { persona: profile } : {});
|
|
2290
|
+
if (!resolved.ok) throw new Error(resolved.error);
|
|
2291
|
+
const { strategy, roster, members, params } = resolved.value;
|
|
2292
|
+
const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
|
|
2293
|
+
return runParticipants(ctx, { strategy, members: panel, params }, owner);
|
|
2294
|
+
},
|
|
2295
|
+
saved: (owner) => personaModels(personaConfigs, owner),
|
|
2296
|
+
write: (owner, models) => {
|
|
2297
|
+
savedSettings = patchPersonaSettings(settingsFile, { models: { [owner]: models } });
|
|
2298
|
+
personaConfigs = savedSettings.personas;
|
|
2299
|
+
},
|
|
2300
|
+
isCurrent: () => !disposed && (session === undefined || lastCtx?.sessionManager?.getSessionId?.() === session),
|
|
2301
|
+
});
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2140
2305
|
// ── /persona ─────────────────────────────────────────────────────────────────
|
|
2141
2306
|
pi.registerCommand("persona", {
|
|
2142
|
-
description: "Switch
|
|
2307
|
+
description: "Switch persona or edit council models: /persona [name|off|list|reload|seed|restore|models [council-persona]]",
|
|
2143
2308
|
handler: async (args, ctx) => {
|
|
2144
2309
|
lastCtx = ctx;
|
|
2145
2310
|
const arg = args.trim();
|
|
2311
|
+
if (arg === "models" || arg.startsWith("models ")) {
|
|
2312
|
+
const caller = controller.activePersona;
|
|
2313
|
+
const scope = modelSelectionScope(ctx);
|
|
2314
|
+
if (!caller || !scope) {
|
|
2315
|
+
ctx.ui.notify("persona models: activate a persona first with /persona <name>.", "warning");
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
if (!ctx.hasUI) {
|
|
2319
|
+
ctx.ui.notify("persona models: an interactive TUI or RPC UI is required to select council models; no assignments changed.", "warning");
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
const profile = arg.slice("models".length).trim();
|
|
2323
|
+
const resolved = resolveCouncilInvocation(personas, caller, profile ? { persona: profile } : {});
|
|
2324
|
+
if (!resolved.ok) {
|
|
2325
|
+
ctx.ui.notify(`persona models: ${resolved.error}`, "error");
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
try {
|
|
2329
|
+
const { strategy, roster, members, params } = resolved.value;
|
|
2330
|
+
const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
|
|
2331
|
+
const deadline = AbortSignal.timeout(20_000);
|
|
2332
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
|
|
2333
|
+
const result = await ctx.modelRegistry.refresh?.({ signal });
|
|
2334
|
+
if (!scope.isCurrent()) {
|
|
2335
|
+
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
if (signal.aborted || result?.aborted) {
|
|
2339
|
+
ctx.ui.notify("persona models: refresh cancelled or timed out; previous assignments kept.", "warning");
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
if (result?.errors.size) {
|
|
2343
|
+
const providers = compactInlineText([...result.errors.keys()].join(", "), { maxChars: 160 });
|
|
2344
|
+
ctx.ui.notify(`persona models: refresh incomplete for ${providers}; previous assignments kept. Check provider configuration and retry.`, "warning");
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
await editPersonaModels(ctx, runParticipants(ctx, { strategy, members: panel, params }), scope);
|
|
2348
|
+
} catch (err) {
|
|
2349
|
+
ctx.ui.notify(`persona models: refresh failed (${err instanceof Error ? err.message : String(err)}); previous assignments kept.`, "error");
|
|
2350
|
+
}
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2146
2353
|
if (arg === "off" || arg === "none") {
|
|
2147
2354
|
await controller.deactivate();
|
|
2148
2355
|
persist(undefined);
|
package/src/orchestration/sdk.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface AgentProgress {
|
|
|
67
67
|
tokens?: number;
|
|
68
68
|
/** The tool the agent is currently running (e.g. "grep src/…"), if any. */
|
|
69
69
|
activity?: string;
|
|
70
|
+
/** Timestamp of newly streamed tool output; no tool output text crosses this seam. */
|
|
71
|
+
toolOutputAt?: number;
|
|
70
72
|
/** One authoritative runtime tool lifecycle transition; never contains tool args or output. */
|
|
71
73
|
toolEvent?: ToolEvent;
|
|
72
74
|
}
|