@cortexkit/opencode-magic-context 0.38.1 → 0.39.0
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/README.md +37 -4
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +11 -6
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/raw-loader.d.ts +42 -0
- package/dist/config/raw-loader.d.ts.map +1 -0
- package/dist/config/schema/magic-context.d.ts +585 -550
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
- package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
- package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
- package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
- package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
- package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +11 -0
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts +4 -0
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
- package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +12 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
- package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
- package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
- package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-transport.d.ts +12 -0
- package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
- package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
- package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +4 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +9 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
- package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
- package/dist/index-ehpzdy0v.js +126 -0
- package/dist/{index-5znjkjws.js → index-k2ts0wk1.js} +486 -106
- package/dist/{index-22mw8km4.js → index-rw0qrkvq.js} +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17186 -14853
- package/dist/model-resolution-r9jbcz86.js +15 -0
- package/dist/plugin/dream-timer.d.ts +3 -0
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/messages-transform.d.ts +14 -6
- package/dist/plugin/messages-transform.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-p030kxtc.js → recomp-orchestrator-2pncxee7.js} +2 -3
- package/dist/shared/jsonc-edit.d.ts +6 -1
- package/dist/shared/jsonc-edit.d.ts.map +1 -1
- package/dist/shared/model-resolution.d.ts +53 -0
- package/dist/shared/model-resolution.d.ts.map +1 -0
- package/dist/shared/model-suggestion-retry.d.ts +4 -1
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/resolve-fallbacks.d.ts +5 -1
- package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
- package/dist/shared/status-detail-text.d.ts +4 -0
- package/dist/shared/status-detail-text.d.ts.map +1 -0
- package/dist/shared/subagent-runner.d.ts +2 -1
- package/dist/shared/subagent-runner.d.ts.map +1 -1
- package/dist/{storage-meta-session-2306t3j7.js → storage-meta-session-yvtbaw7c.js} +1 -1
- package/dist/{task-config-at3adc59.js → task-config-a1qmj9bx.js} +2 -2
- package/package.json +4 -2
- package/src/config/index.test.ts +73 -17
- package/src/config/index.ts +38 -23
- package/src/config/project-security.hostile-matrix.test.ts +471 -0
- package/src/config/project-security.test.ts +26 -5
- package/src/config/project-security.ts +141 -22
- package/src/config/raw-loader.test.ts +424 -0
- package/src/config/raw-loader.ts +490 -0
- package/src/config/schema/magic-context.test.ts +144 -2
- package/src/config/schema/magic-context.ts +253 -49
- package/src/shared/jsonc-edit.ts +74 -1
- package/src/shared/model-resolution.test.ts +170 -0
- package/src/shared/model-resolution.ts +227 -0
- package/src/shared/model-suggestion-retry.test.ts +27 -0
- package/src/shared/model-suggestion-retry.ts +19 -10
- package/src/shared/resolve-fallbacks.test.ts +13 -1
- package/src/shared/resolve-fallbacks.ts +19 -4
- package/src/shared/status-detail-text.test.ts +80 -0
- package/src/shared/status-detail-text.ts +56 -0
- package/src/shared/subagent-runner.ts +3 -1
- package/dist/index-td9depx5.js +0 -46
- package/dist/resolve-fallbacks-qf0ekmz9.js +0 -11
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
export type ModelHarness = "opencode" | "pi";
|
|
2
|
+
|
|
3
|
+
export interface ResolvedModelEntry {
|
|
4
|
+
/** Canonical provider/model reference used for identity and model selection. */
|
|
5
|
+
model: string;
|
|
6
|
+
/** OpenCode variant or Pi thinking level, selected for the active entry only. */
|
|
7
|
+
qualifier?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ModelInput = string | ResolvedModelEntry;
|
|
11
|
+
|
|
12
|
+
type ConfigRecord = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
function asRecord(value: unknown): ConfigRecord | undefined {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
16
|
+
? (value as ConfigRecord)
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readString(value: unknown): string | undefined {
|
|
21
|
+
if (typeof value !== "string") return undefined;
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasOwn(record: ConfigRecord, key: string): boolean {
|
|
27
|
+
return Object.hasOwn(record, key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collapses the string and object spellings of a harness model entry into the
|
|
32
|
+
* same model identity. Qualifiers stay separate because they alter an attempt,
|
|
33
|
+
* not the configured model identity.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeModelEntry(
|
|
36
|
+
entry: unknown,
|
|
37
|
+
harness: ModelHarness,
|
|
38
|
+
): ResolvedModelEntry | undefined {
|
|
39
|
+
if (typeof entry === "string") {
|
|
40
|
+
const model = readString(entry);
|
|
41
|
+
return model ? { model } : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const objectEntry = asRecord(entry);
|
|
45
|
+
if (!objectEntry) return undefined;
|
|
46
|
+
const model = readString(objectEntry.model);
|
|
47
|
+
if (!model) return undefined;
|
|
48
|
+
|
|
49
|
+
const qualifier = readString(
|
|
50
|
+
objectEntry[harness === "opencode" ? "variant" : "thinking_level"],
|
|
51
|
+
);
|
|
52
|
+
return qualifier ? { model, qualifier } : { model };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isValidModelReference(entry: ResolvedModelEntry): boolean {
|
|
56
|
+
const slash = entry.model.indexOf("/");
|
|
57
|
+
return slash > 0 && slash < entry.model.length - 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sameAttempt(a: ResolvedModelEntry, b: ResolvedModelEntry): boolean {
|
|
61
|
+
return a.model === b.model && a.qualifier === b.qualifier;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves a configured fallback list in declaration order. Attempts are
|
|
66
|
+
* deduplicated by model plus qualifier: the same model with distinct
|
|
67
|
+
* qualifiers remains two intentional attempts.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveFallbackEntries(
|
|
70
|
+
value: unknown,
|
|
71
|
+
harness: ModelHarness,
|
|
72
|
+
): ResolvedModelEntry[] {
|
|
73
|
+
const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
74
|
+
const resolved: ResolvedModelEntry[] = [];
|
|
75
|
+
for (const value of values) {
|
|
76
|
+
const entry = normalizeModelEntry(value, harness);
|
|
77
|
+
if (!entry || !isValidModelReference(entry)) continue;
|
|
78
|
+
if (resolved.some((candidate) => sameAttempt(candidate, entry))) continue;
|
|
79
|
+
resolved.push(entry);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolvePrimaryEntry(args: {
|
|
85
|
+
entry: unknown;
|
|
86
|
+
defaultQualifier: unknown;
|
|
87
|
+
harness: ModelHarness;
|
|
88
|
+
}): ResolvedModelEntry | undefined {
|
|
89
|
+
const entry = normalizeModelEntry(args.entry, args.harness);
|
|
90
|
+
if (!entry) return undefined;
|
|
91
|
+
const qualifier = entry.qualifier ?? readString(args.defaultQualifier);
|
|
92
|
+
return qualifier ? { ...entry, qualifier } : entry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ResolvedHistorianModel {
|
|
96
|
+
primary?: ResolvedModelEntry;
|
|
97
|
+
fallbacks: ResolvedModelEntry[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve historian model attempts from exactly one harness block. Flat keys
|
|
102
|
+
* and the other harness are deliberately never consulted after cutover.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveHistorianModel(
|
|
105
|
+
config: unknown,
|
|
106
|
+
harness: ModelHarness,
|
|
107
|
+
): ResolvedHistorianModel {
|
|
108
|
+
const historian = asRecord(asRecord(config)?.historian);
|
|
109
|
+
const block = asRecord(historian?.[harness]);
|
|
110
|
+
if (!block) return { fallbacks: [] };
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
primary: resolvePrimaryEntry({
|
|
114
|
+
entry: block.model,
|
|
115
|
+
defaultQualifier: block[harness === "opencode" ? "variant" : "thinking_level"],
|
|
116
|
+
harness,
|
|
117
|
+
}),
|
|
118
|
+
fallbacks: resolveFallbackEntries(block.fallback_models, harness),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ResolvedDreamerTaskModel {
|
|
123
|
+
primary?: ResolvedModelEntry;
|
|
124
|
+
fallbacks: ResolvedModelEntry[];
|
|
125
|
+
schedule?: string;
|
|
126
|
+
timeoutMinutes?: number;
|
|
127
|
+
promotionThreshold?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a dreamer task from one harness subtree. Model selection is task →
|
|
132
|
+
* harness default, except compress-cues inserts its harness-independent mural
|
|
133
|
+
* string between those two rungs. Scheduling remains at dreamer.tasks.<task>.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveDreamerTaskModel(args: {
|
|
136
|
+
config: unknown;
|
|
137
|
+
harness: ModelHarness;
|
|
138
|
+
task: string;
|
|
139
|
+
muralModel?: unknown;
|
|
140
|
+
}): ResolvedDreamerTaskModel {
|
|
141
|
+
const dreamer = asRecord(asRecord(args.config)?.dreamer);
|
|
142
|
+
const harnessBlock = asRecord(dreamer?.[args.harness]);
|
|
143
|
+
const taskBlock = asRecord(asRecord(harnessBlock?.tasks)?.[args.task]);
|
|
144
|
+
const schedulingTask = asRecord(asRecord(dreamer?.tasks)?.[args.task]);
|
|
145
|
+
if (!harnessBlock) {
|
|
146
|
+
return {
|
|
147
|
+
fallbacks: [],
|
|
148
|
+
schedule: readString(schedulingTask?.schedule),
|
|
149
|
+
timeoutMinutes:
|
|
150
|
+
typeof taskBlock?.timeout_minutes === "number"
|
|
151
|
+
? taskBlock.timeout_minutes
|
|
152
|
+
: undefined,
|
|
153
|
+
promotionThreshold:
|
|
154
|
+
typeof schedulingTask?.promotion_threshold === "number"
|
|
155
|
+
? schedulingTask.promotion_threshold
|
|
156
|
+
: undefined,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const taskQualifier = taskBlock?.[args.harness === "opencode" ? "variant" : "thinking_level"];
|
|
161
|
+
// A mural string borrows the harness default's qualifier whether that
|
|
162
|
+
// qualifier is written beside `model` or inside the default entry object.
|
|
163
|
+
const harnessQualifier =
|
|
164
|
+
harnessBlock[args.harness === "opencode" ? "variant" : "thinking_level"] ??
|
|
165
|
+
normalizeModelEntry(harnessBlock.model, args.harness)?.qualifier;
|
|
166
|
+
const taskHasModel = taskBlock !== undefined && hasOwn(taskBlock, "model");
|
|
167
|
+
const usesMuralModel =
|
|
168
|
+
!taskHasModel && args.task === "compress-cues" && Boolean(readString(args.muralModel));
|
|
169
|
+
const primarySource = taskHasModel
|
|
170
|
+
? taskBlock?.model
|
|
171
|
+
: usesMuralModel
|
|
172
|
+
? args.muralModel
|
|
173
|
+
: harnessBlock.model;
|
|
174
|
+
const fallbackSource =
|
|
175
|
+
taskBlock !== undefined && hasOwn(taskBlock, "fallback_models")
|
|
176
|
+
? taskBlock.fallback_models
|
|
177
|
+
: harnessBlock.fallback_models;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
primary: resolvePrimaryEntry({
|
|
181
|
+
entry: primarySource,
|
|
182
|
+
// mural.model is a plain string, so its qualifier is always the
|
|
183
|
+
// executing harness default rather than a task-local default.
|
|
184
|
+
defaultQualifier: usesMuralModel
|
|
185
|
+
? harnessQualifier
|
|
186
|
+
: (taskQualifier ?? harnessQualifier),
|
|
187
|
+
harness: args.harness,
|
|
188
|
+
}),
|
|
189
|
+
fallbacks: resolveFallbackEntries(fallbackSource, args.harness),
|
|
190
|
+
schedule: readString(schedulingTask?.schedule),
|
|
191
|
+
timeoutMinutes:
|
|
192
|
+
typeof taskBlock?.timeout_minutes === "number" ? taskBlock.timeout_minutes : undefined,
|
|
193
|
+
promotionThreshold:
|
|
194
|
+
typeof schedulingTask?.promotion_threshold === "number"
|
|
195
|
+
? schedulingTask.promotion_threshold
|
|
196
|
+
: undefined,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Keep non-model agent settings at their original level while replacing only
|
|
202
|
+
* OpenCode's model-resolution fields with the executing harness's primary.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveOpenCodeAgentOverrides(agent: unknown): Record<string, unknown> {
|
|
205
|
+
const source = asRecord(agent);
|
|
206
|
+
if (!source) return {};
|
|
207
|
+
const {
|
|
208
|
+
model: _model,
|
|
209
|
+
fallback_models: _fallbackModels,
|
|
210
|
+
variant: _variant,
|
|
211
|
+
thinking_level: _thinking,
|
|
212
|
+
opencode,
|
|
213
|
+
pi,
|
|
214
|
+
tasks,
|
|
215
|
+
...rest
|
|
216
|
+
} = source;
|
|
217
|
+
const primary = resolvePrimaryEntry({
|
|
218
|
+
entry: asRecord(opencode)?.model,
|
|
219
|
+
defaultQualifier: asRecord(opencode)?.variant,
|
|
220
|
+
harness: "opencode",
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
...rest,
|
|
224
|
+
...(primary ? { model: primary.model } : {}),
|
|
225
|
+
...(primary?.qualifier ? { variant: primary.qualifier } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -56,6 +56,33 @@ describe("promptSyncWithModelSuggestionRetry", () => {
|
|
|
56
56
|
expect(prompt).toHaveBeenCalledTimes(1);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test("each active fallback sends only its own variant", async () => {
|
|
60
|
+
const prompt = mock(async () => {
|
|
61
|
+
if (prompt.mock.calls.length <= 2) throw new Error("attempt failed");
|
|
62
|
+
return {};
|
|
63
|
+
});
|
|
64
|
+
const client = createClient(prompt);
|
|
65
|
+
|
|
66
|
+
await promptSyncWithModelSuggestionRetry(
|
|
67
|
+
client,
|
|
68
|
+
{
|
|
69
|
+
path: { id: "ses-test" },
|
|
70
|
+
body: { model: { providerID: "anthropic", modelID: "primary" }, variant: "high" },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
fallbackModels: [{ model: "anthropic/fallback", qualifier: "low" }, "google/bare"],
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect((prompt.mock.calls[1]?.[0] as PromptCall).body).toMatchObject({
|
|
78
|
+
model: { providerID: "anthropic", modelID: "fallback" },
|
|
79
|
+
variant: "low",
|
|
80
|
+
});
|
|
81
|
+
const bareFallbackBody = (prompt.mock.calls[2]?.[0] as PromptCall).body;
|
|
82
|
+
expect(bareFallbackBody.model).toEqual({ providerID: "google", modelID: "bare" });
|
|
83
|
+
expect(bareFallbackBody.variant).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
59
86
|
test("primary fails, fallback[0] succeeds", async () => {
|
|
60
87
|
const prompt = mock(async () => {
|
|
61
88
|
if (prompt.mock.calls.length === 1) throw new Error("primary failed");
|
|
@@ -2,7 +2,8 @@ import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
|
2
2
|
|
|
3
3
|
import { detectOverflow } from "../features/magic-context/overflow-detection";
|
|
4
4
|
import { log } from "./logger";
|
|
5
|
-
import {
|
|
5
|
+
import type { ModelInput } from "./model-resolution";
|
|
6
|
+
import { parseProviderModel, toModelEntry } from "./resolve-fallbacks";
|
|
6
7
|
|
|
7
8
|
type Client = ReturnType<typeof createOpencodeClient>;
|
|
8
9
|
|
|
@@ -13,6 +14,8 @@ const ABORT_CALL_TIMEOUT_MS = 3000;
|
|
|
13
14
|
|
|
14
15
|
export type PromptBody = {
|
|
15
16
|
model?: { providerID: string; modelID: string };
|
|
17
|
+
/** OpenCode variant; Pi's facade also uses it as the active thinking level. */
|
|
18
|
+
variant?: string;
|
|
16
19
|
[key: string]: unknown;
|
|
17
20
|
};
|
|
18
21
|
|
|
@@ -64,7 +67,7 @@ export interface PromptRetryOptions {
|
|
|
64
67
|
* - On all-failed, the LAST error is thrown (matches legacy behavior when
|
|
65
68
|
* `fallbackModels` is empty).
|
|
66
69
|
*/
|
|
67
|
-
fallbackModels?: readonly
|
|
70
|
+
fallbackModels?: readonly ModelInput[];
|
|
68
71
|
/**
|
|
69
72
|
* Identifier for structured logging (e.g. "dreamer:consolidate",
|
|
70
73
|
* "historian", "compressor", "sidekick"). Helps correlate fallback
|
|
@@ -394,16 +397,19 @@ export async function promptSyncWithModelSuggestionRetry(
|
|
|
394
397
|
|
|
395
398
|
// Iterate fallbacks.
|
|
396
399
|
for (let i = 0; i < fallbacks.length; i += 1) {
|
|
397
|
-
const
|
|
400
|
+
const fallback = toModelEntry(fallbacks[i]);
|
|
401
|
+
const parsed = fallback ? parseProviderModel(fallback.model) : null;
|
|
398
402
|
if (!parsed) {
|
|
399
|
-
log(`[${callContext}] skipping invalid fallback spec: ${fallbacks[i]}`);
|
|
403
|
+
log(`[${callContext}] skipping invalid fallback spec: ${String(fallbacks[i])}`);
|
|
400
404
|
continue;
|
|
401
405
|
}
|
|
402
406
|
|
|
403
407
|
const label = `${parsed.providerID}/${parsed.modelID}`;
|
|
408
|
+
const { variant: _primaryVariant, ...bodyWithoutPrimaryVariant } = baseBody;
|
|
404
409
|
const attemptArgs = copyPromptArgs(baseArgs, {
|
|
405
|
-
...
|
|
410
|
+
...bodyWithoutPrimaryVariant,
|
|
406
411
|
model: parsed,
|
|
412
|
+
...(fallback?.qualifier ? { variant: fallback.qualifier } : {}),
|
|
407
413
|
});
|
|
408
414
|
|
|
409
415
|
try {
|
|
@@ -429,7 +435,7 @@ export async function promptSyncWithModelSuggestionRetry(
|
|
|
429
435
|
// caller's report (e.g. /ctx-dream tasks_json) still surfaces a real
|
|
430
436
|
// diagnostic.
|
|
431
437
|
log(
|
|
432
|
-
`[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks].join(", ")}; last error: ${shortErr(lastError)}`,
|
|
438
|
+
`[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks.map((fallback) => toModelEntry(fallback)?.model ?? String(fallback))].join(", ")}; last error: ${shortErr(lastError)}`,
|
|
433
439
|
);
|
|
434
440
|
throw lastError ?? new Error("All fallback models failed");
|
|
435
441
|
}
|
|
@@ -515,16 +521,19 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
|
|
|
515
521
|
}
|
|
516
522
|
|
|
517
523
|
for (let i = 0; i < fallbacks.length; i += 1) {
|
|
518
|
-
const
|
|
524
|
+
const fallback = toModelEntry(fallbacks[i]);
|
|
525
|
+
const parsed = fallback ? parseProviderModel(fallback.model) : null;
|
|
519
526
|
if (!parsed) {
|
|
520
|
-
log(`[${callContext}] skipping invalid fallback spec: ${fallbacks[i]}`);
|
|
527
|
+
log(`[${callContext}] skipping invalid fallback spec: ${String(fallbacks[i])}`);
|
|
521
528
|
continue;
|
|
522
529
|
}
|
|
523
530
|
|
|
524
531
|
const label = `${parsed.providerID}/${parsed.modelID}`;
|
|
532
|
+
const { variant: _primaryVariant, ...bodyWithoutPrimaryVariant } = baseBody;
|
|
525
533
|
const attemptArgs = copyPromptArgs(baseArgs, {
|
|
526
|
-
...
|
|
534
|
+
...bodyWithoutPrimaryVariant,
|
|
527
535
|
model: parsed,
|
|
536
|
+
...(fallback?.qualifier ? { variant: fallback.qualifier } : {}),
|
|
528
537
|
});
|
|
529
538
|
const attempt: PromptAttemptInfo = {
|
|
530
539
|
label,
|
|
@@ -563,7 +572,7 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
|
|
|
563
572
|
}
|
|
564
573
|
|
|
565
574
|
log(
|
|
566
|
-
`[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks].join(", ")}; original error: ${shortErr(firstError)}; last error: ${shortErr(lastError)}`,
|
|
575
|
+
`[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks.map((fallback) => toModelEntry(fallback)?.model ?? String(fallback))].join(", ")}; original error: ${shortErr(firstError)}; last error: ${shortErr(lastError)}`,
|
|
567
576
|
);
|
|
568
577
|
throw firstError ?? lastError ?? new Error("All fallback models failed validation");
|
|
569
578
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { parseProviderModel, resolveFallbackChain } from "./resolve-fallbacks";
|
|
3
|
+
import { modelBodyField, parseProviderModel, resolveFallbackChain } from "./resolve-fallbacks";
|
|
4
4
|
|
|
5
5
|
describe("resolveFallbackChain", () => {
|
|
6
6
|
// Policy: user-config-only. There is NO builtin provider-agnostic chain —
|
|
@@ -62,6 +62,18 @@ describe("resolveFallbackChain", () => {
|
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
describe("modelBodyField", () => {
|
|
66
|
+
test("sends the active entry variant and leaves a bare entry unqualified", () => {
|
|
67
|
+
expect(modelBodyField({ model: "anthropic/claude-sonnet", qualifier: "high" })).toEqual({
|
|
68
|
+
model: { providerID: "anthropic", modelID: "claude-sonnet" },
|
|
69
|
+
variant: "high",
|
|
70
|
+
});
|
|
71
|
+
expect(modelBodyField("google/gemini-flash")).toEqual({
|
|
72
|
+
model: { providerID: "google", modelID: "gemini-flash" },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
65
77
|
describe("parseProviderModel", () => {
|
|
66
78
|
test("parses standard provider/model", () => {
|
|
67
79
|
expect(parseProviderModel("anthropic/claude-sonnet-4-6")).toEqual({
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelInput, ResolvedModelEntry } from "./model-resolution";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Resolve the fallback model list to attempt for a hidden-agent (historian /
|
|
3
5
|
* dreamer / sidekick) call when its configured primary fails.
|
|
@@ -71,10 +73,23 @@ export function parseProviderModel(spec: string): { providerID: string; modelID:
|
|
|
71
73
|
* unparseable (the session falls back to its default model). Spread into a
|
|
72
74
|
* `client.session.prompt` body.
|
|
73
75
|
*/
|
|
74
|
-
export function modelBodyField(spec:
|
|
76
|
+
export function modelBodyField(spec: ModelInput | undefined): {
|
|
75
77
|
model?: { providerID: string; modelID: string };
|
|
78
|
+
variant?: string;
|
|
76
79
|
} {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
const entry = toModelEntry(spec);
|
|
81
|
+
if (!entry) return {};
|
|
82
|
+
const parsed = parseProviderModel(entry.model);
|
|
83
|
+
return parsed
|
|
84
|
+
? { model: parsed, ...(entry.qualifier ? { variant: entry.qualifier } : {}) }
|
|
85
|
+
: {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Convert legacy string call sites and resolved entries into one active attempt shape. */
|
|
89
|
+
export function toModelEntry(spec: ModelInput | undefined): ResolvedModelEntry | undefined {
|
|
90
|
+
if (typeof spec === "string") {
|
|
91
|
+
const model = spec.trim();
|
|
92
|
+
return model ? { model } : undefined;
|
|
93
|
+
}
|
|
94
|
+
return spec;
|
|
80
95
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { StatusDetail } from "./rpc-types";
|
|
3
|
+
import { formatStatusDetailMarkdown } from "./status-detail-text";
|
|
4
|
+
|
|
5
|
+
const STATUS_FIXTURE: StatusDetail = {
|
|
6
|
+
sessionId: "ses_status",
|
|
7
|
+
usagePercentage: 75,
|
|
8
|
+
inputTokens: 96_000,
|
|
9
|
+
contextLimit: 128_000,
|
|
10
|
+
systemPromptTokens: 4_000,
|
|
11
|
+
compartmentCount: 12,
|
|
12
|
+
memoryCount: 8,
|
|
13
|
+
memoryBlockCount: 3,
|
|
14
|
+
pendingOpsCount: 2,
|
|
15
|
+
historianRunning: true,
|
|
16
|
+
compartmentInProgress: true,
|
|
17
|
+
sessionNoteCount: 1,
|
|
18
|
+
readySmartNoteCount: 2,
|
|
19
|
+
cacheTtl: "5m",
|
|
20
|
+
lastTransformError: null,
|
|
21
|
+
lastDreamerRunAt: null,
|
|
22
|
+
projectIdentity: "/repo",
|
|
23
|
+
compartmentTokens: 22_000,
|
|
24
|
+
factTokens: 0,
|
|
25
|
+
memoryTokens: 1_200,
|
|
26
|
+
docsTokens: 500,
|
|
27
|
+
profileTokens: 100,
|
|
28
|
+
conversationTokens: 62_000,
|
|
29
|
+
toolCallTokens: 3_000,
|
|
30
|
+
toolDefinitionTokens: 3_200,
|
|
31
|
+
executeThreshold: 65,
|
|
32
|
+
executeThresholdClamped: false,
|
|
33
|
+
boundaryPresent: true,
|
|
34
|
+
coverageOrdinal: 12,
|
|
35
|
+
newWorkTokens: 1_000,
|
|
36
|
+
totalInputTokens: 96_000,
|
|
37
|
+
recompProgress: null,
|
|
38
|
+
tagCounter: 5,
|
|
39
|
+
activeTags: 4,
|
|
40
|
+
droppedTags: 1,
|
|
41
|
+
totalTags: 5,
|
|
42
|
+
activeBytes: 2_048,
|
|
43
|
+
lastResponseTime: 1,
|
|
44
|
+
lastNudgeTokens: 80_000,
|
|
45
|
+
pendingOps: [],
|
|
46
|
+
cacheTtlMs: 300_000,
|
|
47
|
+
cacheRemainingMs: 42_000,
|
|
48
|
+
cacheExpired: false,
|
|
49
|
+
cacheNeverExpires: false,
|
|
50
|
+
executeThresholdMode: "percentage",
|
|
51
|
+
protectedTagCount: 20,
|
|
52
|
+
historyBudgetPercentage: 0.15,
|
|
53
|
+
historyBlockTokens: 22_000,
|
|
54
|
+
compressionBudget: 12_480,
|
|
55
|
+
compressionUsage: "176%",
|
|
56
|
+
toastDurationMs: 5_000,
|
|
57
|
+
loggerDiagnostics: {
|
|
58
|
+
swallowedWriteCount: 0,
|
|
59
|
+
lastErrorMessage: null,
|
|
60
|
+
lastErrorTime: null,
|
|
61
|
+
},
|
|
62
|
+
storage_versions: {
|
|
63
|
+
context_db_schema_version: 79,
|
|
64
|
+
plugin_supported_version: 79,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("formatStatusDetailMarkdown", () => {
|
|
69
|
+
test("renders the fixed TUI status payload as compact markdown", () => {
|
|
70
|
+
expect(formatStatusDetailMarkdown(STATUS_FIXTURE)).toBe(`## Magic Context Status
|
|
71
|
+
|
|
72
|
+
- **Mode:** Magic Context compaction
|
|
73
|
+
- **Usage:** 75.0% (96,000 / 128,000 usable tokens)
|
|
74
|
+
- **Cache lane:** live (42s remaining); TTL 5m
|
|
75
|
+
- **Historian:** running; boundary present; coverage 12
|
|
76
|
+
- **Memory:** 8 active; 3 injected
|
|
77
|
+
- **Tags:** 4 active, 1 dropped; 2 pending drops
|
|
78
|
+
- **Execute threshold:** 65.0%`);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { StatusDetail } from "./rpc-types";
|
|
2
|
+
|
|
3
|
+
function formatCount(value: number): string {
|
|
4
|
+
return Math.round(value).toLocaleString();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function formatCacheLane(detail: StatusDetail): string {
|
|
8
|
+
if (detail.cacheNeverExpires) return `never expires; TTL ${detail.cacheTtl}`;
|
|
9
|
+
if (detail.lastResponseTime <= 0) return `waiting for first response; TTL ${detail.cacheTtl}`;
|
|
10
|
+
if (detail.cacheExpired) return `expired; TTL ${detail.cacheTtl}`;
|
|
11
|
+
return `live (${Math.round(detail.cacheRemainingMs / 1000)}s remaining); TTL ${detail.cacheTtl}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Render the same StatusDetail payload that powers the TUI dialog for chat-only clients. */
|
|
15
|
+
export function formatStatusDetailMarkdown(detail: StatusDetail): string {
|
|
16
|
+
const usableLimit =
|
|
17
|
+
detail.contextLimit > 0
|
|
18
|
+
? `${formatCount(detail.contextLimit)} usable tokens`
|
|
19
|
+
: "? usable tokens";
|
|
20
|
+
const historianState = detail.historianRunning ? "running" : "idle";
|
|
21
|
+
const historianDetails = [
|
|
22
|
+
detail.boundaryPresent === undefined
|
|
23
|
+
? undefined
|
|
24
|
+
: `boundary ${detail.boundaryPresent ? "present" : "absent"}`,
|
|
25
|
+
detail.coverageOrdinal === undefined
|
|
26
|
+
? undefined
|
|
27
|
+
: `coverage ${detail.coverageOrdinal === null ? "none" : detail.coverageOrdinal}`,
|
|
28
|
+
].filter((value): value is string => value !== undefined);
|
|
29
|
+
const mode =
|
|
30
|
+
detail.compaction_enabled === false
|
|
31
|
+
? "native compaction (Magic Context history compaction disabled)"
|
|
32
|
+
: "Magic Context compaction";
|
|
33
|
+
|
|
34
|
+
const lines = [
|
|
35
|
+
"## Magic Context Status",
|
|
36
|
+
"",
|
|
37
|
+
`- **Mode:** ${mode}`,
|
|
38
|
+
`- **Usage:** ${detail.usagePercentage.toFixed(1)}% (${formatCount(detail.inputTokens)} / ${usableLimit})`,
|
|
39
|
+
`- **Cache lane:** ${formatCacheLane(detail)}`,
|
|
40
|
+
`- **Historian:** ${[historianState, ...historianDetails].join("; ")}`,
|
|
41
|
+
`- **Memory:** ${formatCount(detail.memoryCount)} active; ${formatCount(detail.memoryBlockCount)} injected`,
|
|
42
|
+
`- **Tags:** ${formatCount(detail.activeTags)} active, ${formatCount(detail.droppedTags)} dropped; ${formatCount(detail.pendingOpsCount)} pending drops`,
|
|
43
|
+
`- **Execute threshold:** ${detail.executeThreshold.toFixed(1)}%${detail.executeThresholdClamped ? " (clamped)" : ""}`,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
if (detail.recompProgress?.phase === "recomp") {
|
|
47
|
+
lines.push(
|
|
48
|
+
`- **${detail.recompProgress.kind === "embed" ? "Embed" : "Historian"} progress:** ${formatCount(detail.recompProgress.processedMessages)} / ${formatCount(detail.recompProgress.totalMessages)}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (detail.lastTransformError) {
|
|
52
|
+
lines.push(`- **Last transform error:** ${detail.lastTransformError}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* the runner contract is purely additive on the OpenCode side.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import type { ModelInput } from "./model-resolution";
|
|
33
|
+
|
|
32
34
|
/**
|
|
33
35
|
* Configuration for one subagent invocation.
|
|
34
36
|
*
|
|
@@ -63,7 +65,7 @@ export interface SubagentRunOptions {
|
|
|
63
65
|
systemPrompt: string;
|
|
64
66
|
userMessage: string;
|
|
65
67
|
model?: string | undefined;
|
|
66
|
-
fallbackModels?: readonly
|
|
68
|
+
fallbackModels?: readonly ModelInput[];
|
|
67
69
|
timeoutMs?: number | undefined;
|
|
68
70
|
cwd?: string | undefined;
|
|
69
71
|
signal?: AbortSignal | undefined;
|
package/dist/index-td9depx5.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// src/shared/resolve-fallbacks.ts
|
|
2
|
-
function resolveFallbackChain(userFallbacks) {
|
|
3
|
-
const userList = normalizeUserFallbacks(userFallbacks);
|
|
4
|
-
return dedupe(userList.filter(isValidModelSpec));
|
|
5
|
-
}
|
|
6
|
-
function normalizeUserFallbacks(userFallbacks) {
|
|
7
|
-
if (!userFallbacks)
|
|
8
|
-
return [];
|
|
9
|
-
if (typeof userFallbacks === "string") {
|
|
10
|
-
const trimmed = userFallbacks.trim();
|
|
11
|
-
return trimmed ? [trimmed] : [];
|
|
12
|
-
}
|
|
13
|
-
return userFallbacks.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
14
|
-
}
|
|
15
|
-
function isValidModelSpec(spec) {
|
|
16
|
-
const slash = spec.indexOf("/");
|
|
17
|
-
return slash > 0 && slash < spec.length - 1;
|
|
18
|
-
}
|
|
19
|
-
function dedupe(list) {
|
|
20
|
-
const seen = new Set;
|
|
21
|
-
const out = [];
|
|
22
|
-
for (const item of list) {
|
|
23
|
-
if (seen.has(item))
|
|
24
|
-
continue;
|
|
25
|
-
seen.add(item);
|
|
26
|
-
out.push(item);
|
|
27
|
-
}
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
function parseProviderModel(spec) {
|
|
31
|
-
const slash = spec.indexOf("/");
|
|
32
|
-
if (slash < 1 || slash >= spec.length - 1)
|
|
33
|
-
return null;
|
|
34
|
-
return {
|
|
35
|
-
providerID: spec.slice(0, slash).trim(),
|
|
36
|
-
modelID: spec.slice(slash + 1).trim()
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
function modelBodyField(spec) {
|
|
40
|
-
if (!spec)
|
|
41
|
-
return {};
|
|
42
|
-
const parsed = parseProviderModel(spec);
|
|
43
|
-
return parsed ? { model: parsed } : {};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export { resolveFallbackChain, parseProviderModel, modelBodyField };
|