@aeondave/pi-persona 1.17.0 → 1.17.3
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 +79 -29
- package/CHANGELOG.md +264 -0
- package/README.md +27 -12
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +223 -57
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +189 -35
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +9 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +20 -0
- package/src/bridge.ts +3 -2
- package/src/core/brief.ts +429 -356
- package/src/core/config.ts +16 -11
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +251 -0
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +195 -136
- package/src/engine/child.ts +1 -1
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +11 -6
- package/src/engine/stream.ts +3 -0
- package/src/exocom/install.ts +2 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +336 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +642 -267
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +26 -5
- package/src/orchestration/flow-run.ts +38 -13
- package/src/orchestration/flow.ts +9 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/sdk.ts +17 -3
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/persona.ts +7 -3
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +77 -52
- package/src/tools/delegate-tool.ts +315 -285
- package/src/tools/delegate.ts +22 -210
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +15 -19
- package/src/tools/flow.ts +85 -28
- package/src/tools/intercom-tool.ts +108 -118
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +19 -9
- package/src/tools/monitor.ts +39 -6
- package/src/tools/timer.ts +11 -13
- package/src/ui/agent-overlay.ts +55 -14
- package/src/ui/agent-tree.ts +84 -18
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/persona-settings.ts +328 -0
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +189 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
- package/src/persona/state.ts +0 -28
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/** Public, persistable pi-persona preferences and their resolved runtime config. */
|
|
2
|
+
|
|
3
|
+
import { isThinkingLevel } from "./types.ts";
|
|
4
|
+
import { resolveConfig, type PiPersonaConfig } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
export type CompanionId = "mind" | "flow";
|
|
7
|
+
|
|
8
|
+
/** Preferences contributed by the optional companion extensions to the shared settings panel. */
|
|
9
|
+
export interface CompanionPreferenceValues {
|
|
10
|
+
mindCapture: "auto" | "prompt" | "off";
|
|
11
|
+
mindNudge: boolean;
|
|
12
|
+
flowEnabled: boolean;
|
|
13
|
+
flowPort: number;
|
|
14
|
+
flowIdleMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PersonaPreferences = Partial<Omit<PiPersonaConfig, "disabled" | "stateFile"> & CompanionPreferenceValues>;
|
|
18
|
+
export type SettingId = keyof PersonaPreferences;
|
|
19
|
+
export type SettingKind = "boolean" | "number" | "string" | "string-list" | "choice";
|
|
20
|
+
|
|
21
|
+
export interface PersonaSetting {
|
|
22
|
+
readonly id: SettingId;
|
|
23
|
+
readonly group: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly description: string;
|
|
26
|
+
readonly kind: SettingKind;
|
|
27
|
+
readonly choices?: readonly string[];
|
|
28
|
+
readonly env?: string;
|
|
29
|
+
readonly applyWhen: string;
|
|
30
|
+
readonly owner?: CompanionId;
|
|
31
|
+
readonly min?: number;
|
|
32
|
+
readonly max?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const APPLY_WHEN = "next Pi restart or native extension reload";
|
|
36
|
+
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
|
|
37
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
38
|
+
|
|
39
|
+
/** The one registry used by settings UI, validation, and environment precedence. */
|
|
40
|
+
export const PERSONA_SETTINGS: readonly PersonaSetting[] = [
|
|
41
|
+
{ id: "extraDirs", group: "Discovery", label: "Extra persona directories", description: "Additional directories searched for persona and agent definitions.", kind: "string-list", env: "PI_PERSONA_DIRS", applyWhen: APPLY_WHEN },
|
|
42
|
+
{ id: "defaultPersona", group: "Startup", label: "Default persona", description: "Persona selected unless --persona is explicit; takes precedence over the remembered persona.", kind: "string", env: "PI_PERSONA_DEFAULT", applyWhen: APPLY_WHEN },
|
|
43
|
+
{ id: "keybinding", group: "Interface", label: "Persona cycle key", description: "Key used to cycle through installed personas.", kind: "string", env: "PI_PERSONA_KEY", applyWhen: APPLY_WHEN },
|
|
44
|
+
{ id: "persist", group: "Startup", label: "Remember last persona", description: "Restore the last explicitly selected persona in a later session.", kind: "boolean", env: "PI_PERSONA_PERSIST", applyWhen: APPLY_WHEN },
|
|
45
|
+
{ id: "delegateDefaultAllow", group: "Delegation", label: "Allow delegation by default", description: "Fallback for delegate targets when the persona declares neither an allow nor a deny list; explicit rules take precedence.", kind: "boolean", env: "PI_PERSONA_DELEGATE_DEFAULT", applyWhen: APPLY_WHEN },
|
|
46
|
+
{ id: "seed", group: "Discovery", label: "Install bundled defaults on first run", description: "Opt in to installing bundled personas and agents once.", kind: "boolean", env: "PI_PERSONA_SEED", applyWhen: APPLY_WHEN },
|
|
47
|
+
{ id: "childThinking", group: "Delegation", label: "Delegated thinking level", description: "Thinking level forced on delegated child agents.", kind: "choice", choices: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], env: "PI_PERSONA_CHILD_THINKING", applyWhen: APPLY_WHEN },
|
|
48
|
+
{ id: "engine", group: "Delegation", label: "Sub-agent engine", description: "Run sub-agents in-process or as child Pi processes.", kind: "choice", choices: ["inproc", "child"], env: "PI_PERSONA_ENGINE", applyWhen: APPLY_WHEN },
|
|
49
|
+
{ id: "peekEveryMs", group: "Delegation", label: "Progress and stall check interval", description: "Milliseconds between async progress/stall checks; zero disables checks.", kind: "number", env: "PI_PERSONA_PEEK_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
|
|
50
|
+
{ id: "checkInEveryMs", group: "Delegation", label: "Progress check-in interval", description: "Milliseconds between operator-only progress entries; zero disables check-ins.", kind: "number", env: "PI_PERSONA_CHECKIN_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
|
|
51
|
+
{ id: "agentHardTimeoutMs", group: "Delegation", label: "Agent hard timeout", description: "Opt-in wall-clock limit in milliseconds for one agent run; zero means unlimited.", kind: "number", env: "PI_PERSONA_AGENT_MAX_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
|
|
52
|
+
{ id: "agentStartupTimeoutMs", group: "Delegation", label: "Agent startup deadline", description: "Milliseconds allowed without first progress while an agent starts; zero disables the deadline.", kind: "number", env: "PI_PERSONA_AGENT_STARTUP_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
|
|
53
|
+
{ id: "nudge", group: "Delegation", label: "Delegation nudges", description: "Delegation and persistence checkpoints, plus the repeated peek/result and long-sleep guards; disabling turns all of them off.", kind: "boolean", env: "PI_PERSONA_NUDGE", applyWhen: APPLY_WHEN },
|
|
54
|
+
{ id: "broker", group: "Collaboration", label: "Cross-process communication broker", description: "Connect child-engine agents to supervisor messaging and steering.", kind: "boolean", env: "PI_PERSONA_BROKER", applyWhen: APPLY_WHEN },
|
|
55
|
+
{ id: "exocom", group: "Collaboration", label: "External agent communication", description: "Enable communication between independent top-level Pi instances.", kind: "boolean", env: "PI_PERSONA_EXOCOM", applyWhen: APPLY_WHEN },
|
|
56
|
+
{ id: "asyncRetain", group: "Delegation", label: "Retained async results", description: "Number of settled async runs retained for later collection.", kind: "number", env: "PI_PERSONA_ASYNC_RETAIN", applyWhen: APPLY_WHEN, min: 1, max: MAX_SAFE_INTEGER },
|
|
57
|
+
{ id: "ledgerV2", group: "Delegation", label: "Expanded retry-loop identity", description: "Include role, tools, and isolation in the repeated-delegation guard key.", kind: "boolean", env: "PI_PERSONA_LEDGER_V2", applyWhen: APPLY_WHEN },
|
|
58
|
+
{ id: "spine", group: "Prompt", label: "Supervisor spine", description: "Shared prompt layer for the supervisor: off, on for the bundled layer, or a file path.", kind: "string", env: "PI_PERSONA_SPINE", applyWhen: APPLY_WHEN },
|
|
59
|
+
{ id: "spineLegs", group: "Prompt", label: "Delegated-leg spine", description: "Prompt layer for delegated agents; follows the supervisor selection unless set separately.", kind: "string", env: "PI_PERSONA_SPINE_LEGS", applyWhen: APPLY_WHEN },
|
|
60
|
+
{ id: "changeReport", group: "Delegation", label: "Report files changed by agents", description: "Include a git-based changed-files report for non-worktree agent runs.", kind: "boolean", env: "PI_PERSONA_LEG_CHANGE_REPORT", applyWhen: APPLY_WHEN },
|
|
61
|
+
{ id: "mindCapture", group: "Mind", label: "Memory capture mode", description: "Capture explicit user requests (auto), suggest only (prompt), or disable cue detection (off). The memory tool remains available.", kind: "choice", choices: ["auto", "prompt", "off"], env: "PI_PERSONA_MIND_CAPTURE", owner: "mind", applyWhen: "next Pi restart or native extension reload" },
|
|
62
|
+
{ id: "mindNudge", group: "Mind", label: "Mind suggestions", description: "Optional memory/backlog suggestions, not explicit user capture. Only the exact environment value off disables them.", kind: "boolean", env: "PI_PERSONA_MIND_NUDGE", owner: "mind", applyWhen: "next Pi restart or native extension reload" },
|
|
63
|
+
{ id: "flowEnabled", group: "Flow", label: "Attach Flow by default", description: "Enable this Pi's default dashboard attachment. --no-flow opts out for one run; other Pi sessions keep their shared service.", kind: "boolean", owner: "flow", applyWhen: "next Pi restart or native extension reload" },
|
|
64
|
+
{ id: "flowPort", group: "Flow", label: "Flow shared-service port", description: "Desired loopback port; 0 asks the operating system for an available port. Invalid extension environment values fall back to 7874.", kind: "number", env: "PI_PERSONA_FLOW_PORT", owner: "flow", applyWhen: "next shared-service start after Pi restart or native extension reload; an existing service is unchanged", min: 0, max: 65_535 },
|
|
65
|
+
{ id: "flowIdleMs", group: "Flow", label: "Flow idle grace (advanced)", description: "Milliseconds with no Pi registrations and no browser SSE clients before service shutdown (50–60000). Invalid environment values use 60000 with a Flow warning.", kind: "number", env: "PI_PERSONA_FLOW_IDLE_MS", owner: "flow", applyWhen: "next shared-service start after Pi restart or native extension reload; an existing service is unchanged", min: 50, max: 60_000 },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const SETTING_BY_ID = new Map<SettingId, PersonaSetting>(PERSONA_SETTINGS.map((setting) => [setting.id, setting]));
|
|
69
|
+
|
|
70
|
+
function hasExplicitEnvOverride(setting: PersonaSetting, env: Record<string, string | undefined>): boolean {
|
|
71
|
+
if (!setting.env) return false;
|
|
72
|
+
const value = env[setting.env];
|
|
73
|
+
if (value === undefined) return false;
|
|
74
|
+
// The existing resolver deliberately treats an empty legs selector as "follow spine".
|
|
75
|
+
return setting.id === "spineLegs" && value.trim() === "" ? false : true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a defined environment key pins a setting using the existing resolver semantics.
|
|
80
|
+
* Even a value the resolver normalizes/rejects still pins its resulting default; the one
|
|
81
|
+
* deliberate exception is blank PI_PERSONA_SPINE_LEGS, which means "follow spine".
|
|
82
|
+
*/
|
|
83
|
+
export function settingHasEnvironmentOverride(
|
|
84
|
+
setting: PersonaSetting | SettingId,
|
|
85
|
+
env: Record<string, string | undefined>,
|
|
86
|
+
): boolean {
|
|
87
|
+
const definition = typeof setting === "string" ? SETTING_BY_ID.get(setting) : setting;
|
|
88
|
+
return definition ? hasExplicitEnvOverride(definition, env) : false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
93
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
94
|
+
return prototype === Object.prototype || prototype === null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateValue(setting: PersonaSetting, value: unknown): unknown {
|
|
98
|
+
switch (setting.kind) {
|
|
99
|
+
case "boolean":
|
|
100
|
+
if (typeof value !== "boolean") throw new Error(`invalid preference ${setting.id}: expected a boolean`);
|
|
101
|
+
return value;
|
|
102
|
+
case "number":
|
|
103
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
104
|
+
throw new Error(`invalid preference ${setting.id}: expected a safe integer`);
|
|
105
|
+
}
|
|
106
|
+
if (setting.min !== undefined && value < setting.min) throw new Error(`invalid preference ${setting.id}: must be at least ${setting.min}`);
|
|
107
|
+
if (setting.max !== undefined && value > setting.max) throw new Error(`invalid preference ${setting.id}: must be at most ${setting.max}`);
|
|
108
|
+
return value;
|
|
109
|
+
case "string":
|
|
110
|
+
if (typeof value !== "string") throw new Error(`invalid preference ${setting.id}: expected a string`);
|
|
111
|
+
if (setting.id === "defaultPersona" || setting.id === "keybinding") {
|
|
112
|
+
const normalized = value.trim();
|
|
113
|
+
if (!normalized) throw new Error(`invalid preference ${setting.id}: value must not be blank`);
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
case "string-list":
|
|
118
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
119
|
+
throw new Error(`invalid preference ${setting.id}: expected a list of strings`);
|
|
120
|
+
}
|
|
121
|
+
return [...value];
|
|
122
|
+
case "choice":
|
|
123
|
+
if (typeof value !== "string" || !setting.choices?.includes(value)) {
|
|
124
|
+
throw new Error(`invalid preference ${setting.id}: expected one of ${setting.choices?.join(", ") ?? "the listed choices"}`);
|
|
125
|
+
}
|
|
126
|
+
if (setting.id === "childThinking" && !isThinkingLevel(value)) {
|
|
127
|
+
throw new Error(`invalid preference ${setting.id}: unsupported thinking level`);
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Validate JSON-sourced preferences strictly. Unknown keys are rejected so a typo cannot hide. */
|
|
134
|
+
export function validatePreferences(value: unknown): PersonaPreferences {
|
|
135
|
+
if (!isPlainObject(value)) throw new Error("invalid persona preferences: expected an object");
|
|
136
|
+
const preferences: Record<string, unknown> = {};
|
|
137
|
+
for (const [key, item] of Object.entries(value)) {
|
|
138
|
+
if (!SETTING_BY_ID.has(key as SettingId)) {
|
|
139
|
+
throw new Error(`unknown persona preference: ${key}`);
|
|
140
|
+
}
|
|
141
|
+
const setting = SETTING_BY_ID.get(key as SettingId);
|
|
142
|
+
if (!setting) throw new Error(`unknown persona preference: ${key}`);
|
|
143
|
+
Object.defineProperty(preferences, key, {
|
|
144
|
+
value: validateValue(setting, item), enumerable: true, configurable: true, writable: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return preferences as PersonaPreferences;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resolve startup settings: environment values remain explicit overrides over saved preferences. */
|
|
151
|
+
export function resolvePersonaConfig(
|
|
152
|
+
preferences: PersonaPreferences,
|
|
153
|
+
env: Record<string, string | undefined>,
|
|
154
|
+
): PiPersonaConfig {
|
|
155
|
+
const saved = validatePreferences(preferences);
|
|
156
|
+
const config = resolveConfig(env);
|
|
157
|
+
for (const setting of PERSONA_SETTINGS) {
|
|
158
|
+
if (setting.owner !== undefined) continue;
|
|
159
|
+
if (settingHasEnvironmentOverride(setting, env)) continue;
|
|
160
|
+
if (!Object.hasOwn(saved, setting.id)) continue;
|
|
161
|
+
const value = saved[setting.id];
|
|
162
|
+
if (value === undefined) continue;
|
|
163
|
+
if (setting.id === "spine") {
|
|
164
|
+
if (typeof value !== "string") throw new Error("invalid saved spine preference");
|
|
165
|
+
config.spine = resolveConfig({ PI_PERSONA_SPINE: value }).spine;
|
|
166
|
+
} else if (setting.id === "spineLegs") {
|
|
167
|
+
if (typeof value !== "string") throw new Error("invalid saved spineLegs preference");
|
|
168
|
+
config.spineLegs = resolveConfig({ PI_PERSONA_SPINE_LEGS: value }).spineLegs;
|
|
169
|
+
} else {
|
|
170
|
+
Object.assign(config, { [setting.id]: Array.isArray(value) ? [...value] : value });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const savedSpine = Object.hasOwn(saved, "spine") && env.PI_PERSONA_SPINE === undefined;
|
|
174
|
+
const savedLegs = Object.hasOwn(saved, "spineLegs") && !settingHasEnvironmentOverride("spineLegs", env);
|
|
175
|
+
const envLegsFollow = env.PI_PERSONA_SPINE_LEGS === undefined || env.PI_PERSONA_SPINE_LEGS.trim() === "";
|
|
176
|
+
if (savedSpine && !savedLegs && envLegsFollow) config.spineLegs = config.spine;
|
|
177
|
+
return config;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveMindCapture(value: string | undefined): CompanionPreferenceValues["mindCapture"] {
|
|
181
|
+
const normalized = value?.trim().toLowerCase();
|
|
182
|
+
return normalized === "prompt" || normalized === "off" ? normalized : "auto";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resolveFlowPort(value: string | undefined): number {
|
|
186
|
+
if (value === undefined || !/^\d+$/.test(value)) return 7874;
|
|
187
|
+
const port = Number(value);
|
|
188
|
+
return Number.isSafeInteger(port) && port >= 0 && port <= 65_535 ? port : 7874;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveFlowIdleMs(value: string | undefined): number {
|
|
192
|
+
if (value === undefined || !/^\d+$/.test(value)) return 60_000;
|
|
193
|
+
const idleMs = Number(value);
|
|
194
|
+
return Number.isSafeInteger(idleMs) && idleMs >= 50 && idleMs <= 60_000 ? idleMs : 60_000;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Resolve pi-persona config plus companion previews using the companions' current env grammar. */
|
|
198
|
+
export function resolveSettingsValues(
|
|
199
|
+
preferences: PersonaPreferences,
|
|
200
|
+
env: Record<string, string | undefined>,
|
|
201
|
+
): PiPersonaConfig & CompanionPreferenceValues {
|
|
202
|
+
const saved = validatePreferences(preferences);
|
|
203
|
+
const config = resolvePersonaConfig(saved, env);
|
|
204
|
+
const values: CompanionPreferenceValues = {
|
|
205
|
+
mindCapture: resolveMindCapture(env.PI_PERSONA_MIND_CAPTURE),
|
|
206
|
+
mindNudge: env.PI_PERSONA_MIND_NUDGE !== "off",
|
|
207
|
+
flowEnabled: true,
|
|
208
|
+
flowPort: resolveFlowPort(env.PI_PERSONA_FLOW_PORT),
|
|
209
|
+
flowIdleMs: resolveFlowIdleMs(env.PI_PERSONA_FLOW_IDLE_MS),
|
|
210
|
+
};
|
|
211
|
+
for (const setting of PERSONA_SETTINGS) {
|
|
212
|
+
if (setting.owner === undefined || settingHasEnvironmentOverride(setting, env)) continue;
|
|
213
|
+
if (!Object.hasOwn(saved, setting.id)) continue;
|
|
214
|
+
const value = saved[setting.id];
|
|
215
|
+
if (value !== undefined) Object.assign(values, { [setting.id]: Array.isArray(value) ? [...value] : value });
|
|
216
|
+
}
|
|
217
|
+
return { ...config, ...values };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Parse the text field used by a settings editor according to registry metadata. */
|
|
221
|
+
export function parseSettingInput(setting: PersonaSetting, text: string): unknown {
|
|
222
|
+
switch (setting.kind) {
|
|
223
|
+
case "boolean": {
|
|
224
|
+
const value = text.trim().toLowerCase();
|
|
225
|
+
if (["true", "on", "yes", "1"].includes(value)) return true;
|
|
226
|
+
if (["false", "off", "no", "0"].includes(value)) return false;
|
|
227
|
+
throw new Error(`invalid ${setting.id}: enter true or false`);
|
|
228
|
+
}
|
|
229
|
+
case "number": {
|
|
230
|
+
const trimmed = text.trim();
|
|
231
|
+
if (!trimmed) throw new Error(`invalid ${setting.id}: enter a number`);
|
|
232
|
+
const value = Number(trimmed);
|
|
233
|
+
return validateValue(setting, value);
|
|
234
|
+
}
|
|
235
|
+
case "string":
|
|
236
|
+
return validateValue(setting, text);
|
|
237
|
+
case "string-list":
|
|
238
|
+
if (text.trimStart().startsWith("[")) {
|
|
239
|
+
let parsed: unknown;
|
|
240
|
+
try {
|
|
241
|
+
parsed = JSON.parse(text) as unknown;
|
|
242
|
+
} catch {
|
|
243
|
+
throw new Error(`invalid ${setting.id}: expected a JSON string array or one item per line`);
|
|
244
|
+
}
|
|
245
|
+
return validateValue(setting, parsed);
|
|
246
|
+
}
|
|
247
|
+
return validateValue(setting, text.split(/\r?\n/).map((item) => item.trim()).filter(Boolean));
|
|
248
|
+
case "choice":
|
|
249
|
+
return validateValue(setting, text.trim());
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/core/timer.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `TimerScheduler` — the pure kernel behind the supervisor-armable `timer` tool.
|
|
3
3
|
*
|
|
4
|
-
* Problem it solves: a supervisor
|
|
5
|
-
* a service window, a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* a fresh turn, so the supervisor resumes on its own).
|
|
4
|
+
* Problem it solves: a supervisor needs a wake at a fixed wall-clock moment the world decides (a
|
|
5
|
+
* scheduled event, a service window, a reminder). The MCP/transport layer caps a blocking tool
|
|
6
|
+
* call at ~20-60s, so a blocking sleep is not an option. The fix is an ALARM: arm a timer that,
|
|
7
|
+
* when it expires, WAKES the session by injecting a follow-up (the extension routes the fire
|
|
8
|
+
* through the same idle-delivery notifier that surfaces async-run completions — an idle delivery
|
|
9
|
+
* always starts a fresh turn, so the supervisor resumes on its own).
|
|
11
10
|
*
|
|
12
11
|
* This module is pure (injected clock + setTimer/clearTimer + an onFire sink), so scheduling,
|
|
13
12
|
* validation, cancellation and listing are unit-tested without a live model or real wall clock.
|
|
@@ -65,6 +64,11 @@ export interface TimerSchedulerDeps {
|
|
|
65
64
|
clearTimer: (handle: unknown) => void;
|
|
66
65
|
/** Called exactly once when a timer expires (never for a cancelled one). */
|
|
67
66
|
onFire: (entry: TimerEntry) => void;
|
|
67
|
+
/** Called once per admitted alarm, right after `arm` accepts it (never for a rejected request).
|
|
68
|
+
* Every announced alarm later reaches exactly one of `onFire` / `onCancelled`. */
|
|
69
|
+
onArmed?: (entry: TimerEntry) => void;
|
|
70
|
+
/** Called once per alarm dropped by `cancel` or `cancelAll` (never for one that fired). */
|
|
71
|
+
onCancelled?: (entry: TimerEntry) => void;
|
|
68
72
|
/** Minimum accepted delay (ms). Default 1000 — sub-second alarms are pointless here. */
|
|
69
73
|
minDelayMs?: number;
|
|
70
74
|
/** Maximum accepted delay (ms). Default 24h — guards against a runaway/typo alarm. */
|
|
@@ -75,12 +79,26 @@ export interface TimerSchedulerDeps {
|
|
|
75
79
|
/** Maximum follow-up message length (chars). Default 4000 — the stored message is
|
|
76
80
|
* re-injected into context at fire time, so it is a budget, not a document. */
|
|
77
81
|
maxMessageChars?: number;
|
|
82
|
+
/** Longest accepted label (code units). Default 120 — the label lands in the tree row, `timer list`
|
|
83
|
+
* and the fire message, so it is a caption, not a document. A longer label is CLIPPED (ending in
|
|
84
|
+
* "…"), not rejected: a verbose label must never cost the alarm. */
|
|
85
|
+
maxLabelChars?: number;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
const DEFAULT_MIN_DELAY_MS = 1_000;
|
|
81
89
|
const DEFAULT_MAX_DELAY_MS = 24 * 60 * 60 * 1_000;
|
|
82
90
|
const DEFAULT_MAX_ARMED = 32;
|
|
83
91
|
const DEFAULT_MAX_MESSAGE_CHARS = 4_000;
|
|
92
|
+
const DEFAULT_MAX_LABEL_CHARS = 120;
|
|
93
|
+
|
|
94
|
+
/** At most `max` code units, ending in "…" when clipped; never cuts an astral character in half. */
|
|
95
|
+
function clipLabel(text: string, max: number): string {
|
|
96
|
+
if (text.length <= max) return text;
|
|
97
|
+
let end = Math.max(0, max - 1);
|
|
98
|
+
const last = text.charCodeAt(end - 1);
|
|
99
|
+
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
|
|
100
|
+
return `${text.slice(0, end)}…`;
|
|
101
|
+
}
|
|
84
102
|
|
|
85
103
|
interface Scheduled {
|
|
86
104
|
entry: TimerEntry;
|
|
@@ -93,6 +111,7 @@ export class TimerScheduler {
|
|
|
93
111
|
private readonly maxDelayMs: number;
|
|
94
112
|
private readonly maxArmed: number;
|
|
95
113
|
private readonly maxMessageChars: number;
|
|
114
|
+
private readonly maxLabelChars: number;
|
|
96
115
|
private readonly timers = new Map<string, Scheduled>();
|
|
97
116
|
private seq = 0;
|
|
98
117
|
|
|
@@ -102,6 +121,7 @@ export class TimerScheduler {
|
|
|
102
121
|
this.maxDelayMs = deps.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
103
122
|
this.maxArmed = deps.maxArmed ?? DEFAULT_MAX_ARMED;
|
|
104
123
|
this.maxMessageChars = deps.maxMessageChars ?? DEFAULT_MAX_MESSAGE_CHARS;
|
|
124
|
+
this.maxLabelChars = deps.maxLabelChars ?? DEFAULT_MAX_LABEL_CHARS;
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
/** Arm a new alarm. Returns the entry on success, or a reason on rejection. */
|
|
@@ -137,13 +157,14 @@ export class TimerScheduler {
|
|
|
137
157
|
const id = `timer-${++this.seq}`;
|
|
138
158
|
const entry: TimerEntry = {
|
|
139
159
|
id,
|
|
140
|
-
label: (req.label ?? "").trim() || id,
|
|
160
|
+
label: clipLabel((req.label ?? "").trim(), this.maxLabelChars) || id,
|
|
141
161
|
fireAtEpochMs: fireAt,
|
|
142
162
|
armedAtEpochMs: now,
|
|
143
163
|
message,
|
|
144
164
|
};
|
|
145
165
|
const handle = this.deps.setTimer(() => this.fire(id), delay);
|
|
146
166
|
this.timers.set(id, { entry, handle });
|
|
167
|
+
this.deps.onArmed?.(entry);
|
|
147
168
|
return { ok: true, entry, remainingMs: delay };
|
|
148
169
|
}
|
|
149
170
|
|
|
@@ -153,13 +174,17 @@ export class TimerScheduler {
|
|
|
153
174
|
if (!scheduled) return false;
|
|
154
175
|
this.deps.clearTimer(scheduled.handle);
|
|
155
176
|
this.timers.delete(id);
|
|
177
|
+
this.deps.onCancelled?.(scheduled.entry);
|
|
156
178
|
return true;
|
|
157
179
|
}
|
|
158
180
|
|
|
159
181
|
/** Cancel every armed alarm (reload/dispose hygiene — never leak a timer across sessions). */
|
|
160
182
|
cancelAll(): void {
|
|
161
|
-
|
|
183
|
+
const dropped = [...this.timers.values()];
|
|
184
|
+
for (const scheduled of dropped) this.deps.clearTimer(scheduled.handle);
|
|
162
185
|
this.timers.clear();
|
|
186
|
+
// Announced after the map is empty, so a listener that re-enters (list/cancel) sees the truth.
|
|
187
|
+
for (const scheduled of dropped) this.deps.onCancelled?.(scheduled.entry);
|
|
163
188
|
}
|
|
164
189
|
|
|
165
190
|
/** Snapshot of all armed alarms, soonest first, with live remaining time. */
|
package/src/engine/adapter.ts
CHANGED
|
@@ -12,14 +12,14 @@ import { type ContractDef, contractInstructions, parseAndValidate, pinContract,
|
|
|
12
12
|
import { sanitizeDisplayLabel } from "../core/display-label.ts";
|
|
13
13
|
import { assignedIdentityPrompt } from "../core/naming.ts";
|
|
14
14
|
import { roleHint } from "../orchestration/roster.ts";
|
|
15
|
-
import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
15
|
+
import { type AgentProgress, type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
16
16
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
17
17
|
import { type ChildEngineOptions, type ChildRunSpec, runChildAgent } from "./child.ts";
|
|
18
18
|
import { looksLikeProviderError } from "./errors.ts";
|
|
19
|
+
import { piHostModuleHint } from "./host-module.ts";
|
|
19
20
|
import { nextChildHandle } from "./handles.ts";
|
|
20
21
|
import { combineSignals } from "./signals.ts";
|
|
21
22
|
import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
22
|
-
import type { ToolEvent } from "./stream.ts";
|
|
23
23
|
|
|
24
24
|
/** The engine-side face of the cross-process broker (spec B3/B4/B7) — a child-engine
|
|
25
25
|
* spawn's connection to the supervisor-hosted relay. The concrete implementation
|
|
@@ -106,7 +106,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
106
106
|
return {
|
|
107
107
|
async run(
|
|
108
108
|
spec: AgentRunSpec,
|
|
109
|
-
onProgress?: (p:
|
|
109
|
+
onProgress?: (p: AgentProgress) => void,
|
|
110
110
|
callSignal?: AbortSignal,
|
|
111
111
|
/** Called once with a steer handle when the broker is on (in-process parity). */
|
|
112
112
|
onSteerable?: (steer: SteerFn) => void,
|
|
@@ -153,7 +153,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
153
153
|
if (isPositiveFiniteMs(spec.timeoutMs)) childOptions.timeoutMs = spec.timeoutMs;
|
|
154
154
|
if (onProgress) {
|
|
155
155
|
childOptions.onProgress = (snap) =>
|
|
156
|
-
onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
|
|
156
|
+
onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
|
|
157
157
|
}
|
|
158
158
|
// The run aborts if EITHER the whole-run signal or this agent's own (UI stop) fires.
|
|
159
159
|
const signal = combineSignals(deps.signal, callSignal);
|
|
@@ -215,6 +215,8 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
215
215
|
const tag = `[${spec.agent} · ${modelRef}${dyn}]`;
|
|
216
216
|
if (child.errorMessage) result.error = `${tag} ${child.errorMessage}`;
|
|
217
217
|
else if (!child.ok) result.error = `${tag} ${child.stderr.trim() || `agent failed (exit ${child.exitCode})`}`;
|
|
218
|
+
const hostModuleHint = !child.ok ? piHostModuleHint(`${child.errorMessage ?? ""}\n${child.stderr}`) : undefined;
|
|
219
|
+
if (hostModuleHint && result.error) result.error += ` — ${hostModuleHint}`;
|
|
218
220
|
// MCP-leg diagnosis: an `mcp: true` leg is routed here (the child engine) precisely so
|
|
219
221
|
// `pi-mcp-adapter` initializes in a fresh headless `pi -p`. When such a leg fails without
|
|
220
222
|
// completing a single turn, the near-certain cause is the MCP adapter hanging in init —
|
|
@@ -222,7 +224,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
222
224
|
// Keyed on turns===0 (never really started), not empty output, so an early tool/log line
|
|
223
225
|
// can't mask it. Name the cause + the fix so the operator never has to guess (a real prod
|
|
224
226
|
// report: a dead `mcp: true` async delegation with an opaque timeout).
|
|
225
|
-
if (!child.ok && spec.mcp === true && child.usage.turns === 0) {
|
|
227
|
+
if (!child.ok && !hostModuleHint && spec.mcp === true && child.usage.turns === 0) {
|
|
226
228
|
result.error =
|
|
227
229
|
`${result.error ?? `${tag} agent failed`} — mcp:true leg never completed a turn: the MCP adapter likely hung initializing in the headless child ` +
|
|
228
230
|
`(a server needing interactive OAuth can't authenticate without a UI). Fix: authenticate the MCP server once in a normal \`pi\` session (\`/mcp auth\`) so its token is cached — the headless child reuses it; ` +
|
|
@@ -239,9 +241,11 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
239
241
|
? "timeout"
|
|
240
242
|
: child.aborted
|
|
241
243
|
? "abort"
|
|
242
|
-
:
|
|
243
|
-
? "
|
|
244
|
-
: "
|
|
244
|
+
: hostModuleHint
|
|
245
|
+
? "agent"
|
|
246
|
+
: child.stopReason === "error" || looksLikeProviderError(`${child.errorMessage ?? ""}\n${child.stderr}`)
|
|
247
|
+
? "provider"
|
|
248
|
+
: "agent";
|
|
245
249
|
|
|
246
250
|
if (contractDef) {
|
|
247
251
|
// Shared parse+validate (unwraps ```json fences / prose first) — a member's
|