@sangyaahub/pi-marg 1.0.3 → 1.0.5
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/.omp-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +12 -0
- package/MODEL-SETUP.md +30 -0
- package/README.md +106 -6
- package/adapters/omp/extensions/model-router.ts +188 -16
- package/adapters/pi/extensions/model-router.ts +111 -2
- package/adapters/pi/prompts/auto-models.md +3 -3
- package/agents/auto-intake.md +1 -1
- package/commands/auto-models.md +3 -3
- package/core/model-routing.ts +449 -11
- package/package.json +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/validate.sh +44 -1
- package/skills/auto-mode-router/SKILL.md +1 -1
- package/skills/auto-mode-router/references/intake-and-state.md +3 -0
- package/skills/auto-mode-router/references/models-and-tools.md +9 -0
- package/skills/auto-mode-router/references/session-ledger.md +5 -0
package/core/model-routing.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type AutoStage = "A" | "B" | "C" | "D";
|
|
2
|
+
export type FallbackLevel = "high" | "low";
|
|
2
3
|
export type CandidateTier = "frontier" | "execution" | "general";
|
|
3
4
|
export type RuntimeName = "OMP" | "Pi";
|
|
4
5
|
|
|
@@ -36,14 +37,27 @@ export interface ModelSelection extends ModelCandidate {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface ModelRouteState {
|
|
39
|
-
version:
|
|
40
|
+
version: 2;
|
|
40
41
|
workType?: number;
|
|
41
42
|
selections: Partial<Record<AutoStage, ModelSelection>>;
|
|
43
|
+
fallbacks: Partial<Record<FallbackLevel, ModelSelection>>;
|
|
44
|
+
activeStage?: AutoStage;
|
|
45
|
+
activeFallback?: FallbackLevel;
|
|
46
|
+
exhaustedSelectors?: string[];
|
|
47
|
+
handledFailureKeys?: string[];
|
|
48
|
+
lastFailover?: {
|
|
49
|
+
stage?: AutoStage;
|
|
50
|
+
failedSelector: string;
|
|
51
|
+
fallbackLevel: FallbackLevel;
|
|
52
|
+
activatedSelector: string;
|
|
53
|
+
switchedAt: string;
|
|
54
|
+
};
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
export interface ModelRouteParams {
|
|
45
|
-
action: "catalog" | "select" | "activate" | "status";
|
|
58
|
+
action: "catalog" | "select" | "select_fallback" | "activate" | "status";
|
|
46
59
|
stage?: AutoStage;
|
|
60
|
+
fallback?: FallbackLevel;
|
|
47
61
|
target?: string;
|
|
48
62
|
workType?: number;
|
|
49
63
|
provider?: string;
|
|
@@ -63,8 +77,66 @@ export interface ModelRouteExecution {
|
|
|
63
77
|
response: ReturnType<typeof toolResult>;
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
export interface AutomaticFailoverExecution {
|
|
81
|
+
state: ModelRouteState;
|
|
82
|
+
switched?: {
|
|
83
|
+
level: FallbackLevel;
|
|
84
|
+
selection: ModelSelection;
|
|
85
|
+
};
|
|
86
|
+
exhausted: boolean;
|
|
87
|
+
stateChanged: boolean;
|
|
88
|
+
reusedCurrent?: boolean;
|
|
89
|
+
ignoredDuplicate?: boolean;
|
|
90
|
+
persistenceError?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TerminalUsageFailure {
|
|
94
|
+
role: "assistant";
|
|
95
|
+
stopReason: "error";
|
|
96
|
+
errorMessage: string;
|
|
97
|
+
provider?: string;
|
|
98
|
+
model?: string;
|
|
99
|
+
responseId?: string;
|
|
100
|
+
timestamp?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ToolUsageFailure {
|
|
104
|
+
selector: string;
|
|
105
|
+
key: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
66
108
|
export const MODEL_STATE_TYPE = "co.sangyaa.pi-marg.model-routing.v1";
|
|
67
109
|
export const STAGES: AutoStage[] = ["A", "B", "C", "D"];
|
|
110
|
+
export const FALLBACK_LEVELS: FallbackLevel[] = ["high", "low"];
|
|
111
|
+
|
|
112
|
+
const USAGE_LIMIT_PATTERNS = [
|
|
113
|
+
/usage[\s_-]*limit/i,
|
|
114
|
+
/quota[^\n]{0,40}(?:exceeded|exhausted|reached|depleted|limit)/i,
|
|
115
|
+
/insufficient[_ -]?quota/i,
|
|
116
|
+
/monthly\s+usage\s+limit/i,
|
|
117
|
+
/weekly\s+(?:usage\s+)?limit/i,
|
|
118
|
+
/available\s+balance[^\n]{0,40}(?:\b0\b|depleted|exhausted|insufficient)/i,
|
|
119
|
+
/out\s+of\s+(?:budget|credits?)/i,
|
|
120
|
+
/credits?\s+(?:are\s+)?exhausted/i,
|
|
121
|
+
/agent\s+compute\s+units?[^\n]{0,40}(?:exhausted|depleted|limit|remaining\s*:?\s*0)/i,
|
|
122
|
+
/\bACUs?\b[^\n]*(?:exhausted|limit|remaining\s*:?\s*0)/i,
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const ERROR_TEXT_KEYS = ["error", "errorMessage", "message", "stderr", "output", "content"];
|
|
126
|
+
const ERROR_CONTAINER_KEYS = ["details", "result"];
|
|
127
|
+
const MAX_ERROR_TEXT_LENGTH = 32_000;
|
|
128
|
+
const MAX_ERROR_NODES = 200;
|
|
129
|
+
const MAX_HANDLED_FAILURE_KEYS = 32;
|
|
130
|
+
const EXACT_MODEL_EXECUTION_TOOLS = new Set([
|
|
131
|
+
"task",
|
|
132
|
+
"subagent",
|
|
133
|
+
"spawn_agent",
|
|
134
|
+
"run_agent",
|
|
135
|
+
"agent",
|
|
136
|
+
"workflowz",
|
|
137
|
+
"orchestrate",
|
|
138
|
+
"advisor",
|
|
139
|
+
]);
|
|
68
140
|
|
|
69
141
|
const FRONTIER_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
|
|
70
142
|
["claude-frontier", /(?:claude[^\n]*(?:fable|opus)|(?:fable|opus)[^\n]*claude)/i],
|
|
@@ -79,7 +151,87 @@ const EXECUTION_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
|
|
|
79
151
|
];
|
|
80
152
|
|
|
81
153
|
export function emptyModelRouteState(): ModelRouteState {
|
|
82
|
-
return { version:
|
|
154
|
+
return { version: 2, selections: {}, fallbacks: {} };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isFallbackLevel(value: unknown): value is FallbackLevel {
|
|
158
|
+
return typeof value === "string" && FALLBACK_LEVELS.includes(value as FallbackLevel);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isUsageLimitErrorText(value: unknown): boolean {
|
|
162
|
+
if (typeof value !== "string" || value.trim().length === 0) return false;
|
|
163
|
+
return USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(value));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function hasConfiguredFallback(state: ModelRouteState): boolean {
|
|
167
|
+
const high = state.fallbacks.high;
|
|
168
|
+
const low = state.fallbacks.low;
|
|
169
|
+
return Boolean(
|
|
170
|
+
high
|
|
171
|
+
&& low
|
|
172
|
+
&& high.access === "runtime-model"
|
|
173
|
+
&& low.access === "runtime-model"
|
|
174
|
+
&& high.identity !== low.identity,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function findTerminalUsageLimitFailure(messages: readonly unknown[]): TerminalUsageFailure | undefined {
|
|
179
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
180
|
+
const message = asRecord(messages[index]);
|
|
181
|
+
if (message?.role !== "assistant") continue;
|
|
182
|
+
if (message.stopReason !== "error" || typeof message.errorMessage !== "string") return undefined;
|
|
183
|
+
if (!isUsageLimitErrorText(message.errorMessage)) return undefined;
|
|
184
|
+
return {
|
|
185
|
+
role: "assistant",
|
|
186
|
+
stopReason: "error",
|
|
187
|
+
errorMessage: message.errorMessage,
|
|
188
|
+
...(typeof message.provider === "string" ? { provider: message.provider } : {}),
|
|
189
|
+
...(typeof message.model === "string" ? { model: message.model } : {}),
|
|
190
|
+
...(typeof message.responseId === "string" ? { responseId: message.responseId } : {}),
|
|
191
|
+
...(typeof message.timestamp === "number" ? { timestamp: message.timestamp } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function failedModelSelector(message: TerminalUsageFailure, current?: ModelLike): string {
|
|
198
|
+
if (message.provider && message.model) {
|
|
199
|
+
return `${message.provider}/${message.model}`;
|
|
200
|
+
}
|
|
201
|
+
return current?.provider && current?.id ? `${current.provider}/${current.id}` : "unknown-model";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function modelFailureKey(message: TerminalUsageFailure): string {
|
|
205
|
+
if (message.responseId) return `model:${message.responseId}`;
|
|
206
|
+
return `model:${message.provider ?? "unknown"}/${message.model ?? "unknown"}:${message.timestamp ?? "no-time"}:${message.errorMessage}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function modelToolUsageLimitFailure(event: unknown): ToolUsageFailure | undefined {
|
|
210
|
+
const record = asRecord(event);
|
|
211
|
+
const toolName = typeof record?.toolName === "string" ? record.toolName : "";
|
|
212
|
+
if (record?.isError !== true || !isModelExecutionTool(toolName)) return undefined;
|
|
213
|
+
if (!isUsageLimitErrorText(boundedErrorText(record.result))) return undefined;
|
|
214
|
+
return {
|
|
215
|
+
selector: isDevinExecutionTool(toolName) ? "devin/external-session" : `tool/${toolName}`,
|
|
216
|
+
key: `tool:${typeof record.toolCallId === "string" ? record.toolCallId : `${toolName}:${boundedErrorText(record.result)}`}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function formatFailoverRecoveryMessage(
|
|
221
|
+
state: ModelRouteState,
|
|
222
|
+
failedSelector: string,
|
|
223
|
+
level: FallbackLevel,
|
|
224
|
+
target: string,
|
|
225
|
+
reusedCurrent = false,
|
|
226
|
+
): string {
|
|
227
|
+
const stage = state.activeStage ? `Stage ${state.activeStage}` : "the active PiMarg stage";
|
|
228
|
+
return [
|
|
229
|
+
`[PiMarg auto-failover] ${failedSelector} reached its usage limit.`,
|
|
230
|
+
reusedCurrent
|
|
231
|
+
? `Kept the active ${level} backup: ${target}.`
|
|
232
|
+
: `Switched to the ${level} backup: ${target}.`,
|
|
233
|
+
`Resume the current ${stage} work from the last safe point. Inspect existing files, tool results, and external state before repeating any side effect. Do not restart intake or the approved plan.`,
|
|
234
|
+
].join("\n");
|
|
83
235
|
}
|
|
84
236
|
|
|
85
237
|
export function isStage(value: unknown): value is AutoStage {
|
|
@@ -211,6 +363,13 @@ export function buildModelCatalog(models: ModelLike[], hasExternalDevin: boolean
|
|
|
211
363
|
} satisfies Record<AutoStage, ModelCandidate[]>;
|
|
212
364
|
}
|
|
213
365
|
|
|
366
|
+
export function buildFallbackCatalog(catalog: Record<AutoStage, ModelCandidate[]>) {
|
|
367
|
+
return {
|
|
368
|
+
high: catalog.A.filter((candidate) => candidate.access === "runtime-model"),
|
|
369
|
+
low: catalog.B.filter((candidate) => candidate.access === "runtime-model"),
|
|
370
|
+
} satisfies Record<FallbackLevel, ModelCandidate[]>;
|
|
371
|
+
}
|
|
372
|
+
|
|
214
373
|
export function numberedOptionLabel(index: number, text: string): string {
|
|
215
374
|
return `${index + 1}. ${text}`;
|
|
216
375
|
}
|
|
@@ -305,6 +464,7 @@ export function formatCatalogPresentation(
|
|
|
305
464
|
for (const note of notes) lines.push(`- ${note}`);
|
|
306
465
|
lines.push("- Present every provider as 1,2,… then every model for the chosen provider as 1,2,… Never omit a logged-in provider.");
|
|
307
466
|
lines.push("- Select with action=select using the exact selector string.");
|
|
467
|
+
lines.push("- Also save two distinct runtime-only backups with action=select_fallback: high first, then low.");
|
|
308
468
|
|
|
309
469
|
return {
|
|
310
470
|
text: lines.join("\n"),
|
|
@@ -329,27 +489,152 @@ export function validateDistinctSelection(
|
|
|
329
489
|
export function restoreModelRouteState(entries: readonly any[]): ModelRouteState {
|
|
330
490
|
let restored = emptyModelRouteState();
|
|
331
491
|
for (const entry of entries) {
|
|
332
|
-
if (entry?.type
|
|
333
|
-
|
|
492
|
+
if (entry?.type !== "custom" || entry.customType !== MODEL_STATE_TYPE) continue;
|
|
493
|
+
if (entry.data?.version === 2) {
|
|
494
|
+
restored = {
|
|
495
|
+
...entry.data,
|
|
496
|
+
version: 2,
|
|
497
|
+
selections: entry.data.selections ?? {},
|
|
498
|
+
fallbacks: entry.data.fallbacks ?? {},
|
|
499
|
+
} as ModelRouteState;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (entry.data?.version === 1) {
|
|
503
|
+
restored = {
|
|
504
|
+
version: 2,
|
|
505
|
+
workType: entry.data.workType,
|
|
506
|
+
selections: entry.data.selections ?? {},
|
|
507
|
+
fallbacks: {},
|
|
508
|
+
};
|
|
334
509
|
}
|
|
335
510
|
}
|
|
336
511
|
return restored;
|
|
337
512
|
}
|
|
338
513
|
|
|
514
|
+
export async function executeAutomaticFailover(
|
|
515
|
+
currentState: ModelRouteState,
|
|
516
|
+
failedSelector: string,
|
|
517
|
+
port: ModelRoutePort,
|
|
518
|
+
failureKey?: string,
|
|
519
|
+
): Promise<AutomaticFailoverExecution> {
|
|
520
|
+
const failed = failedSelector.trim() || "unknown-model";
|
|
521
|
+
const handledFailureKeys = currentState.handledFailureKeys ?? [];
|
|
522
|
+
if (failureKey && handledFailureKeys.includes(failureKey)) {
|
|
523
|
+
return {
|
|
524
|
+
state: currentState,
|
|
525
|
+
exhausted: false,
|
|
526
|
+
stateChanged: false,
|
|
527
|
+
ignoredDuplicate: true,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
const nextHandledFailureKeys = failureKey
|
|
531
|
+
? [...handledFailureKeys, failureKey].slice(-MAX_HANDLED_FAILURE_KEYS)
|
|
532
|
+
: handledFailureKeys;
|
|
533
|
+
const exhausted = new Set(currentState.exhaustedSelectors ?? []);
|
|
534
|
+
exhausted.add(failed);
|
|
535
|
+
const activeLevel = currentState.activeFallback;
|
|
536
|
+
const activeSelection = activeLevel ? currentState.fallbacks[activeLevel] : undefined;
|
|
537
|
+
|
|
538
|
+
if (activeLevel && activeSelection && activeSelection.selector !== failed) {
|
|
539
|
+
const nextState: ModelRouteState = {
|
|
540
|
+
...currentState,
|
|
541
|
+
handledFailureKeys: nextHandledFailureKeys,
|
|
542
|
+
exhaustedSelectors: [...exhausted],
|
|
543
|
+
};
|
|
544
|
+
const persistenceError = persistFailoverState(port, nextState);
|
|
545
|
+
return {
|
|
546
|
+
state: nextState,
|
|
547
|
+
switched: { level: activeLevel, selection: activeSelection },
|
|
548
|
+
exhausted: false,
|
|
549
|
+
stateChanged: true,
|
|
550
|
+
reusedCurrent: true,
|
|
551
|
+
...(persistenceError ? { persistenceError } : {}),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const startingIndex = activeLevel ? FALLBACK_LEVELS.indexOf(activeLevel) + 1 : 0;
|
|
556
|
+
|
|
557
|
+
for (const level of FALLBACK_LEVELS.slice(startingIndex)) {
|
|
558
|
+
const selection = currentState.fallbacks[level];
|
|
559
|
+
if (!selection || selection.access !== "runtime-model" || exhausted.has(selection.selector)) continue;
|
|
560
|
+
const model = port.models.find((item) => `${item.provider}/${item.id}` === selection.selector);
|
|
561
|
+
if (!model) {
|
|
562
|
+
exhausted.add(selection.selector);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
let activated: boolean | void;
|
|
566
|
+
try {
|
|
567
|
+
activated = await port.activate(model);
|
|
568
|
+
} catch {
|
|
569
|
+
exhausted.add(selection.selector);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (activated === false) {
|
|
573
|
+
exhausted.add(selection.selector);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const nextState: ModelRouteState = {
|
|
578
|
+
...currentState,
|
|
579
|
+
version: 2,
|
|
580
|
+
activeFallback: level,
|
|
581
|
+
exhaustedSelectors: [...exhausted],
|
|
582
|
+
handledFailureKeys: nextHandledFailureKeys,
|
|
583
|
+
lastFailover: {
|
|
584
|
+
stage: currentState.activeStage,
|
|
585
|
+
failedSelector: failed,
|
|
586
|
+
fallbackLevel: level,
|
|
587
|
+
activatedSelector: selection.selector,
|
|
588
|
+
switchedAt: port.now?.() ?? new Date().toISOString(),
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
const persistenceError = persistFailoverState(port, nextState);
|
|
592
|
+
return {
|
|
593
|
+
state: nextState,
|
|
594
|
+
switched: { level, selection },
|
|
595
|
+
exhausted: false,
|
|
596
|
+
stateChanged: true,
|
|
597
|
+
...(persistenceError ? { persistenceError } : {}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const exhaustedSelectors = [...exhausted];
|
|
602
|
+
const priorExhausted = new Set(currentState.exhaustedSelectors ?? []);
|
|
603
|
+
const stateChanged = exhaustedSelectors.length !== priorExhausted.size
|
|
604
|
+
|| exhaustedSelectors.some((selector) => !priorExhausted.has(selector))
|
|
605
|
+
|| nextHandledFailureKeys.length !== handledFailureKeys.length;
|
|
606
|
+
if (!stateChanged) {
|
|
607
|
+
return { state: currentState, exhausted: true, stateChanged: false };
|
|
608
|
+
}
|
|
609
|
+
const nextState: ModelRouteState = {
|
|
610
|
+
...currentState,
|
|
611
|
+
version: 2,
|
|
612
|
+
exhaustedSelectors,
|
|
613
|
+
handledFailureKeys: nextHandledFailureKeys,
|
|
614
|
+
};
|
|
615
|
+
const persistenceError = persistFailoverState(port, nextState);
|
|
616
|
+
return {
|
|
617
|
+
state: nextState,
|
|
618
|
+
exhausted: true,
|
|
619
|
+
stateChanged: true,
|
|
620
|
+
...(persistenceError ? { persistenceError } : {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
339
624
|
export async function executeModelRoute(
|
|
340
625
|
currentState: ModelRouteState,
|
|
341
626
|
params: ModelRouteParams,
|
|
342
627
|
port: ModelRoutePort,
|
|
343
628
|
): Promise<ModelRouteExecution> {
|
|
344
|
-
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
345
|
-
|
|
346
629
|
if (params.action === "catalog") {
|
|
630
|
+
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
631
|
+
const fallbackCatalog = buildFallbackCatalog(catalog);
|
|
347
632
|
if (port.models.length === 0) {
|
|
348
633
|
return {
|
|
349
634
|
state: currentState,
|
|
350
635
|
response: toolResult(
|
|
351
636
|
emptyCatalogSetupMessage(port.runtimeName),
|
|
352
|
-
{ runtime: port.runtimeName, catalog, state: currentState },
|
|
637
|
+
{ runtime: port.runtimeName, catalog, fallbackCatalog, state: currentState },
|
|
353
638
|
true,
|
|
354
639
|
),
|
|
355
640
|
};
|
|
@@ -377,6 +662,7 @@ export async function executeModelRoute(
|
|
|
377
662
|
runtime: port.runtimeName,
|
|
378
663
|
requiredStages: formatted.presentation.requiredStages,
|
|
379
664
|
catalog,
|
|
665
|
+
fallbackCatalog,
|
|
380
666
|
presentation: formatted.presentation,
|
|
381
667
|
state: currentState,
|
|
382
668
|
}),
|
|
@@ -387,12 +673,59 @@ export async function executeModelRoute(
|
|
|
387
673
|
return { state: currentState, response: toolResult(JSON.stringify(currentState, null, 2), { state: currentState }) };
|
|
388
674
|
}
|
|
389
675
|
|
|
676
|
+
if (params.action === "select_fallback") {
|
|
677
|
+
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
678
|
+
const fallbackCatalog = buildFallbackCatalog(catalog);
|
|
679
|
+
if (!isFallbackLevel(params.fallback)) {
|
|
680
|
+
return { state: currentState, response: toolResult("A fallback level of high or low is required.", { state: currentState }, true) };
|
|
681
|
+
}
|
|
682
|
+
if (typeof params.target !== "string") {
|
|
683
|
+
return { state: currentState, response: toolResult("Select an exact runtime selector from the current catalog.", { fallbackCatalog, state: currentState }, true) };
|
|
684
|
+
}
|
|
685
|
+
const level = params.fallback;
|
|
686
|
+
if (level === "low" && !currentState.fallbacks.high) {
|
|
687
|
+
return {
|
|
688
|
+
state: currentState,
|
|
689
|
+
response: toolResult("Select the high fallback before the low fallback so the recovery order is explicit.", { fallbackCatalog, state: currentState }, true),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const selected = fallbackCatalog[level].find((item) => item.selector === params.target);
|
|
693
|
+
if (!selected) {
|
|
694
|
+
return { state: currentState, response: toolResult(`Target ${params.target} is not an available ${level} fallback choice. Refresh the catalog.`, { fallbackCatalog, state: currentState }, true) };
|
|
695
|
+
}
|
|
696
|
+
const otherLevel: FallbackLevel = level === "high" ? "low" : "high";
|
|
697
|
+
const other = currentState.fallbacks[otherLevel];
|
|
698
|
+
if (other?.identity === selected.identity) {
|
|
699
|
+
return { state: currentState, response: toolResult(`The ${level} fallback must use a different model from the ${otherLevel} fallback.`, { fallbackCatalog, state: currentState }, true) };
|
|
700
|
+
}
|
|
701
|
+
const nextState: ModelRouteState = {
|
|
702
|
+
...currentState,
|
|
703
|
+
version: 2,
|
|
704
|
+
fallbacks: {
|
|
705
|
+
...currentState.fallbacks,
|
|
706
|
+
[level]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
|
|
707
|
+
},
|
|
708
|
+
activeFallback: undefined,
|
|
709
|
+
exhaustedSelectors: [],
|
|
710
|
+
handledFailureKeys: [],
|
|
711
|
+
};
|
|
712
|
+
port.persist(nextState);
|
|
713
|
+
return {
|
|
714
|
+
state: nextState,
|
|
715
|
+
response: toolResult(
|
|
716
|
+
`Saved ${selected.label} (${selected.selector}) as the ${level} fallback. It will activate only after a usage-limit failure.`,
|
|
717
|
+
{ state: nextState, selected, fallback: level },
|
|
718
|
+
),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
390
722
|
if (!isStage(params.stage)) {
|
|
391
723
|
return { state: currentState, response: toolResult("A valid stage A, B, C, or D is required.", { state: currentState }, true) };
|
|
392
724
|
}
|
|
393
725
|
|
|
394
726
|
const stage = params.stage;
|
|
395
727
|
if (params.action === "select") {
|
|
728
|
+
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
396
729
|
if (typeof params.target !== "string") {
|
|
397
730
|
return { state: currentState, response: toolResult("Select an exact selector from the current catalog.", { catalog, state: currentState }, true) };
|
|
398
731
|
}
|
|
@@ -412,12 +745,17 @@ export async function executeModelRoute(
|
|
|
412
745
|
|
|
413
746
|
const workType = Number(params.workType);
|
|
414
747
|
const nextState: ModelRouteState = {
|
|
415
|
-
|
|
748
|
+
...currentState,
|
|
749
|
+
version: 2,
|
|
416
750
|
workType: Number.isInteger(workType) && workType >= 1 && workType <= 7 ? workType : currentState.workType,
|
|
417
751
|
selections: {
|
|
418
752
|
...currentState.selections,
|
|
419
753
|
[stage]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
|
|
420
754
|
},
|
|
755
|
+
activeStage: stage,
|
|
756
|
+
activeFallback: undefined,
|
|
757
|
+
exhaustedSelectors: [],
|
|
758
|
+
handledFailureKeys: [],
|
|
421
759
|
};
|
|
422
760
|
port.persist(nextState);
|
|
423
761
|
return {
|
|
@@ -437,13 +775,113 @@ export async function executeModelRoute(
|
|
|
437
775
|
}
|
|
438
776
|
if (selected.access === "external-agent") {
|
|
439
777
|
if (!port.hasExternalDevin) return { state: currentState, response: toolResult("Devin was selected but no external Devin tool is available.", { state: currentState }, true) };
|
|
440
|
-
|
|
778
|
+
const nextState: ModelRouteState = {
|
|
779
|
+
...currentState,
|
|
780
|
+
activeStage: stage,
|
|
781
|
+
activeFallback: undefined,
|
|
782
|
+
exhaustedSelectors: [],
|
|
783
|
+
handledFailureKeys: [],
|
|
784
|
+
};
|
|
785
|
+
port.persist(nextState);
|
|
786
|
+
return { state: nextState, response: toolResult(`Stage ${stage} uses an external Devin session. Delegate only after post-web approval.`, { state: nextState, selected }) };
|
|
441
787
|
}
|
|
442
788
|
const model = port.models.find((item) => `${item.provider}/${item.id}` === selected.selector);
|
|
443
789
|
if (!model) return { state: currentState, response: toolResult(`Saved model ${selected.selector} is unavailable. Select a replacement.`, { state: currentState }, true) };
|
|
444
790
|
const activated = await port.activate(model);
|
|
445
791
|
if (activated === false) return { state: currentState, response: toolResult(`Could not activate ${selected.selector}; verify provider authentication.`, { state: currentState }, true) };
|
|
446
|
-
|
|
792
|
+
const nextState: ModelRouteState = {
|
|
793
|
+
...currentState,
|
|
794
|
+
activeStage: stage,
|
|
795
|
+
activeFallback: undefined,
|
|
796
|
+
exhaustedSelectors: [],
|
|
797
|
+
handledFailureKeys: [],
|
|
798
|
+
};
|
|
799
|
+
port.persist(nextState);
|
|
800
|
+
return { state: nextState, response: toolResult(`Activated ${selected.label} (${selected.selector}) for Stage ${stage}.`, { state: nextState, selected }) };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function boundedErrorText(value: unknown): string {
|
|
804
|
+
const parts: string[] = [];
|
|
805
|
+
const seen = new Set<object>();
|
|
806
|
+
let remaining = MAX_ERROR_TEXT_LENGTH;
|
|
807
|
+
let nodes = 0;
|
|
808
|
+
|
|
809
|
+
const append = (text: string) => {
|
|
810
|
+
if (remaining <= 0 || text.length === 0) return;
|
|
811
|
+
const fragment = text.slice(0, remaining);
|
|
812
|
+
parts.push(fragment);
|
|
813
|
+
remaining -= fragment.length;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const visit = (item: unknown, includePlainText = true) => {
|
|
817
|
+
if (remaining <= 0 || nodes >= MAX_ERROR_NODES || item == null) return;
|
|
818
|
+
nodes += 1;
|
|
819
|
+
if (typeof item === "string") {
|
|
820
|
+
if (includePlainText) append(item);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (typeof item !== "object") {
|
|
824
|
+
if (includePlainText) append(String(item));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (seen.has(item)) return;
|
|
828
|
+
seen.add(item);
|
|
829
|
+
|
|
830
|
+
if (item instanceof Error) {
|
|
831
|
+
append(item.message);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (Array.isArray(item)) {
|
|
835
|
+
for (const child of item) visit(child, includePlainText);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const record = item as Record<string, unknown>;
|
|
840
|
+
if (includePlainText && record.type === "text" && typeof record.text === "string") append(record.text);
|
|
841
|
+
for (const key of ERROR_TEXT_KEYS) {
|
|
842
|
+
if (!(key in record)) continue;
|
|
843
|
+
try {
|
|
844
|
+
visit(record[key], true);
|
|
845
|
+
} catch {
|
|
846
|
+
// Ignore getters that throw while inspecting an external tool error.
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const key of ERROR_CONTAINER_KEYS) {
|
|
850
|
+
if (!(key in record)) continue;
|
|
851
|
+
try {
|
|
852
|
+
visit(record[key], false);
|
|
853
|
+
} catch {
|
|
854
|
+
// Ignore getters that throw while inspecting an external tool error.
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
visit(value);
|
|
860
|
+
return parts.join("\n");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
864
|
+
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function isDevinExecutionTool(toolName: string): boolean {
|
|
868
|
+
return /^(?:mcp__)?devin(?:__|_|$)/i.test(toolName);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function isModelExecutionTool(toolName: string): boolean {
|
|
872
|
+
const normalized = toolName.toLowerCase();
|
|
873
|
+
return EXACT_MODEL_EXECUTION_TOOLS.has(normalized)
|
|
874
|
+
|| /^(?:spawn_agent|run_agent|subagent)(?:__|_|$)/.test(normalized)
|
|
875
|
+
|| /^(?:mcp__)?(?:devin|workflowz)(?:__|_|$)/.test(normalized);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function persistFailoverState(port: ModelRoutePort, state: ModelRouteState): string | undefined {
|
|
879
|
+
try {
|
|
880
|
+
port.persist(state);
|
|
881
|
+
return undefined;
|
|
882
|
+
} catch (error) {
|
|
883
|
+
return error instanceof Error ? error.message : String(error);
|
|
884
|
+
}
|
|
447
885
|
}
|
|
448
886
|
|
|
449
887
|
function candidate(
|
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -71,4 +71,4 @@ if [[ "${runtime}" == "omp" || "${runtime}" == "both" ]]; then
|
|
|
71
71
|
omp plugin install --scope user pi-marg@pi-marg-marketplace
|
|
72
72
|
fi
|
|
73
73
|
|
|
74
|
-
echo "PiMarg v1.0.
|
|
74
|
+
echo "PiMarg v1.0.5 installed for ${runtime}. Start a fresh runtime session, then run /pi-marg."
|
package/scripts/validate.sh
CHANGED
|
@@ -14,10 +14,38 @@ done
|
|
|
14
14
|
|
|
15
15
|
bun -e '
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
|
+
const isMapping = (value) =>
|
|
18
|
+
value !== null && !Array.isArray(value) && typeof value === "object";
|
|
19
|
+
|
|
20
|
+
function assertNpmPublishWorkflow(workflow, path) {
|
|
21
|
+
const publish = workflow.jobs.publish;
|
|
22
|
+
const steps = publish?.steps;
|
|
23
|
+
const setupNode = steps?.find((step) => step?.uses === "actions/setup-node@v7");
|
|
24
|
+
const commands = steps?.map((step) => step?.run).filter(Boolean) ?? [];
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
!isMapping(workflow.on) ||
|
|
28
|
+
!("workflow_dispatch" in workflow.on) ||
|
|
29
|
+
workflow.permissions?.contents !== "read" ||
|
|
30
|
+
workflow.permissions?.["id-token"] !== "write" ||
|
|
31
|
+
!isMapping(publish) ||
|
|
32
|
+
publish.if !== "github.ref == \u0027refs/heads/main\u0027" ||
|
|
33
|
+
publish.environment !== "npm-publish" ||
|
|
34
|
+
publish["runs-on"] !== "ubuntu-latest" ||
|
|
35
|
+
!Array.isArray(steps) ||
|
|
36
|
+
setupNode?.with?.["node-version"] !== 24 ||
|
|
37
|
+
setupNode?.with?.["registry-url"] !== "https://registry.npmjs.org" ||
|
|
38
|
+
setupNode?.with?.["package-manager-cache"] !== false ||
|
|
39
|
+
!commands.includes("npm publish --provenance")
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(`Unsafe npm publishing workflow: ${path}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
17
45
|
const packageJson = await Bun.file("package.json").json();
|
|
18
46
|
const marketplace = await Bun.file(".omp-plugin/marketplace.json").json();
|
|
19
47
|
if (packageJson.name !== "@sangyaahub/pi-marg") throw new Error("Unexpected package name");
|
|
20
|
-
if (packageJson.version !== "1.0.
|
|
48
|
+
if (packageJson.version !== "1.0.5") throw new Error("package.json must be v1.0.5");
|
|
21
49
|
if (packageJson.license !== "MIT") throw new Error("package.json must declare MIT");
|
|
22
50
|
if (packageJson.author !== "Sangyaa") throw new Error("Unexpected package author");
|
|
23
51
|
if (!packageJson.keywords?.includes("pi-package")) throw new Error("Pi catalog keyword is missing");
|
|
@@ -60,6 +88,21 @@ bun -e '
|
|
|
60
88
|
throw new Error(`Invalid skill frontmatter: ${path}`);
|
|
61
89
|
}
|
|
62
90
|
}
|
|
91
|
+
for await (const path of new Bun.Glob(".github/workflows/*.{yml,yaml}").scan(".")) {
|
|
92
|
+
const text = await Bun.file(path).text();
|
|
93
|
+
const workflow = Bun.YAML.parse(text);
|
|
94
|
+
if (
|
|
95
|
+
!isMapping(workflow) ||
|
|
96
|
+
typeof workflow.name !== "string" ||
|
|
97
|
+
!("on" in workflow) ||
|
|
98
|
+
!isMapping(workflow.jobs)
|
|
99
|
+
) {
|
|
100
|
+
throw new Error(`Invalid GitHub Actions workflow structure: ${path}`);
|
|
101
|
+
}
|
|
102
|
+
if (path === ".github/workflows/npm-deploy.yml") {
|
|
103
|
+
assertNpmPublishWorkflow(workflow, path);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
63
106
|
for await (const path of new Bun.Glob("**/*").scan({ cwd: ".", onlyFiles: true })) {
|
|
64
107
|
if (path === "scripts/validate.sh") continue;
|
|
65
108
|
if (/\.(?:png|jpe?g|gif|webp)$/i.test(path)) continue;
|
|
@@ -10,7 +10,7 @@ Turn a user request into one controlled workflow.
|
|
|
10
10
|
1. Read [intake-and-state.md](references/intake-and-state.md) and complete the intake stages in order.
|
|
11
11
|
2. After the user selects mode 1–4, read [routing-matrix.md](references/routing-matrix.md) and select exactly one primary skill spine.
|
|
12
12
|
3. Read [session-ledger.md](references/session-ledger.md), create the neutral Markdown ledger, and identify the selected spine's authoritative planning artifact.
|
|
13
|
-
4. Read [models-and-tools.md](references/models-and-tools.md). Call `auto_model_route` with `action: catalog` and the confirmed work type, present the live choices, and save each required A/B/C/D selection. The runtime rejects A=C and B=D.
|
|
13
|
+
4. Read [models-and-tools.md](references/models-and-tools.md). Call `auto_model_route` with `action: catalog` and the confirmed work type, present the live choices, and save each required A/B/C/D selection. The runtime rejects A=C and B=D. Then save two distinct runtime-only recovery models with `action: select_fallback`: high first, low second.
|
|
14
14
|
5. Call `auto_runtime_status`. If it reports `OMP`, read [native-omp-runtime.md](references/native-omp-runtime.md); if it reports `Pi`, read [native-pi-runtime.md](references/native-pi-runtime.md). Record which advisor, task, review, checkpoint/rewind, code-intelligence, debugger, browser/desktop, security, and memory routes are actually available.
|
|
15
15
|
6. For work types 2–6, load `codegraph-first` and pass its bootstrap gate before making repository-wide architecture or impact claims. CodeGraph owns those claims when available. LSP, AST, compiler, and repository-native tools may handle precise operations; missing CodeGraph must be recorded rather than concealed behind broad grep output.
|
|
16
16
|
7. Load `auto-mode-addons` only for requested or configured Ponytail, Hindsight, language-specific, or cost-control support. An add-on never becomes a second workflow spine.
|
|
@@ -80,6 +80,8 @@ Ask only for stages required by the current work type. Work types 2–3 normally
|
|
|
80
80
|
|
|
81
81
|
Use `action: select` for each answer with the **exact selector** string (for example `devin/swe-2`), not the display number alone. The active adapter saves exact selectors in session state and immediately activates the selected runtime model. Native `devin/swe-*` selectors behave like other models. An external Devin session requires `post-web` approval before creation.
|
|
82
82
|
|
|
83
|
+
After the stage models, choose two distinct authenticated runtime models from the same live catalog: a strong **high** backup and an economical **low** backup. Save them with `action: select_fallback`. Do not choose an external-agent route as a backup because automatic recovery requires `setModel` in the current session.
|
|
84
|
+
|
|
83
85
|
## Stage 5 — native runtime and supporting capabilities
|
|
84
86
|
|
|
85
87
|
Call `auto_runtime_status` before making a recommendation. Distinguish `available`, `enabled`, and `used`; a tool installed but gated off did not run.
|
|
@@ -109,6 +111,7 @@ type: 1..7
|
|
|
109
111
|
mode: 1..4
|
|
110
112
|
spine: compound-engineering | superpowers | gsd | none
|
|
111
113
|
models: A=<selector> B=<selector|n/a> C=<selector> D=<selector|n/a>
|
|
114
|
+
fallbacks: high=<runtime-selector> low=<different-runtime-selector> active=<none|high|low|exhausted>
|
|
112
115
|
runtime: advisor=<off|available|on|unavailable> orchestration=<serial|task|workflowz> checkpoint=<off|available|used>
|
|
113
116
|
addons: ponytail=<off|lite|full> memory=<off|local|hindsight-recall|hindsight-retain> language=<names|none> cost=<economy|balanced|quality>
|
|
114
117
|
verification: tests=<planned|n/a> dap=<planned|n/a> ui=<browser|desktop|n/a> security=<planned|n/a>
|