@aeondave/pi-persona 1.17.1 → 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 +59 -28
- package/CHANGELOG.md +234 -0
- package/README.md +16 -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 +183 -55
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +82 -32
- 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 +15 -10
- 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 +1 -1
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +165 -146
- package/src/engine/child.ts +2 -2
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +12 -7
- package/src/engine/stream.ts +4 -20
- package/src/exocom/install.ts +2 -1
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +31 -13
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +505 -241
- 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 -5
- 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/persona.ts +7 -3
- 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 +310 -280
- 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 +9 -3
- 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/core/config.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import { isThinkingLevel } from "./types.ts";
|
|
9
9
|
|
|
10
|
+
/** Settled-but-uncollected async runs retained by default. Equal to the `tasks` cap (RUN_LIMITS.maxChildren,
|
|
11
|
+
* 64), so one maximum-width call can never evict its own first results before the batch is delivered. */
|
|
12
|
+
export const DEFAULT_ASYNC_RETAIN = 64;
|
|
13
|
+
|
|
10
14
|
export interface PiPersonaConfig {
|
|
11
15
|
disabled: boolean;
|
|
12
16
|
extraDirs: string[];
|
|
@@ -24,14 +28,14 @@ export interface PiPersonaConfig {
|
|
|
24
28
|
/** Engine backend for sub-agents: "inproc" (run in-process via `createAgentSession`,
|
|
25
29
|
* the default) or "child" (spawn `pi -p`, the baseline). */
|
|
26
30
|
engine?: "child" | "inproc";
|
|
27
|
-
/** Fast stall/message
|
|
28
|
-
*
|
|
31
|
+
/** Fast stall/message tick (ms): while async children run, a tick stays SILENT unless a leg NEWLY
|
|
32
|
+
* looks stalled (operator-only card) or a sub-agent messaged the supervisor (model wake), so a
|
|
29
33
|
* healthy run never interrupts. On by default (30000); PI_PERSONA_PEEK_MS=0 opts out (the routine
|
|
30
34
|
* check-in below is independent). */
|
|
31
35
|
peekEveryMs: number;
|
|
32
36
|
/** Routine check-in interval (ms): how often, while async children run, the operator gets a durable,
|
|
33
37
|
* expandable compact progress entry. It is status-only and does not start a model turn. Deliberately
|
|
34
|
-
* slow and independent of the fast stall/message
|
|
38
|
+
* slow and independent of the fast stall/message tick. 300000 (5 min) by default;
|
|
35
39
|
* PI_PERSONA_CHECKIN_MS=0 opts out. */
|
|
36
40
|
checkInEveryMs: number;
|
|
37
41
|
/** Per-agent hard wall-clock cap (ms): an OPT-IN lifetime ceiling. When set (>0) it settles even
|
|
@@ -54,8 +58,9 @@ export interface PiPersonaConfig {
|
|
|
54
58
|
* 300000 (5 min) by default; PI_PERSONA_AGENT_STARTUP_MS=0 disables it. */
|
|
55
59
|
agentStartupTimeoutMs: number;
|
|
56
60
|
/** Delegation nudge: when a delegating supervisor grinds a RUN of hands-on commands by hand (a
|
|
57
|
-
* by-hand sweep) without a hand-off, append a reminder to the offending tool's result.
|
|
58
|
-
*
|
|
61
|
+
* by-hand sweep) without a hand-off, append a reminder to the offending tool's result. The same
|
|
62
|
+
* switch gates the call-time wake guards (the repeated peek/result refusal and the long-sleep
|
|
63
|
+
* block). On by default; PI_PERSONA_NUDGE=off opts out of all of them. */
|
|
59
64
|
nudge: boolean;
|
|
60
65
|
/** Cross-process broker (spec B1-B7): on by default so child-engine legs (MCP, worktree,
|
|
61
66
|
* `PI_PERSONA_ENGINE=child`) expose the same steer/contact_supervisor plane in-process
|
|
@@ -67,9 +72,9 @@ export interface PiPersonaConfig {
|
|
|
67
72
|
* additionally gated by the persona's canUseBus. */
|
|
68
73
|
exocom: boolean;
|
|
69
74
|
/** How many settled-but-uncollected async runs {@link AsyncRunTracker} retains before
|
|
70
|
-
* FIFO-evicting the oldest completed ones.
|
|
71
|
-
* PI_PERSONA_ASYNC_RETAIN=<n> (a finite value >= 1) to
|
|
72
|
-
*
|
|
75
|
+
* FIFO-evicting the oldest completed ones. {@link DEFAULT_ASYNC_RETAIN} by default; set
|
|
76
|
+
* PI_PERSONA_ASYNC_RETAIN=<n> (a finite value >= 1) to change it. A value < 1 (junk,
|
|
77
|
+
* negative, or 0 — not a meaningful retention bound) keeps the default. */
|
|
73
78
|
asyncRetain: number;
|
|
74
79
|
/** Opt-in wider {@link DelegationLedger} veto key: OFF (default) keys purely on
|
|
75
80
|
* `agent+model+task` (today's veto *behavior* — unchanged; the key bytes themselves have
|
|
@@ -171,7 +176,7 @@ export function resolveConfig(env: Env): PiPersonaConfig {
|
|
|
171
176
|
// steerable without a hidden env pin. Off-words restore pre-broker spawn env.
|
|
172
177
|
broker: !isOff(env.PI_PERSONA_BROKER ?? "on"),
|
|
173
178
|
exocom: false,
|
|
174
|
-
asyncRetain:
|
|
179
|
+
asyncRetain: DEFAULT_ASYNC_RETAIN,
|
|
175
180
|
ledgerV2: false,
|
|
176
181
|
spine: "",
|
|
177
182
|
spineLegs: "",
|
|
@@ -199,7 +204,7 @@ export function resolveConfig(env: Env): PiPersonaConfig {
|
|
|
199
204
|
if (Number.isFinite(startup) && startup >= 0) config.agentStartupTimeoutMs = startup;
|
|
200
205
|
}
|
|
201
206
|
// A finite value >= 1 sets the retention bound; 0 isn't meaningful here (unlike the interval
|
|
202
|
-
// knobs above, where 0 opts out) so it — like junk/negative — falls back to the default
|
|
207
|
+
// knobs above, where 0 opts out) so it — like junk/negative — falls back to the default.
|
|
203
208
|
const asyncRetainRaw = env.PI_PERSONA_ASYNC_RETAIN?.trim();
|
|
204
209
|
if (asyncRetainRaw !== undefined && asyncRetainRaw !== "") {
|
|
205
210
|
const retain = Number(asyncRetainRaw);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** Compact discovery only: version-shaped ids are a display heuristic, not release dates,
|
|
2
|
+
* account entitlement or model quality. Keep the full catalog for queries and exact-pin resolution. */
|
|
3
|
+
import type { ModelLite } from "./models.ts";
|
|
4
|
+
import { modelCostRank, modelCostTiers, type ModelCostMetadata } from "./model-tier.ts";
|
|
5
|
+
|
|
6
|
+
interface CatalogEntry {
|
|
7
|
+
model: ModelLite;
|
|
8
|
+
root: string;
|
|
9
|
+
lane: string;
|
|
10
|
+
version: string[];
|
|
11
|
+
snapshot: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const textOrder = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
|
|
15
|
+
const refKey = (model: ModelLite): string => `${model.provider}\0${model.id}`;
|
|
16
|
+
|
|
17
|
+
function entry(model: ModelLite): CatalogEntry {
|
|
18
|
+
let base = model.id.toLowerCase();
|
|
19
|
+
let snapshot: string | undefined;
|
|
20
|
+
// Snapshot tokens can precede adapter suffixes (e.g. -20250514-v1:0). Dots stay
|
|
21
|
+
// version separators; validate the calendar so arbitrary eight-digit versions survive.
|
|
22
|
+
base = base.replace(/(^|[-_@/:])((?:19|20)\d{2})-?(\d{2})-?(\d{2})(?=$|[-_@/:])/g, (match: string, prefix: string, yearText: string, monthText: string, dayText: string) => {
|
|
23
|
+
const year = Number(yearText);
|
|
24
|
+
const month = Number(monthText);
|
|
25
|
+
const day = Number(dayText);
|
|
26
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
27
|
+
if (parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day) {
|
|
28
|
+
const date = `${yearText}${monthText}${dayText}`;
|
|
29
|
+
if (snapshot === undefined || date > snapshot) snapshot = date;
|
|
30
|
+
return prefix;
|
|
31
|
+
}
|
|
32
|
+
return match;
|
|
33
|
+
});
|
|
34
|
+
// Keep quantity tokens such as 8b/70b/128k as distinct lanes; they are not release versions.
|
|
35
|
+
const tokens = base.match(/\d+(?:\.\d+)?[bkmt]\b|[\p{L}]+|\d+(?:[._]\d+)*/gu) ?? [];
|
|
36
|
+
const words = tokens.filter((token) => /\p{L}/u.test(token));
|
|
37
|
+
const version = tokens.filter((token) => !/\p{L}/u.test(token)).flatMap((token) => token.split(/[._]/).map((part) => part.replace(/^0+(?=\d)/, "")));
|
|
38
|
+
return {
|
|
39
|
+
model,
|
|
40
|
+
root: words[0] ?? base,
|
|
41
|
+
lane: words.length > 0 && version.length > 0 ? words.join("-") : `opaque:${base}`,
|
|
42
|
+
version,
|
|
43
|
+
snapshot,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Descending numeric tuples, without floating-point loss on unusual catalog ids. */
|
|
48
|
+
function versionOrder(a: CatalogEntry, b: CatalogEntry): number {
|
|
49
|
+
for (let i = 0; i < Math.max(a.version.length, b.version.length); i++) {
|
|
50
|
+
const left = a.version[i] ?? "0";
|
|
51
|
+
const right = b.version[i] ?? "0";
|
|
52
|
+
const order = right.length - left.length || textOrder(right, left);
|
|
53
|
+
if (order) return order;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function representativeOrder(a: CatalogEntry, b: CatalogEntry): number {
|
|
59
|
+
return versionOrder(a, b)
|
|
60
|
+
|| Number(a.snapshot !== undefined) - Number(b.snapshot !== undefined)
|
|
61
|
+
|| textOrder(b.snapshot ?? "", a.snapshot ?? "")
|
|
62
|
+
|| textOrder(a.model.id, b.model.id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function candidateOrder(a: CatalogEntry, b: CatalogEntry, session?: ModelLite): number {
|
|
66
|
+
const active = session ? refKey(session) : undefined;
|
|
67
|
+
return Number(refKey(a.model) !== active) - Number(refKey(b.model) !== active)
|
|
68
|
+
|| Number(a.model.provider !== session?.provider) - Number(b.model.provider !== session?.provider)
|
|
69
|
+
|| textOrder(a.model.provider, b.model.provider)
|
|
70
|
+
|| textOrder(a.root, b.root)
|
|
71
|
+
|| versionOrder(a, b)
|
|
72
|
+
|| textOrder(a.lane, b.lane)
|
|
73
|
+
|| representativeOrder(a, b);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Share a truncated provider's slots across roots; names identify groups, not capabilities. */
|
|
77
|
+
function rotateRoots(candidates: readonly CatalogEntry[], preferredRoot?: string): CatalogEntry[] {
|
|
78
|
+
const byRoot = new Map<string, CatalogEntry[]>();
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
const group = byRoot.get(candidate.root) ?? [];
|
|
81
|
+
group.push(candidate);
|
|
82
|
+
byRoot.set(candidate.root, group);
|
|
83
|
+
}
|
|
84
|
+
const groups = [...byRoot.entries()].sort(([a], [b]) =>
|
|
85
|
+
Number(a !== preferredRoot) - Number(b !== preferredRoot) || textOrder(a, b),
|
|
86
|
+
).map(([, group]) => group);
|
|
87
|
+
const rotated: CatalogEntry[] = [];
|
|
88
|
+
for (let row = 0; rotated.length < candidates.length; row++) {
|
|
89
|
+
for (const group of groups) {
|
|
90
|
+
const candidate = group[row];
|
|
91
|
+
if (candidate) rotated.push(candidate);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return rotated;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Rank full-catalog search matches without collapsing versions or snapshots. */
|
|
98
|
+
export function orderModelCandidates(models: readonly ModelLite[], session?: ModelLite): ModelLite[] {
|
|
99
|
+
const unique = new Map(models.map((model) => [refKey(model), model]));
|
|
100
|
+
return [...unique.values()].map(entry).sort((a, b) => candidateOrder(a, b, session)).map((candidate) => candidate.model);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One representative per provider/version-line/tier, plus the exact active model even when older.
|
|
104
|
+
* Named tiers remain separate without a table of model releases. Opaque ids retain lexical order;
|
|
105
|
+
* within a textual root, numeric generations precede older generations across all its lanes.
|
|
106
|
+
* Only a provider that exceeds its remaining slots rotates roots, with the active root first. */
|
|
107
|
+
export function shortlistModels(models: readonly ModelLite[], session?: ModelLite, max = 40): ModelLite[] {
|
|
108
|
+
const limit = Number.isFinite(max) ? Math.max(0, Math.floor(max)) : 40;
|
|
109
|
+
if (limit === 0) return [];
|
|
110
|
+
const unique = new Map(models.map((model) => [refKey(model), model]));
|
|
111
|
+
const active = session ? unique.get(refKey(session)) : undefined;
|
|
112
|
+
const representatives = new Map<string, CatalogEntry>();
|
|
113
|
+
for (const model of unique.values()) {
|
|
114
|
+
const candidate = entry(model);
|
|
115
|
+
const key = `${model.provider}\0${candidate.lane}`;
|
|
116
|
+
const previous = representatives.get(key);
|
|
117
|
+
if (!previous || representativeOrder(candidate, previous) < 0) representatives.set(key, candidate);
|
|
118
|
+
}
|
|
119
|
+
const ordered = [...representatives.values()].sort((a, b) => candidateOrder(a, b, session));
|
|
120
|
+
const byProvider = new Map<string, CatalogEntry[]>();
|
|
121
|
+
for (const candidate of ordered) {
|
|
122
|
+
if (active && refKey(candidate.model) === refKey(active)) continue;
|
|
123
|
+
const group = byProvider.get(candidate.model.provider) ?? [];
|
|
124
|
+
group.push(candidate);
|
|
125
|
+
byProvider.set(candidate.model.provider, group);
|
|
126
|
+
}
|
|
127
|
+
const selected = active ? [active] : [];
|
|
128
|
+
for (const [provider, candidates] of byProvider) {
|
|
129
|
+
const remaining = limit - selected.length;
|
|
130
|
+
if (remaining === 0) break;
|
|
131
|
+
const balanced = candidates.length > remaining
|
|
132
|
+
? rotateRoots(candidates, provider === active?.provider ? entry(active).root : undefined)
|
|
133
|
+
: candidates;
|
|
134
|
+
selected.push(...balanced.slice(0, remaining).map((candidate) => candidate.model));
|
|
135
|
+
}
|
|
136
|
+
return selected;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Recommendation families use the model name after a namespace, unlike rotation's vendor root.
|
|
140
|
+
* Dots before letters delimit namespace segments; numeric version dots stay in the model name. */
|
|
141
|
+
function recommendationGroup(model: ModelLite): { family: string; major: string | undefined } {
|
|
142
|
+
const id = model.id.toLowerCase();
|
|
143
|
+
let nameStart = 0;
|
|
144
|
+
for (const separator of id.matchAll(/\/|\.(?=\p{L})/gu)) nameStart = separator.index + 1;
|
|
145
|
+
const name = entry({ provider: model.provider, id: id.slice(nameStart) });
|
|
146
|
+
return { family: `${id.slice(0, nameStart)}\0${name.root}`, major: name.version[0] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Advisory neighbors of the current model; full queries and exact pins use the full catalog. */
|
|
150
|
+
export function recommendModels(models: readonly ModelCostMetadata[], session?: ModelLite, max = 40): ModelCostMetadata[] {
|
|
151
|
+
if (!session) return shortlistModels(models, undefined, max);
|
|
152
|
+
const limit = Number.isFinite(max) ? Math.max(0, Math.floor(max)) : 40;
|
|
153
|
+
if (limit === 0) return [];
|
|
154
|
+
const anchor = recommendationGroup(session);
|
|
155
|
+
const neighbors = models.filter((model) => {
|
|
156
|
+
if (model.provider !== session.provider) return false;
|
|
157
|
+
const candidate = recommendationGroup(model);
|
|
158
|
+
return candidate.family === anchor.family && (anchor.major === undefined || candidate.major === anchor.major);
|
|
159
|
+
});
|
|
160
|
+
// Filter before latest-per-lane selection: a newer generation must not erase a neighbor
|
|
161
|
+
// of an older working supervisor. Price bands keep the full uncapped provider reference.
|
|
162
|
+
const selected = shortlistModels(neighbors, session, Number.MAX_SAFE_INTEGER).map(entry);
|
|
163
|
+
const tiers = modelCostTiers(models, shortlistModels(models, undefined, Number.MAX_SAFE_INTEGER));
|
|
164
|
+
const active = refKey(session);
|
|
165
|
+
selected.sort((a, b) =>
|
|
166
|
+
Number(refKey(a.model) !== active) - Number(refKey(b.model) !== active)
|
|
167
|
+
|| modelCostRank(tiers.get(`${a.model.provider}/${a.model.id}`)) - modelCostRank(tiers.get(`${b.model.provider}/${b.model.id}`))
|
|
168
|
+
|| candidateOrder(a, b, session),
|
|
169
|
+
);
|
|
170
|
+
return selected.slice(0, limit).map((candidate) => candidate.model);
|
|
171
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Relative advertised cost within one provider; never capability, throughput, or effort. */
|
|
2
|
+
export type ModelTier = "low" | "medium" | "high";
|
|
3
|
+
|
|
4
|
+
/** Presentation order only: unknown prices follow known provider-relative bands. */
|
|
5
|
+
export function modelCostRank(tier: ModelTier | undefined): number {
|
|
6
|
+
return tier === "low" ? 0 : tier === "medium" ? 1 : tier === "high" ? 2 : 3;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ModelCostMetadata {
|
|
10
|
+
provider: string;
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
reasoning?: boolean;
|
|
14
|
+
contextWindow?: number;
|
|
15
|
+
cost?: { input: number; output: number };
|
|
16
|
+
thinkingLevelMap?: Partial<Record<string, unknown>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rank distinct positive input+output prices within each provider, using an equal-token
|
|
21
|
+
* input/output proxy. The first ceil(n/3) prices are low, through ceil(2n/3) medium,
|
|
22
|
+
* and the rest high. Aliases cannot change the cutoffs. At least three distinct prices
|
|
23
|
+
* are required; missing/zero prices remain unknown. No model names or ids imply a tier.
|
|
24
|
+
* An uncapped reference keeps labels stable across searches; outside prices clamp to
|
|
25
|
+
* its low/high bands. These are catalog comparisons, not account billing estimates.
|
|
26
|
+
*/
|
|
27
|
+
export function modelCostTiers(models: readonly ModelCostMetadata[], reference: readonly ModelCostMetadata[] = models): Map<string, ModelTier> {
|
|
28
|
+
const pricesByProvider = new Map<string, Set<number>>();
|
|
29
|
+
for (const model of reference) {
|
|
30
|
+
const price = priceProxy(model);
|
|
31
|
+
if (price === undefined) continue;
|
|
32
|
+
let prices = pricesByProvider.get(model.provider);
|
|
33
|
+
if (!prices) {
|
|
34
|
+
prices = new Set();
|
|
35
|
+
pricesByProvider.set(model.provider, prices);
|
|
36
|
+
}
|
|
37
|
+
prices.add(price);
|
|
38
|
+
}
|
|
39
|
+
const cutoffs = new Map<string, { low: number; medium: number }>();
|
|
40
|
+
for (const [provider, prices] of pricesByProvider) {
|
|
41
|
+
if (prices.size < 3) continue;
|
|
42
|
+
const sorted = [...prices].sort((a, b) => a - b);
|
|
43
|
+
const low = sorted[Math.ceil(sorted.length / 3) - 1];
|
|
44
|
+
const medium = sorted[Math.ceil(2 * sorted.length / 3) - 1];
|
|
45
|
+
if (low !== undefined && medium !== undefined) cutoffs.set(provider, { low, medium });
|
|
46
|
+
}
|
|
47
|
+
const tiers = new Map<string, ModelTier>();
|
|
48
|
+
for (const model of models) {
|
|
49
|
+
const price = priceProxy(model);
|
|
50
|
+
const bands = cutoffs.get(model.provider);
|
|
51
|
+
if (price === undefined || !bands) continue;
|
|
52
|
+
tiers.set(`${model.provider}/${model.id}`, price <= bands.low ? "low" : price <= bands.medium ? "medium" : "high");
|
|
53
|
+
}
|
|
54
|
+
return tiers;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function positiveFinite(value: number | undefined): value is number {
|
|
58
|
+
return value !== undefined && Number.isFinite(value) && value > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function priceProxy(model: ModelCostMetadata): number | undefined {
|
|
62
|
+
if (!positiveFinite(model.cost?.input) || !positiveFinite(model.cost?.output)) return undefined;
|
|
63
|
+
const price = model.cost.input + model.cost.output;
|
|
64
|
+
if (!positiveFinite(price)) return undefined;
|
|
65
|
+
// Decimal catalog prices can sum to adjacent binary floats; do not invent a new price rank.
|
|
66
|
+
const normalized = Number(price.toPrecision(12));
|
|
67
|
+
return positiveFinite(normalized) ? normalized : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function shortNumber(value: number): string {
|
|
71
|
+
return value.toPrecision(3).replace(/(?:\.0+|(\.\d+?)0+)(?=e|$)/, "$1");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function contextLabel(value: number): string {
|
|
75
|
+
if (value >= 1_000_000) return `${shortNumber(value / 1_000_000)}M`;
|
|
76
|
+
if (value >= 1_000) return `${shortNumber(value / 1_000)}k`;
|
|
77
|
+
return shortNumber(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Host catalog hints; API prices are advertised values, not an OAuth billing estimate. */
|
|
81
|
+
export function modelCandidateDetails(model: ModelCostMetadata, tier?: ModelTier): string {
|
|
82
|
+
const details: string[] = [`cost:${tier ?? "unknown"}`];
|
|
83
|
+
if (model.reasoning === true) details.push("reasoning");
|
|
84
|
+
if (positiveFinite(model.contextWindow)) details.push(`ctx ${contextLabel(model.contextWindow)}`);
|
|
85
|
+
if (positiveFinite(model.cost?.input) && positiveFinite(model.cost?.output)) {
|
|
86
|
+
details.push(`API $${shortNumber(model.cost.input)}/$${shortNumber(model.cost.output)} per 1M in/out`);
|
|
87
|
+
}
|
|
88
|
+
return `[${details.join("; ")}]`;
|
|
89
|
+
}
|
package/src/core/models.ts
CHANGED
|
@@ -46,6 +46,8 @@ export type ModelResolution =
|
|
|
46
46
|
| { ok: false; reason: "unknown" | "ambiguous" | "blocked"; candidates: string[] };
|
|
47
47
|
|
|
48
48
|
const full = (m: ModelLite): string => `${m.provider}/${m.id}`;
|
|
49
|
+
/** Loose-match form of a model ref: lower-case, version dots folded to dashes. */
|
|
50
|
+
const looseKey = (s: string): string => s.toLowerCase().replace(/(\d)\.(?=\d)/g, "$1-");
|
|
49
51
|
|
|
50
52
|
function dedupe(models: ModelLite[]): ModelLite[] {
|
|
51
53
|
const seen = new Set<string>();
|
|
@@ -118,16 +120,23 @@ export function resolveModelRef(
|
|
|
118
120
|
policy?: ModelRoutingPolicy,
|
|
119
121
|
): ModelResolution {
|
|
120
122
|
const wanted = ref.trim();
|
|
121
|
-
const all = models.map(full);
|
|
122
123
|
const exact = models.find((m) => full(m) === wanted);
|
|
123
124
|
// An exact provider-qualified pin is an explicit user choice. Never rewrite or reject it
|
|
124
125
|
// because the current routing policy is intended only for loose automatic selection.
|
|
125
126
|
if (exact) return { ok: true, ref: wanted };
|
|
126
127
|
|
|
127
|
-
// Candidate set: a unique exact id (no provider), else a case-insensitive
|
|
128
|
+
// Candidate set: a unique exact id (no provider), else a loose match — case-insensitive, dots
|
|
129
|
+
// and dashes interchangeable in versions ('opus-4.6' ≡ 'opus-4-6'), and a `provider/` prefix
|
|
130
|
+
// scoping the loose id to that provider's ids ('claude-pro-max-native/opus-4-6').
|
|
128
131
|
const byId = models.filter((m) => m.id === wanted);
|
|
129
|
-
const
|
|
130
|
-
const
|
|
132
|
+
const slash = wanted.indexOf("/");
|
|
133
|
+
const scopedProvider = slash > 0 ? wanted.slice(0, slash) : undefined;
|
|
134
|
+
const scoped = scopedProvider ? models.filter((m) => m.provider === scopedProvider) : [];
|
|
135
|
+
const loose = looseKey(scoped.length > 0 ? wanted.slice(slash + 1) : wanted);
|
|
136
|
+
const pool = scoped.length > 0 ? scoped : models;
|
|
137
|
+
const rawCandidates = dedupe(byId.length > 0 ? byId : pool.filter((m) => looseKey(scoped.length > 0 ? m.id : full(m)).includes(loose)));
|
|
138
|
+
// With nothing to match, the catalog itself is the answer — session provider first.
|
|
139
|
+
const all = [...models.filter((m) => m.provider === preferProvider), ...models.filter((m) => m.provider !== preferProvider)].map(full);
|
|
131
140
|
const candidates = policy ? rawCandidates.filter((m) => isModelProviderAllowed(m, policy)) : rawCandidates;
|
|
132
141
|
if (policy) {
|
|
133
142
|
const allCandidatesArePolicyBlocked = rawCandidates.length > 0 && rawCandidates.every((m) => allowedModelProviders(m.id, policy) !== undefined && !isModelProviderAllowed(m, policy));
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Pure, bounded terminal-output projection. Raw output must never be replayed as terminal commands. */
|
|
2
|
+
export const MONITOR_OUTPUT_TAIL_CHARS = 4_000;
|
|
3
|
+
|
|
4
|
+
/** Neutralize display-direction/line controls in complete monitor text; never mutate raw captures. */
|
|
5
|
+
export function neutralizeMonitorUnicode(text: string): string {
|
|
6
|
+
return text.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").replace(/[\u2028\u2029]/g, " ");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type ControlState = "text" | "escape" | "escapeIntermediate" | "csi" | "osc" | "oscEscape" | "string" | "stringEscape";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One stream's sanitized tail. A fixed-size ring bounds storage even for a giant unterminated
|
|
13
|
+
* line. Escape parsing keeps only a finite state (no pending payload), independently per stream,
|
|
14
|
+
* so split/unterminated OSC, CSI and DCS cannot leak into the surrounding display structure.
|
|
15
|
+
* CR replaces the current progress line; CRLF completes it. This is not a terminal emulator.
|
|
16
|
+
*/
|
|
17
|
+
export class MonitorOutputTail {
|
|
18
|
+
private readonly chars: string[] = [];
|
|
19
|
+
private start = 0;
|
|
20
|
+
private length = 0;
|
|
21
|
+
private state: ControlState = "text";
|
|
22
|
+
private carriageReturn = false;
|
|
23
|
+
|
|
24
|
+
append(chunk: string): void {
|
|
25
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
26
|
+
const char = chunk[i]!;
|
|
27
|
+
const code = chunk.charCodeAt(i);
|
|
28
|
+
if (this.state === "osc" || this.state === "string") {
|
|
29
|
+
if (code === 0x9c || (code === 0x07 && this.state === "osc")) this.state = "text";
|
|
30
|
+
else if (code === 0x1b) this.state = this.state === "osc" ? "oscEscape" : "stringEscape";
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (this.state === "oscEscape" || this.state === "stringEscape") {
|
|
34
|
+
if (char === "\\" || code === 0x9c || (code === 0x07 && this.state === "oscEscape")) this.state = "text";
|
|
35
|
+
else if (code !== 0x1b) this.state = this.state === "oscEscape" ? "osc" : "string";
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (code === 0x1b) { this.state = "escape"; continue; }
|
|
39
|
+
if (this.state === "escape") {
|
|
40
|
+
if (char === "[") this.state = "csi";
|
|
41
|
+
else if (char === "]") this.state = "osc";
|
|
42
|
+
else if (char === "P" || char === "X" || char === "^" || char === "_") this.state = "string";
|
|
43
|
+
else this.state = code >= 0x20 && code <= 0x2f ? "escapeIntermediate" : "text";
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (this.state === "escapeIntermediate" || this.state === "csi") {
|
|
47
|
+
if (code >= (this.state === "csi" ? 0x40 : 0x30) && code <= 0x7e) this.state = "text";
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (code === 0x9b) { this.state = "csi"; continue; }
|
|
51
|
+
if (code === 0x9d) { this.state = "osc"; continue; }
|
|
52
|
+
if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) { this.state = "string"; continue; }
|
|
53
|
+
if (char === "\r") { this.carriageReturn = true; continue; }
|
|
54
|
+
if (char === "\n") { this.carriageReturn = false; this.push("\n"); continue; }
|
|
55
|
+
if (code < 0x20 && code !== 0x09 || code >= 0x7f && code <= 0x9f) continue;
|
|
56
|
+
// Inert display text must not carry bidi direction overrides or Unicode line separators.
|
|
57
|
+
if (code >= 0x202a && code <= 0x202e || code >= 0x2066 && code <= 0x2069 || code === 0x200e || code === 0x200f) continue;
|
|
58
|
+
if (this.carriageReturn) {
|
|
59
|
+
while (this.length > 0 && this.chars[(this.start + this.length - 1) % MONITOR_OUTPUT_TAIL_CHARS] !== "\n") this.length--;
|
|
60
|
+
this.carriageReturn = false;
|
|
61
|
+
}
|
|
62
|
+
this.push(code === 0x09 || code === 0x2028 || code === 0x2029 ? " " : char);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get text(): string {
|
|
67
|
+
let text = Array.from({ length: this.length }, (_, i) => this.chars[(this.start + i) % MONITOR_OUTPUT_TAIL_CHARS]!).join("");
|
|
68
|
+
// Eviction/chunk splits can expose half a surrogate pair: never render that half alone.
|
|
69
|
+
if (text.charCodeAt(0) >= 0xdc00 && text.charCodeAt(0) <= 0xdfff) text = text.slice(1);
|
|
70
|
+
const last = text.charCodeAt(text.length - 1);
|
|
71
|
+
if (last >= 0xd800 && last <= 0xdbff) text = text.slice(0, -1);
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get preview(): string {
|
|
76
|
+
const text = this.text.trimEnd();
|
|
77
|
+
return text.slice(text.lastIndexOf("\n") + 1).trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private push(char: string): void {
|
|
81
|
+
this.chars[(this.start + this.length) % MONITOR_OUTPUT_TAIL_CHARS] = char;
|
|
82
|
+
if (this.length < MONITOR_OUTPUT_TAIL_CHARS) this.length++;
|
|
83
|
+
else this.start = (this.start + 1) % MONITOR_OUTPUT_TAIL_CHARS;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/core/monitor.ts
CHANGED
|
@@ -38,6 +38,13 @@ export interface MonitorEvent {
|
|
|
38
38
|
terminal: boolean;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Raw process output for UI/log observers only; never a semantic wake event. */
|
|
42
|
+
export interface MonitorOutput {
|
|
43
|
+
id: string;
|
|
44
|
+
stream: "stdout" | "stderr";
|
|
45
|
+
text: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
export interface MonitorProcessHandlers {
|
|
42
49
|
onStdout: (chunk: string) => void;
|
|
43
50
|
onStderr: (chunk: string) => void;
|
|
@@ -50,11 +57,22 @@ export interface MonitorProcess {
|
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export interface MonitorManagerDeps {
|
|
60
|
+
/** Internal activation namespace for persisted ids; not a user-facing monitor parameter. */
|
|
61
|
+
idPrefix?: string;
|
|
53
62
|
now: () => number;
|
|
54
63
|
setTimer: (fn: () => void, ms: number) => unknown;
|
|
55
64
|
clearTimer: (handle: unknown) => void;
|
|
56
65
|
start: (spec: MonitorSpec, handlers: MonitorProcessHandlers) => MonitorProcess;
|
|
57
66
|
onEvent: (event: MonitorEvent) => void;
|
|
67
|
+
/** Accepted nonempty chunks, BEFORE semantic processing. Never called after terminal/removal.
|
|
68
|
+
* Observer failures are isolated: display/logging cannot change wakes or process lifetime. */
|
|
69
|
+
onOutput?: (output: MonitorOutput) => void;
|
|
70
|
+
/** Called once per admitted monitor, BEFORE its process starts (never for a rejected spec).
|
|
71
|
+
* Every announced monitor later reaches exactly one of: a terminal `onEvent`, or `onRemoved`. */
|
|
72
|
+
onArmed?: (entry: MonitorEntry) => void;
|
|
73
|
+
/** Called once per monitor dropped WITHOUT a wake event: `cancel`, `cancelAll`, or a start that
|
|
74
|
+
* failed before it ran. A terminal event is never followed by this. */
|
|
75
|
+
onRemoved?: (entry: MonitorEntry) => void;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
export type MonitorArmResult =
|
|
@@ -143,7 +161,7 @@ export class MonitorManager {
|
|
|
143
161
|
const timeoutMs = request.timeoutMs ?? MONITOR_DEFAULT_TIMEOUT_MS;
|
|
144
162
|
const maxEvents = request.maxEvents ?? MONITOR_DEFAULT_MAX_EVENTS;
|
|
145
163
|
const now = this.deps.now();
|
|
146
|
-
const id =
|
|
164
|
+
const id = `${this.deps.idPrefix ?? "monitor"}-${++this.sequence}`;
|
|
147
165
|
const entry: MonitorEntry = {
|
|
148
166
|
id,
|
|
149
167
|
label: request.label.trim(),
|
|
@@ -169,6 +187,7 @@ export class MonitorManager {
|
|
|
169
187
|
lineLength: 0,
|
|
170
188
|
};
|
|
171
189
|
this.monitors.set(id, active);
|
|
190
|
+
this.deps.onArmed?.(copyEntry(entry));
|
|
172
191
|
|
|
173
192
|
const handlers: MonitorProcessHandlers = {
|
|
174
193
|
onStdout: (chunk) => this.receiveStdout(id, chunk),
|
|
@@ -282,6 +301,8 @@ export class MonitorManager {
|
|
|
282
301
|
private receiveStdout(id: string, chunk: string): void {
|
|
283
302
|
const active = this.monitors.get(id);
|
|
284
303
|
if (!active || active.terminal || !chunk) return;
|
|
304
|
+
this.observeOutput(id, "stdout", chunk);
|
|
305
|
+
if (active.terminal) return;
|
|
285
306
|
if (active.entry.mode === "exit") {
|
|
286
307
|
active.stdoutTail = appendTail(active.stdoutTail, chunk, MONITOR_MAX_DIAGNOSTIC_CHARS);
|
|
287
308
|
return;
|
|
@@ -318,10 +339,20 @@ export class MonitorManager {
|
|
|
318
339
|
|
|
319
340
|
private receiveStderr(id: string, chunk: string): void {
|
|
320
341
|
const active = this.monitors.get(id);
|
|
321
|
-
if (!active || active.terminal) return;
|
|
342
|
+
if (!active || active.terminal || !chunk) return;
|
|
343
|
+
this.observeOutput(id, "stderr", chunk);
|
|
344
|
+
if (active.terminal) return;
|
|
322
345
|
active.stderrTail = appendTail(active.stderrTail, chunk, MONITOR_MAX_DIAGNOSTIC_CHARS);
|
|
323
346
|
}
|
|
324
347
|
|
|
348
|
+
private observeOutput(id: string, stream: MonitorOutput["stream"], text: string): void {
|
|
349
|
+
try {
|
|
350
|
+
this.deps.onOutput?.({ id, stream, text });
|
|
351
|
+
} catch {
|
|
352
|
+
/* UI/log observers cannot suppress a semantic event or interrupt process cleanup. */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
325
356
|
private receiveExit(id: string, code: number | null, signal?: string): void {
|
|
326
357
|
const active = this.monitors.get(id);
|
|
327
358
|
if (!active || active.terminal) return;
|
|
@@ -411,5 +442,6 @@ export class MonitorManager {
|
|
|
411
442
|
} catch {
|
|
412
443
|
/* best effort; cancellation remains terminal */
|
|
413
444
|
}
|
|
445
|
+
this.deps.onRemoved?.(copyEntry(active.entry));
|
|
414
446
|
}
|
|
415
447
|
}
|