@pfoundation/ocadvisor 26.9.1 → 26.9.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.
Files changed (52) hide show
  1. package/README.md +444 -46
  2. package/dist/artificialAnalysis.d.ts +4 -0
  3. package/dist/artificialAnalysis.d.ts.map +1 -0
  4. package/dist/artificialAnalysis.js +77 -0
  5. package/dist/artificialAnalysis.js.map +1 -0
  6. package/dist/benchmarkConfig.d.ts +21 -0
  7. package/dist/benchmarkConfig.d.ts.map +1 -0
  8. package/dist/benchmarkConfig.js +59 -0
  9. package/dist/benchmarkConfig.js.map +1 -0
  10. package/dist/benchmarkEvidence.d.ts +37 -0
  11. package/dist/benchmarkEvidence.d.ts.map +1 -0
  12. package/dist/benchmarkEvidence.js +81 -0
  13. package/dist/benchmarkEvidence.js.map +1 -0
  14. package/dist/benchmarkMatch.d.ts +58 -0
  15. package/dist/benchmarkMatch.d.ts.map +1 -0
  16. package/dist/benchmarkMatch.js +259 -0
  17. package/dist/benchmarkMatch.js.map +1 -0
  18. package/dist/benchmarkStore.d.ts +45 -0
  19. package/dist/benchmarkStore.d.ts.map +1 -0
  20. package/dist/benchmarkStore.js +309 -0
  21. package/dist/benchmarkStore.js.map +1 -0
  22. package/dist/benchmarkTypes.d.ts +48 -0
  23. package/dist/benchmarkTypes.d.ts.map +1 -0
  24. package/dist/benchmarkTypes.js +298 -0
  25. package/dist/benchmarkTypes.js.map +1 -0
  26. package/dist/benchmarkUpdate.d.ts +33 -0
  27. package/dist/benchmarkUpdate.d.ts.map +1 -0
  28. package/dist/benchmarkUpdate.js +327 -0
  29. package/dist/benchmarkUpdate.js.map +1 -0
  30. package/dist/cli.d.ts +13 -0
  31. package/dist/cli.d.ts.map +1 -0
  32. package/dist/cli.js +397 -0
  33. package/dist/cli.js.map +1 -0
  34. package/dist/data/artificialAnalysis.mappings.json +229 -0
  35. package/dist/data/artificialAnalysis.snapshot.json +17684 -0
  36. package/dist/modelProfiles.d.ts +45 -0
  37. package/dist/modelProfiles.d.ts.map +1 -0
  38. package/dist/modelProfiles.js +141 -0
  39. package/dist/modelProfiles.js.map +1 -0
  40. package/dist/ocAdvisor.d.ts +105 -5
  41. package/dist/ocAdvisor.d.ts.map +1 -1
  42. package/dist/ocAdvisor.js +534 -45
  43. package/dist/ocAdvisor.js.map +1 -1
  44. package/dist/typesafeGate.d.ts +96 -0
  45. package/dist/typesafeGate.d.ts.map +1 -0
  46. package/dist/typesafeGate.js +475 -0
  47. package/dist/typesafeGate.js.map +1 -0
  48. package/dist/typesafeState.d.ts +94 -0
  49. package/dist/typesafeState.d.ts.map +1 -0
  50. package/dist/typesafeState.js +301 -0
  51. package/dist/typesafeState.js.map +1 -0
  52. package/package.json +8 -2
package/dist/ocAdvisor.js CHANGED
@@ -1,12 +1,18 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { appendFile } from "fs/promises";
3
3
  import { homedir } from "os";
4
- import { join } from "path";
4
+ import { isAbsolute, join } from "path";
5
+ import { resolveBenchmarkPaths } from "./benchmarkConfig.js";
6
+ import { buildBenchmarkEvidence, } from "./benchmarkEvidence.js";
7
+ import { createBenchmarkStore, } from "./benchmarkStore.js";
8
+ import { resolveAdvisorProfile, resolveRequesterProfile, } from "./modelProfiles.js";
9
+ import { runTypeSafeGate, } from "./typesafeGate.js";
10
+ import { TYPESAFE_DEFAULTS, buildDecisionState, normalizeTypeSafeOptions, resolveTypeSafeConfig, } from "./typesafeState.js";
5
11
  const DB_PATH = join(homedir(), ".local/share/opencode/opencode.db");
6
12
  const METRICS_PATH = join(homedir(), ".local/share/opencode/ocAdvisor-metrics.jsonl");
7
13
  const ADVISOR_PROVIDER = "anthropic";
8
14
  const ADVISOR_MODEL = "claude-fable-5-1";
9
- const ADVISOR_VARIANT = "max";
15
+ const ADVISOR_VARIANT = "xhigh";
10
16
  const ADVISOR_SESSION_TITLE = "advisor";
11
17
  // Sessions created before the ocAdvisor → advisor rename keep working: title
12
18
  // discovery accepts both, and the storage key below is unchanged.
@@ -24,6 +30,11 @@ const DEFAULT_ADVISOR_CONFIG = {
24
30
  timeoutMs: ADVISOR_TIMEOUT_MS,
25
31
  maxTranscriptChars: 0,
26
32
  agentEffort: null,
33
+ disabledForModels: [],
34
+ benchmarks: {},
35
+ typesafeSource: { disabled: false, overrides: {} },
36
+ typesafe: { enabled: false, settings: null, keyPresent: false },
37
+ typesafeSettings: null,
27
38
  };
28
39
  function normalizeVariant(value) {
29
40
  if (value === null || value === undefined)
@@ -72,6 +83,74 @@ function normalizeAgentEffort(value) {
72
83
  }
73
84
  return undefined;
74
85
  }
86
+ // Exact `provider/model` IDs. A string is split on commas; blanks are
87
+ // dropped. `undefined` means "not set" so a later source can keep the
88
+ // previous list. An empty array or whitespace-only string clears it.
89
+ function normalizeDisabledForModels(value) {
90
+ if (value === undefined)
91
+ return undefined;
92
+ const entries = typeof value === "string" ? value.split(",") : value;
93
+ if (!Array.isArray(entries) ||
94
+ entries.some((entry) => typeof entry !== "string")) {
95
+ throw new Error("disabledForModels must be a string array or comma-separated string.");
96
+ }
97
+ const refs = entries.map((entry) => entry.trim()).filter(Boolean);
98
+ for (const ref of refs) {
99
+ const slash = ref.indexOf("/");
100
+ if (slash <= 0 ||
101
+ slash === ref.length - 1 ||
102
+ ref.endsWith("/") ||
103
+ ref.includes("//") ||
104
+ /[\s#*?]/u.test(ref)) {
105
+ throw new Error("disabledForModels requires exact provider/model IDs without variants or wildcards.");
106
+ }
107
+ }
108
+ return [...new Set(refs)];
109
+ }
110
+ // `{ path, mappingsPath, matchAnyProvider }`. `undefined`/`null` means
111
+ // "not set"; blank entries are dropped; unknown keys are ignored. Invalid
112
+ // explicit values throw so misconfiguration fails fast at setup instead of
113
+ // silently degrading to default benchmark data.
114
+ function parseBooleanOption(name, value) {
115
+ if (typeof value === "boolean")
116
+ return value;
117
+ if (typeof value === "string") {
118
+ const lowered = value.trim().toLowerCase();
119
+ if (["true", "1", "yes", "on"].includes(lowered))
120
+ return true;
121
+ if (["false", "0", "no", "off", ""].includes(lowered))
122
+ return false;
123
+ }
124
+ throw new Error(`benchmarks.${name} must be true or false.`);
125
+ }
126
+ function normalizeBenchmarks(value) {
127
+ if (value === undefined || value === null)
128
+ return undefined;
129
+ if (typeof value !== "object" || Array.isArray(value)) {
130
+ throw new Error("benchmarks must be an object with path options.");
131
+ }
132
+ const raw = value;
133
+ const out = {};
134
+ for (const key of ["path", "mappingsPath"]) {
135
+ const entry = raw[key];
136
+ if (entry === undefined || entry === null)
137
+ continue;
138
+ if (typeof entry !== "string") {
139
+ throw new Error(`benchmarks.${key} must be an absolute path.`);
140
+ }
141
+ const trimmed = entry.trim();
142
+ if (!trimmed)
143
+ continue;
144
+ if (!isAbsolute(trimmed)) {
145
+ throw new Error(`benchmarks.${key} must be an absolute path.`);
146
+ }
147
+ out[key] = trimmed;
148
+ }
149
+ if (raw.matchAnyProvider !== undefined && raw.matchAnyProvider !== null) {
150
+ out.matchAnyProvider = parseBooleanOption("matchAnyProvider", raw.matchAnyProvider);
151
+ }
152
+ return out;
153
+ }
75
154
  function toBoundedInt(value, min) {
76
155
  const raw = typeof value === "number"
77
156
  ? value
@@ -108,7 +187,7 @@ function parseModelRef(ref) {
108
187
  }
109
188
  return out;
110
189
  }
111
- function applyAdvisorConfigSource(base, src) {
190
+ function applyAdvisorConfigSource(base, src, env = process.env) {
112
191
  if (!src || typeof src !== "object")
113
192
  return base;
114
193
  const next = { ...base };
@@ -135,12 +214,31 @@ function applyAdvisorConfigSource(base, src) {
135
214
  if (normalized !== undefined)
136
215
  next.agentEffort = normalized;
137
216
  }
217
+ const disabledForModels = normalizeDisabledForModels(src.disabledForModels);
218
+ if (disabledForModels !== undefined) {
219
+ next.disabledForModels = disabledForModels;
220
+ }
221
+ // Benchmark paths merge per field so a plugin option can override one
222
+ // location while the other still falls back to the environment default.
223
+ const benchmarks = normalizeBenchmarks(src.benchmarks);
224
+ if (benchmarks !== undefined) {
225
+ next.benchmarks = { ...next.benchmarks, ...benchmarks };
226
+ }
138
227
  const timeout = toBoundedInt(src.timeoutMs ?? src.timeout_ms, 1);
139
228
  if (timeout !== undefined)
140
229
  next.timeoutMs = timeout;
141
230
  const cap = toBoundedInt(src.maxTranscriptChars ?? src.max_transcript_chars, 0);
142
231
  if (cap !== undefined)
143
232
  next.maxTranscriptChars = cap;
233
+ if (src.typesafe !== undefined) {
234
+ const normalized = normalizeTypeSafeOptions(src.typesafe);
235
+ if ("error" in normalized) {
236
+ throw new Error(normalized.error);
237
+ }
238
+ next.typesafeSource = normalized;
239
+ }
240
+ next.typesafe = resolveTypeSafeConfig(next.typesafeSource, env);
241
+ next.typesafeSettings = next.typesafe.settings;
144
242
  return next;
145
243
  }
146
244
  function envAdvisorConfigSource(env = process.env) {
@@ -151,14 +249,36 @@ function envAdvisorConfigSource(env = process.env) {
151
249
  timeoutMs: env.OCADVISOR_TIMEOUT_MS,
152
250
  maxTranscriptChars: env.OCADVISOR_MAX_TRANSCRIPT_CHARS,
153
251
  agentEffort: env.OCADVISOR_AGENT_EFFORT,
252
+ disabledForModels: env.OCADVISOR_DISABLED_FOR_MODELS,
253
+ benchmarks: env.OCADVISOR_BENCHMARKS_PATH === undefined &&
254
+ env.OCADVISOR_BENCHMARK_MAPPINGS_PATH === undefined &&
255
+ env.OCADVISOR_BENCHMARKS_MATCH_ANY_PROVIDER === undefined
256
+ ? undefined
257
+ : {
258
+ path: env.OCADVISOR_BENCHMARKS_PATH,
259
+ mappingsPath: env.OCADVISOR_BENCHMARK_MAPPINGS_PATH,
260
+ ...(env.OCADVISOR_BENCHMARKS_MATCH_ANY_PROVIDER !== undefined
261
+ ? {
262
+ matchAnyProvider: env.OCADVISOR_BENCHMARKS_MATCH_ANY_PROVIDER,
263
+ }
264
+ : {}),
265
+ },
154
266
  };
155
267
  }
156
268
  // Precedence (low to high): built-in defaults, environment variables,
157
269
  // plugin options from opencode.json.
158
270
  function resolveAdvisorConfig(options, env = process.env) {
159
- let config = applyAdvisorConfigSource(DEFAULT_ADVISOR_CONFIG, envAdvisorConfigSource(env));
160
- config = applyAdvisorConfigSource(config, options);
161
- return config;
271
+ let config = applyAdvisorConfigSource(DEFAULT_ADVISOR_CONFIG, envAdvisorConfigSource(env), env);
272
+ config = applyAdvisorConfigSource(config, options, env);
273
+ // Resolved view always states the matching policy, so downstream code
274
+ // never has to invent the documented default.
275
+ return {
276
+ ...config,
277
+ benchmarks: {
278
+ ...config.benchmarks,
279
+ matchAnyProvider: config.benchmarks.matchAnyProvider ?? false,
280
+ },
281
+ };
162
282
  }
163
283
  const SYSTEM_BASE = `You are a senior advisor reviewing a coding agent's work. You have the full session transcript.
164
284
  Respond in 500-750 words with structured analysis and enumerated steps. Be direct and actionable.`;
@@ -174,15 +294,17 @@ You are a debugger. Analyze error patterns, stack traces, and failed attempts in
174
294
  };
175
295
  const TOOL_DESCRIPTION = `Consult a senior advisor model with your full session transcript — including parent sessions for subagents — for high-quality analysis.
176
296
 
177
- Use advisor selectively on substantial, non-trivial work. Straightforward tasks normally need no consultation.
297
+ Use advisor when an independent perspective could improve the approach, help resolve a problem, or strengthen an implementation review.
298
+
299
+ On substantial work, consider consulting before committing to an approach, when progress stalls, or before completing meaningful changes. Additional consultations are welcome as the work evolves — particularly when new evidence appears, the approach changes, or another concern needs review.
178
300
 
179
- - Normally make AT MOST ONE consultation per task, at the point where a second opinion has the most value: a consequential unresolved design decision (mode "plan"), a blocker after two substantially different attempts (mode "debug"), or a high-risk change with a specific unresolved correctness concern (mode "review"). Pick one stage, not all three.
180
- - mode "general": a second opinion that does not fit the above.
301
+ Ask a concrete, focused question. Avoid repeating settled questions without new context. Straightforward tasks usually need no consultation.
181
302
 
182
303
  Rules:
183
304
  - Always pass a concrete "question" naming the decision or artifact under review.
184
- - A second consultation requires material new evidence, a distinct unresolved issue, or an explicit user request. Reconcile an advisor conflict with primary-source evidence via one "followup" call stating both sides.
305
+ - Reconcile an advisor conflict with primary-source evidence via one "followup" call stating both sides.
185
306
  - Give the advice serious weight. A passing self-test alone is not counter-evidence; primary-source evidence (the file says X) is. Clear factual corrections do not need another confirmation call.
307
+ - When TypeSafe screening is enabled, a clearly unnecessary consultation returns a skip notice instead of advice, and an omitted effort may be chosen for you.
186
308
 
187
309
  Args: "mode" (general, review, plan, debug), "trigger" (before_approach, stuck, pre_complete, followup, other), "question" (concrete question focusing the advisor).
188
310
  `;
@@ -195,7 +317,7 @@ function buildToolDescription(config = DEFAULT_ADVISOR_CONFIG) {
195
317
  return (TOOL_DESCRIPTION +
196
318
  `Optional "effort" (one of: ${config.agentEffort.join(", ")}): reasoning effort for this consultation; omit to use the configured variant.\n`);
197
319
  }
198
- const CHECKPOINT_INSTRUCTION = `[advisor] Use advisor selectively on substantial work: normally 0-1 consultations per task, at most one unless material new evidence, a distinct unresolved issue, or an explicit user request. Consult for a consequential undecided design (mode "plan"), a blocker after 2+ different attempts (mode "debug"), or a high-risk change with a specific correctness concern (mode "review"). Always pass a concrete question.`;
320
+ const CHECKPOINT_INSTRUCTION = `[advisor] Consult advisor when an independent perspective would improve the approach, help resolve a problem, or strengthen review. Use useful checkpoints during substantial work; additional consultations are welcome when evidence, approach, or concerns change. Ask a concrete question and avoid repeating settled questions without new context.`;
199
321
  const ADVISOR_TRIGGERS = [
200
322
  "before_approach",
201
323
  "stuck",
@@ -240,8 +362,10 @@ function buildAdvisorInputSchema(config = DEFAULT_ADVISOR_CONFIG) {
240
362
  },
241
363
  };
242
364
  }
243
- function openDb() {
244
- return new Database(DB_PATH, { readonly: true });
365
+ function openDb(runtime) {
366
+ return new Database(runtime?.__advisorTest?.dbPath ?? DB_PATH, {
367
+ readonly: true,
368
+ });
245
369
  }
246
370
  function tableExists(db, name) {
247
371
  const row = db
@@ -267,6 +391,22 @@ function isFableModel(model) {
267
391
  const id = String(model.id || model.modelID || "").toLowerCase();
268
392
  return provider.includes("anthropic") && id.includes("fable");
269
393
  }
394
+ function advisorDisabledReason(model, config) {
395
+ if (isFableModel(model)) {
396
+ return { outcome: "skipped_fable", message: FABLE_DISABLED };
397
+ }
398
+ const provider = model?.providerID || model?.provider;
399
+ const id = model?.id || model?.modelID;
400
+ if (!provider || !id)
401
+ return null;
402
+ const ref = `${provider}/${id}`;
403
+ if (!config.disabledForModels.includes(ref))
404
+ return null;
405
+ return {
406
+ outcome: "skipped_model",
407
+ message: `advisor is disabled (model opt-out): ${ref} is listed in disabledForModels.`,
408
+ };
409
+ }
270
410
  function inferTrigger(mode, trigger) {
271
411
  if (trigger && ADVISOR_TRIGGERS.includes(trigger)) {
272
412
  return trigger;
@@ -405,6 +545,13 @@ function isAdvisorToolName(name) {
405
545
  function countPriorAdvisorCalls(db, sessionId) {
406
546
  const callIds = new Set();
407
547
  const modes = [];
548
+ const questions = [];
549
+ const record = (input) => {
550
+ modes.push(String(input?.mode || "general"));
551
+ if (typeof input?.question === "string" && input.question.trim()) {
552
+ questions.push(input.question);
553
+ }
554
+ };
408
555
  try {
409
556
  for (const sid of collectSessionChain(db, sessionId)) {
410
557
  if (tableExists(db, "session_message")) {
@@ -433,7 +580,7 @@ function countPriorAdvisorCalls(db, sessionId) {
433
580
  const cid = String(block.id || block.callID || row.id);
434
581
  if (!callIds.has(cid)) {
435
582
  callIds.add(cid);
436
- modes.push(String(state.input?.mode || "general"));
583
+ record(state.input);
437
584
  }
438
585
  }
439
586
  nested.forEach((call, index) => {
@@ -441,7 +588,7 @@ function countPriorAdvisorCalls(db, sessionId) {
441
588
  const cid = `${block.id || row.id}#${index}`;
442
589
  if (!callIds.has(cid)) {
443
590
  callIds.add(cid);
444
- modes.push(String(call.input?.mode || "general"));
591
+ record(call.input);
445
592
  }
446
593
  }
447
594
  });
@@ -469,22 +616,88 @@ function countPriorAdvisorCalls(db, sessionId) {
469
616
  if (!cid || callIds.has(cid))
470
617
  continue;
471
618
  callIds.add(cid);
472
- modes.push(String(block.state?.input?.mode || "general"));
619
+ record(block.state?.input);
473
620
  }
474
621
  }
475
622
  }
476
623
  }
477
624
  catch { }
478
- return { count: callIds.size, modes };
625
+ return { count: callIds.size, modes, questions };
479
626
  }
480
- async function logAdvisorMetrics(metrics) {
627
+ async function logAdvisorMetrics(runtime, metrics) {
481
628
  try {
482
- await appendFile(METRICS_PATH, JSON.stringify(metrics) + "\n", "utf-8");
629
+ const path = runtime?.__advisorTest?.metricsPath ?? METRICS_PATH;
630
+ await appendFile(path, JSON.stringify(metrics) + "\n", "utf-8");
483
631
  }
484
632
  catch {
485
633
  // Metrics must never break the advisor call.
486
634
  }
487
635
  }
636
+ // Flattens a gate decision into the additive metrics record. A bypassed gate
637
+ // reports `bypass` so reports can separate bypass, skip, fallback, and
638
+ // allowed proceed without inferring from other fields.
639
+ function gateRecordFrom(decision) {
640
+ if (decision.status === "disabled") {
641
+ return {
642
+ status: "bypass",
643
+ reason: "disabled",
644
+ model: null,
645
+ neededProbability: null,
646
+ suggestedEffort: null,
647
+ effortConfidence: null,
648
+ effectiveEffort: null,
649
+ effortSource: null,
650
+ latencyMs: 0,
651
+ stateBytes: 0,
652
+ truncated: false,
653
+ inputTokens: null,
654
+ outputTokens: null,
655
+ };
656
+ }
657
+ const metrics = decision.metrics;
658
+ return {
659
+ status: decision.status,
660
+ reason: decision.reason,
661
+ model: metrics.model,
662
+ neededProbability: metrics.neededProbability,
663
+ suggestedEffort: metrics.suggestedEffort,
664
+ effortConfidence: metrics.effortConfidence,
665
+ effectiveEffort: decision.status === "proceed" ? decision.effectiveEffort : null,
666
+ effortSource: decision.status === "proceed" ? decision.effortSource : null,
667
+ latencyMs: metrics.latencyMs,
668
+ stateBytes: metrics.stateBytes,
669
+ truncated: metrics.truncated,
670
+ inputTokens: metrics.inputTokens,
671
+ outputTokens: metrics.outputTokens,
672
+ };
673
+ }
674
+ // Human-readable gate summary for the tool output. A bypassed gate contributes
675
+ // nothing, so keyless or disabled installs keep the plain footer.
676
+ export function gateSummary(gate) {
677
+ if (!gate || gate.status === "bypass")
678
+ return null;
679
+ const parts = [];
680
+ if (gate.neededProbability !== null) {
681
+ parts.push(`need=${gate.neededProbability.toFixed(2)}`);
682
+ }
683
+ if (gate.effectiveEffort) {
684
+ parts.push(gate.effortSource === "typesafe"
685
+ ? `effort=${gate.effectiveEffort} (gate)`
686
+ : `effort=${gate.effectiveEffort}`);
687
+ }
688
+ switch (gate.status) {
689
+ case "skip":
690
+ parts.push("decision=skip", gate.reason);
691
+ break;
692
+ case "fallback":
693
+ parts.push("decision=fallback", gate.reason);
694
+ break;
695
+ default:
696
+ parts.push("decision=proceed");
697
+ break;
698
+ }
699
+ return `gate: ${parts.join(", ")}`;
700
+ }
488
701
  function getSession(db, sessionId) {
489
702
  return db
490
703
  .query("SELECT id, parent_id, title, directory FROM session WHERE id = ?")
@@ -759,6 +972,20 @@ function resolveAdvisorVariant(model, config = DEFAULT_ADVISOR_CONFIG, requested
759
972
  const ids = model.variants.map((variant) => variant?.id);
760
973
  return ids.includes(config.variant) ? config.variant : undefined;
761
974
  }
975
+ // Effort levels the gate may choose: plugin-allowed candidates intersected
976
+ // with the model's live variants. Unknown discovery yields no candidates,
977
+ // which keeps the configured effort and omits automatic effort selection.
978
+ function resolveSupportedEfforts(model, config) {
979
+ const allowed = config.agentEffort && config.agentEffort.length > 0
980
+ ? config.agentEffort
981
+ : null;
982
+ if (!allowed || !model || !Array.isArray(model.variants))
983
+ return [];
984
+ const variants = new Set(model.variants
985
+ .map((variant) => variant?.id)
986
+ .filter((id) => !!id));
987
+ return allowed.filter((effort) => variants.has(effort));
988
+ }
762
989
  function hasAdvisorConnection(connection) {
763
990
  if (connection === null || connection === undefined)
764
991
  return false;
@@ -772,11 +999,50 @@ function hasAdvisorConnection(connection) {
772
999
  }
773
1000
  return true;
774
1001
  }
1002
+ // Discovery namespaces: the documented V2 `model`/`provider` domains win,
1003
+ // the legacy `catalog` namespace stays for older servers and fixtures.
1004
+ // Calls stay in method form so any receiver-bound implementation keeps
1005
+ // working.
1006
+ function discoveryModelApi(runtime) {
1007
+ if (runtime.model && typeof runtime.model.list === "function") {
1008
+ return runtime.model;
1009
+ }
1010
+ if (runtime.catalog?.model &&
1011
+ typeof runtime.catalog.model.list === "function") {
1012
+ return runtime.catalog.model;
1013
+ }
1014
+ return null;
1015
+ }
1016
+ function discoveryProviderApi(runtime) {
1017
+ if (runtime.provider && typeof runtime.provider.get === "function") {
1018
+ return runtime.provider;
1019
+ }
1020
+ if (runtime.catalog?.provider &&
1021
+ typeof runtime.catalog.provider.get === "function") {
1022
+ return runtime.catalog.provider;
1023
+ }
1024
+ return null;
1025
+ }
1026
+ // Shared by support checks and the effort gate; null means discovery is
1027
+ // unavailable or the configured model was not found.
1028
+ async function findAdvisorCatalogModel(runtime, config) {
1029
+ const api = discoveryModelApi(runtime);
1030
+ if (!api)
1031
+ return null;
1032
+ try {
1033
+ const models = unwrapData((await api.list()));
1034
+ return findAdvisorModel(models, config);
1035
+ }
1036
+ catch {
1037
+ return null;
1038
+ }
1039
+ }
775
1040
  async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG, requestedVariant) {
776
- if (typeof runtime.catalog?.provider?.get === "function") {
1041
+ const providerApi = discoveryProviderApi(runtime);
1042
+ if (providerApi) {
777
1043
  let provider = null;
778
1044
  try {
779
- provider = unwrapData((await runtime.catalog.provider.get({
1045
+ provider = unwrapData((await providerApi.get({
780
1046
  providerID: config.provider,
781
1047
  })));
782
1048
  }
@@ -794,10 +1060,11 @@ async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG, req
794
1060
  }
795
1061
  }
796
1062
  let variant = requestedVariant ?? config.variant;
797
- if (typeof runtime.catalog?.model?.list === "function") {
1063
+ const modelApi = discoveryModelApi(runtime);
1064
+ if (modelApi) {
798
1065
  let models = null;
799
1066
  try {
800
- models = unwrapData((await runtime.catalog.model.list()));
1067
+ models = unwrapData((await modelApi.list()));
801
1068
  }
802
1069
  catch (err) {
803
1070
  return {
@@ -1025,10 +1292,25 @@ async function callAdvisor(opts) {
1025
1292
  const text = await enqueueAdvisor(() => withTimeout((async () => {
1026
1293
  const sessionId = await ensureAdvisorSession(runtime, support.variant, config);
1027
1294
  const request = { sessionID: sessionId, prompt };
1028
- const result = opts.signal
1029
- ? await generate(request, { signal: opts.signal })
1030
- : await generate(request);
1031
- const output = extractGeneratedText(result);
1295
+ const startedAt = Date.now();
1296
+ const run = async () => {
1297
+ const result = opts.signal
1298
+ ? await generate(request, { signal: opts.signal })
1299
+ : await generate(request);
1300
+ return extractGeneratedText(result);
1301
+ };
1302
+ // A generation can come back without text (transient provider
1303
+ // behavior, e.g. a reasoning-only response with adaptive thinking).
1304
+ // Retry once while most of the timeout budget remains; never retry
1305
+ // after a caller cancellation.
1306
+ let output = await run();
1307
+ if (!output?.trim() && !opts.signal?.aborted) {
1308
+ const elapsed = Date.now() - startedAt;
1309
+ if (elapsed < config.timeoutMs / 2) {
1310
+ console.log(`[advisor] empty generation; retrying once (session=${sessionId} elapsedMs=${elapsed})`);
1311
+ output = await run();
1312
+ }
1313
+ }
1032
1314
  if (!output?.trim()) {
1033
1315
  throw new Error("Advisor returned an empty response.");
1034
1316
  }
@@ -1044,6 +1326,87 @@ async function callAdvisor(opts) {
1044
1326
  variant: support.variant,
1045
1327
  };
1046
1328
  }
1329
+ // Benchmark stores live for the process, keyed by resolved user paths (and
1330
+ // test seed overrides), so every consultation observes CLI refreshes
1331
+ // without re-reading unchanged files. Tests reset between cases.
1332
+ const benchmarkStores = new Map();
1333
+ function resetBenchmarkStores() {
1334
+ benchmarkStores.clear();
1335
+ }
1336
+ function benchmarkStoreKey(snapshotPath, mappingsPath, matchAnyProvider, seedSnapshotPath, seedMappingsPath) {
1337
+ return [
1338
+ snapshotPath,
1339
+ mappingsPath,
1340
+ matchAnyProvider ? "any-provider" : "strict-provider",
1341
+ seedSnapshotPath ?? "",
1342
+ seedMappingsPath ?? "",
1343
+ ].join("\n");
1344
+ }
1345
+ // Loads one consistent benchmark view and assembles gate evidence from it.
1346
+ // Any failure (bad paths, unreadable files, unexpected errors) yields null
1347
+ // and the consultation proceeds without benchmark enrichment.
1348
+ async function loadBenchmarkEvidence(runtime, config, requester, advisor) {
1349
+ try {
1350
+ const testOpts = runtime?.__advisorTest?.benchmarkStoreOptions;
1351
+ const paths = resolveBenchmarkPaths(config.benchmarks, testOpts?.env ?? process.env);
1352
+ if ("error" in paths)
1353
+ return null;
1354
+ const key = benchmarkStoreKey(paths.snapshotPath, paths.mappingsPath, config.benchmarks.matchAnyProvider ?? false, testOpts?.seedSnapshotPath, testOpts?.seedMappingsPath);
1355
+ let store = benchmarkStores.get(key);
1356
+ if (!store) {
1357
+ store = await createBenchmarkStore({
1358
+ snapshotPath: paths.snapshotPath,
1359
+ mappingsPath: paths.mappingsPath,
1360
+ seedSnapshotPath: testOpts?.seedSnapshotPath,
1361
+ seedMappingsPath: testOpts?.seedMappingsPath,
1362
+ fs: testOpts?.fs,
1363
+ maxBytes: testOpts?.maxBytes,
1364
+ matchAnyProvider: config.benchmarks.matchAnyProvider ?? false,
1365
+ });
1366
+ benchmarkStores.set(key, store);
1367
+ }
1368
+ const view = await store.view();
1369
+ return {
1370
+ view,
1371
+ evidence: buildBenchmarkEvidence({ requester, advisor, view }),
1372
+ };
1373
+ }
1374
+ catch {
1375
+ return null;
1376
+ }
1377
+ }
1378
+ function summarizeBenchmarkEvidence(loaded, finalEffort) {
1379
+ if (!loaded)
1380
+ return undefined;
1381
+ const { view, evidence } = loaded;
1382
+ const { requester, advisor } = evidence.models;
1383
+ const policy = advisor.policy;
1384
+ const finalMatch = finalEffort === null
1385
+ ? null
1386
+ : view.matcher.match({
1387
+ providerID: advisor.providerID,
1388
+ modelID: advisor.modelID,
1389
+ variant: finalEffort,
1390
+ }).status;
1391
+ return {
1392
+ source: evidence.benchmarks.source,
1393
+ contentHash: evidence.benchmarks.contentHash,
1394
+ fetchedAt: evidence.benchmarks.fetchedAt,
1395
+ hashVerified: evidence.benchmarks.hashVerified,
1396
+ requester: requester.providerID && requester.modelID
1397
+ ? `${requester.providerID}/${requester.modelID}${requester.variant ? `#${requester.variant}` : ""} (${requester.provenance})`
1398
+ : null,
1399
+ requesterMatch: evidence.benchmarks.requesterMatch.status,
1400
+ advisorPolicy: policy.kind === "pinned"
1401
+ ? `pinned:${policy.effort}`
1402
+ : policy.kind === "candidates"
1403
+ ? `candidates:${policy.candidates.join(",")}>${policy.fallback ?? "none"}`
1404
+ : `fixed:${policy.effort ?? "none"}`,
1405
+ advisorMatch: evidence.benchmarks.advisorDefaultMatch.status,
1406
+ finalEffort,
1407
+ finalMatch,
1408
+ };
1409
+ }
1047
1410
  async function runAdvisor(opts) {
1048
1411
  const started = Date.now();
1049
1412
  const config = opts.config ?? DEFAULT_ADVISOR_CONFIG;
@@ -1052,7 +1415,7 @@ async function runAdvisor(opts) {
1052
1415
  const sessionId = opts.sessionId;
1053
1416
  const questionChars = opts.question?.length ?? 0;
1054
1417
  if (!sessionId) {
1055
- await logAdvisorMetrics({
1418
+ await logAdvisorMetrics(opts.runtime, {
1056
1419
  ts: new Date().toISOString(),
1057
1420
  sessionId: null,
1058
1421
  callerModel: null,
@@ -1075,13 +1438,14 @@ async function runAdvisor(opts) {
1075
1438
  }
1076
1439
  let db = null;
1077
1440
  try {
1078
- db = openDb();
1441
+ db = openDb(opts.runtime);
1079
1442
  const info = getSessionInfo(db, sessionId);
1080
1443
  const callerModel = callerLabel(info?.model ?? null);
1081
1444
  const callerAgent = opts.callerAgent || info?.agent || null;
1082
1445
  const directory = opts.callerDirectory || info?.directory || null;
1083
- if (isFableModel(info?.model)) {
1084
- await logAdvisorMetrics({
1446
+ const disabled = advisorDisabledReason(info?.model, config);
1447
+ if (disabled) {
1448
+ await logAdvisorMetrics(opts.runtime, {
1085
1449
  ts: new Date().toISOString(),
1086
1450
  sessionId,
1087
1451
  callerModel,
@@ -1091,7 +1455,7 @@ async function runAdvisor(opts) {
1091
1455
  trigger,
1092
1456
  questionChars,
1093
1457
  effort: null,
1094
- outcome: "skipped_fable",
1458
+ outcome: disabled.outcome,
1095
1459
  errorType: null,
1096
1460
  latencyMs: Date.now() - started,
1097
1461
  inputTokens: null,
@@ -1100,12 +1464,12 @@ async function runAdvisor(opts) {
1100
1464
  priorConsultations: 0,
1101
1465
  via: "opencode-session",
1102
1466
  });
1103
- console.log(`[advisor] session=${sessionId} mode=${mode} outcome=skipped_fable (already Fable)`);
1104
- return FABLE_DISABLED;
1467
+ console.log(`[advisor] session=${sessionId} mode=${mode} outcome=${disabled.outcome}`);
1468
+ return disabled.message;
1105
1469
  }
1106
1470
  let transcript = buildTranscript(db, sessionId);
1107
1471
  if (!transcript?.trim()) {
1108
- await logAdvisorMetrics({
1472
+ await logAdvisorMetrics(opts.runtime, {
1109
1473
  ts: new Date().toISOString(),
1110
1474
  sessionId,
1111
1475
  callerModel,
@@ -1134,26 +1498,141 @@ async function runAdvisor(opts) {
1134
1498
  "... (older transcript trimmed to fit maxTranscriptChars) ...\n\n" +
1135
1499
  transcript.slice(-config.maxTranscriptChars);
1136
1500
  }
1501
+ // Invocation-correct requester identity for benchmark matching: the
1502
+ // model that produced this tool call, resolved after the early guards
1503
+ // so skipped calls pay for no extra reads.
1504
+ const requesterProfile = resolveRequesterProfile(db, {
1505
+ sessionId,
1506
+ messageID: opts.callerMessageID,
1507
+ callID: opts.callerCallID,
1508
+ sessionModel: info?.model ?? null,
1509
+ });
1137
1510
  const prior = countPriorAdvisorCalls(db, sessionId);
1138
1511
  const priorNote = prior.count > 0
1139
1512
  ? `Note: this session chain already has ${prior.count} recorded advisor consultation(s) (modes: ${prior.modes.join(", ") || "unknown"}). Focus on what is new since then; do not repeat settled advice unless new evidence changes it.`
1140
1513
  : null;
1141
1514
  const systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.general;
1142
1515
  let requestedEffort;
1516
+ let effectiveEffort;
1517
+ let gateRecord;
1518
+ let benchmarkLoaded = null;
1143
1519
  try {
1144
1520
  requestedEffort = resolveRequestedEffort(config, opts.effort);
1521
+ // Optional TypeSafe preflight: one bounded request decides whether this
1522
+ // consultation is worth the expensive generation and, when the caller
1523
+ // did not pin an effort, which supported effort fits. Gate failures
1524
+ // fall back to the ordinary behavior and never fail the call.
1525
+ let gateDecision = null;
1526
+ let advisorProfile = null;
1527
+ if (config.typesafe.keyPresent && !config.typesafeSource.disabled) {
1528
+ const catalogModel = await findAdvisorCatalogModel(opts.runtime, config);
1529
+ const supportedEfforts = resolveSupportedEfforts(catalogModel, config);
1530
+ advisorProfile = resolveAdvisorProfile({
1531
+ providerID: config.provider,
1532
+ modelID: config.model,
1533
+ requestedEffort,
1534
+ supportedEfforts,
1535
+ defaultEffort: requestedEffort ??
1536
+ resolveAdvisorVariant(catalogModel, config) ??
1537
+ null,
1538
+ });
1539
+ const gateSettings = config.typesafe.settings ?? TYPESAFE_DEFAULTS;
1540
+ benchmarkLoaded = await loadBenchmarkEvidence(opts.runtime, config, requesterProfile, advisorProfile);
1541
+ gateDecision = await runTypeSafeGate({
1542
+ state: buildDecisionState(db, sessionId, {
1543
+ question: opts.question ?? null,
1544
+ mode,
1545
+ trigger,
1546
+ explicitEffort: requestedEffort ?? null,
1547
+ caller: callerModel,
1548
+ directory,
1549
+ priorConsultations: prior.count,
1550
+ priorModes: prior.modes,
1551
+ priorQuestions: prior.questions,
1552
+ supportedEfforts,
1553
+ defaultEffort: requestedEffort ?? config.variant ?? null,
1554
+ maxStateBytes: gateSettings.maxStateBytes,
1555
+ models: benchmarkLoaded?.evidence.models ?? null,
1556
+ benchmarks: benchmarkLoaded?.evidence.benchmarks ?? null,
1557
+ formatMessage: formatV2Message,
1558
+ }),
1559
+ input: {
1560
+ mode,
1561
+ trigger,
1562
+ question: opts.question ?? null,
1563
+ explicitEffort: requestedEffort ?? null,
1564
+ supportedEfforts,
1565
+ defaultEffort: requestedEffort ?? config.variant ?? null,
1566
+ },
1567
+ settings: gateSettings,
1568
+ keyPresent: config.typesafe.keyPresent,
1569
+ client: opts.runtime?.__advisorTest?.gateClient,
1570
+ env: opts.runtime?.__advisorTest?.gateEnv,
1571
+ fetchImpl: opts.runtime?.__advisorTest?.gateFetch,
1572
+ signal: opts.signal,
1573
+ });
1574
+ gateRecord = gateRecordFrom(gateDecision);
1575
+ }
1576
+ // Profiles resolve for every consultation: with discovery the advisor
1577
+ // policy reflects live variants, otherwise it fixes to the configured
1578
+ // effort (or the caller's pin).
1579
+ advisorProfile ??= resolveAdvisorProfile({
1580
+ providerID: config.provider,
1581
+ modelID: config.model,
1582
+ requestedEffort,
1583
+ supportedEfforts: [],
1584
+ defaultEffort: config.variant ?? null,
1585
+ });
1586
+ opts.runtime?.__advisorTest?.profileSink?.({
1587
+ requester: requesterProfile,
1588
+ advisor: advisorProfile,
1589
+ });
1590
+ if (gateDecision?.status === "skip") {
1591
+ const latencyMs = Date.now() - started;
1592
+ await logAdvisorMetrics(opts.runtime, {
1593
+ ts: new Date().toISOString(),
1594
+ sessionId,
1595
+ callerModel,
1596
+ callerAgent,
1597
+ directory,
1598
+ mode,
1599
+ trigger,
1600
+ questionChars,
1601
+ effort: null,
1602
+ outcome: "skipped_typesafe",
1603
+ errorType: null,
1604
+ latencyMs,
1605
+ inputTokens: null,
1606
+ outputTokens: null,
1607
+ transcriptChars: transcript.length,
1608
+ priorConsultations: prior.count,
1609
+ via: "opencode-session",
1610
+ gate: gateRecord,
1611
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, null),
1612
+ });
1613
+ console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=skipped_typesafe needed=${gateDecision.metrics.neededProbability ?? "n/a"} latencyMs=${latencyMs}`);
1614
+ const skipNote = gateSummary(gateRecord);
1615
+ return (`advisor consultation skipped (typesafe): the request did not need an advisor at this point` +
1616
+ `${gateDecision.metrics.neededProbability !== null ? ` (need probability ${gateDecision.metrics.neededProbability.toFixed(2)})` : ""}. ` +
1617
+ `Ask again with a concrete unresolved question if the situation changes.` +
1618
+ (skipNote ? `\n_${skipNote}_` : ""));
1619
+ }
1620
+ effectiveEffort =
1621
+ gateDecision?.status === "proceed" && gateDecision.effectiveEffort
1622
+ ? gateDecision.effectiveEffort
1623
+ : requestedEffort;
1145
1624
  const result = await callAdvisor({
1146
1625
  runtime: opts.runtime,
1147
1626
  systemPrompt,
1148
1627
  transcript,
1149
1628
  question: opts.question,
1150
1629
  priorNote,
1151
- effort: requestedEffort,
1630
+ effort: effectiveEffort,
1152
1631
  signal: opts.signal,
1153
1632
  config,
1154
1633
  });
1155
1634
  const latencyMs = Date.now() - started;
1156
- await logAdvisorMetrics({
1635
+ await logAdvisorMetrics(opts.runtime, {
1157
1636
  ts: new Date().toISOString(),
1158
1637
  sessionId,
1159
1638
  callerModel,
@@ -1171,16 +1650,20 @@ async function runAdvisor(opts) {
1171
1650
  transcriptChars: transcript.length,
1172
1651
  priorConsultations: prior.count,
1173
1652
  via: "opencode-session",
1653
+ gate: gateRecord,
1654
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, result.variant ?? effectiveEffort ?? requestedEffort ?? null),
1174
1655
  });
1175
1656
  console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=advisor_response latencyMs=${latencyMs}`);
1657
+ const gateNote = gateSummary(gateRecord);
1176
1658
  return (result.text +
1177
- `\n\n_advisor consultation #${prior.count + 1} in this session chain (trigger=${trigger})_`);
1659
+ `\n\n_advisor consultation #${prior.count + 1} in this session chain (trigger=${trigger})_` +
1660
+ (gateNote ? `\n_${gateNote}_` : ""));
1178
1661
  }
1179
1662
  catch (err) {
1180
1663
  const message = err instanceof Error ? err.message : String(err);
1181
1664
  const errorType = classifyAdvisorError(message);
1182
1665
  const latencyMs = Date.now() - started;
1183
- await logAdvisorMetrics({
1666
+ await logAdvisorMetrics(opts.runtime, {
1184
1667
  ts: new Date().toISOString(),
1185
1668
  sessionId,
1186
1669
  callerModel,
@@ -1189,7 +1672,7 @@ async function runAdvisor(opts) {
1189
1672
  mode,
1190
1673
  trigger,
1191
1674
  questionChars,
1192
- effort: requestedEffort ?? null,
1675
+ effort: effectiveEffort ?? requestedEffort ?? null,
1193
1676
  outcome: "error",
1194
1677
  errorType,
1195
1678
  latencyMs,
@@ -1198,6 +1681,8 @@ async function runAdvisor(opts) {
1198
1681
  transcriptChars: transcript.length,
1199
1682
  priorConsultations: prior.count,
1200
1683
  via: "opencode-session",
1684
+ gate: gateRecord,
1685
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, effectiveEffort ?? requestedEffort ?? null),
1201
1686
  });
1202
1687
  console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=error errorType=${errorType} latencyMs=${latencyMs}`);
1203
1688
  throw new Error(`advisor failed (${errorType}): ${message}`);
@@ -1236,6 +1721,8 @@ export async function setupOcAdvisorV2(ctx) {
1236
1721
  signal: context.abort,
1237
1722
  callerAgent: context.agent,
1238
1723
  callerDirectory: context.directory,
1724
+ callerMessageID: context.messageID,
1725
+ callerCallID: context.id,
1239
1726
  config: advisorConfig,
1240
1727
  });
1241
1728
  return { content: text };
@@ -1251,15 +1738,17 @@ export async function setupOcAdvisorV2(ctx) {
1251
1738
  return;
1252
1739
  // `event.tools` lists the direct tools available to this request.
1253
1740
  // OpenCode drops entries a hook adds for tools it did not register,
1254
- // so the hook can only hide the tool (Fable sessions), never add it.
1255
- // The checkpoint instruction is injected only when the tool is
1256
- // actually available, e.g. not when a permission rule removed it.
1741
+ // so the hook can only hide the tool (Fable or opted-out sessions),
1742
+ // never add it. The checkpoint instruction is injected only when
1743
+ // the tool is actually available, e.g. not when a permission rule
1744
+ // removed it.
1745
+ const disabled = advisorDisabledReason(event.model, advisorConfig);
1257
1746
  let available = false;
1258
1747
  for (const key of Object.keys(event.tools)) {
1259
1748
  if (!isAdvisorToolName(key)) {
1260
1749
  continue;
1261
1750
  }
1262
- if (isFableModel(event.model)) {
1751
+ if (disabled) {
1263
1752
  delete event.tools[key];
1264
1753
  }
1265
1754
  else {
@@ -1292,5 +1781,5 @@ export const OcAdvisorPluginV2 = plugin;
1292
1781
  export default plugin;
1293
1782
  // Named exports for unit tests (bun test). The plugin entrypoint is the
1294
1783
  // default export above.
1295
- export { ADVISOR_TRIGGERS, CHECKPOINT_INSTRUCTION, DEFAULT_ADVISOR_CONFIG, TOOL_DESCRIPTION, buildAdvisorInputSchema, buildAdvisorPrompt, buildToolDescription, checkAdvisorSupport, classifyAdvisorError, ensureAdvisorSession, extractGeneratedText, findAdvisorModel, hasAdvisorConnection, inferTrigger, isAdvisorToolName, isFableModel, isProviderUsable, parseModelRef, resolveAdvisorConfig, resolveAdvisorVariant, resolveRequestedEffort, resetAdvisorSessionCache, unwrapData, withTimeout, };
1784
+ export { ADVISOR_TRIGGERS, CHECKPOINT_INSTRUCTION, DEFAULT_ADVISOR_CONFIG, TOOL_DESCRIPTION, buildAdvisorInputSchema, buildAdvisorPrompt, buildToolDescription, checkAdvisorSupport, classifyAdvisorError, ensureAdvisorSession, extractGeneratedText, findAdvisorModel, hasAdvisorConnection, advisorDisabledReason, inferTrigger, isAdvisorToolName, isFableModel, isProviderUsable, parseModelRef, resolveAdvisorConfig, resolveAdvisorVariant, resolveRequestedEffort, resolveSupportedEfforts, resetAdvisorSessionCache, resetBenchmarkStores, unwrapData, withTimeout, formatV2Message, runAdvisor, };
1296
1785
  //# sourceMappingURL=ocAdvisor.js.map