@pfoundation/ocadvisor 26.9.1 → 26.9.2

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 +388 -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 +19 -0
  7. package/dist/benchmarkConfig.d.ts.map +1 -0
  8. package/dist/benchmarkConfig.js +58 -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 +57 -0
  15. package/dist/benchmarkMatch.d.ts.map +1 -0
  16. package/dist/benchmarkMatch.js +236 -0
  17. package/dist/benchmarkMatch.js.map +1 -0
  18. package/dist/benchmarkStore.d.ts +44 -0
  19. package/dist/benchmarkStore.d.ts.map +1 -0
  20. package/dist/benchmarkStore.js +308 -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 +377 -0
  33. package/dist/cli.js.map +1 -0
  34. package/dist/data/artificialAnalysis.mappings.json +197 -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 +502 -44
  43. package/dist/ocAdvisor.js.map +1 -1
  44. package/dist/typesafeGate.d.ts +89 -0
  45. package/dist/typesafeGate.d.ts.map +1 -0
  46. package/dist/typesafeGate.js +463 -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,59 @@ 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 }` with absolute locations. `undefined`/`null`
111
+ // means "not set"; blank entries are dropped; unknown keys are ignored.
112
+ // Invalid explicit values throw so misconfiguration fails fast at setup
113
+ // instead of silently degrading to default benchmark data.
114
+ function normalizeBenchmarks(value) {
115
+ if (value === undefined || value === null)
116
+ return undefined;
117
+ if (typeof value !== "object" || Array.isArray(value)) {
118
+ throw new Error("benchmarks must be an object with path options.");
119
+ }
120
+ const raw = value;
121
+ const out = {};
122
+ for (const key of ["path", "mappingsPath"]) {
123
+ const entry = raw[key];
124
+ if (entry === undefined || entry === null)
125
+ continue;
126
+ if (typeof entry !== "string") {
127
+ throw new Error(`benchmarks.${key} must be an absolute path.`);
128
+ }
129
+ const trimmed = entry.trim();
130
+ if (!trimmed)
131
+ continue;
132
+ if (!isAbsolute(trimmed)) {
133
+ throw new Error(`benchmarks.${key} must be an absolute path.`);
134
+ }
135
+ out[key] = trimmed;
136
+ }
137
+ return out;
138
+ }
75
139
  function toBoundedInt(value, min) {
76
140
  const raw = typeof value === "number"
77
141
  ? value
@@ -108,7 +172,7 @@ function parseModelRef(ref) {
108
172
  }
109
173
  return out;
110
174
  }
111
- function applyAdvisorConfigSource(base, src) {
175
+ function applyAdvisorConfigSource(base, src, env = process.env) {
112
176
  if (!src || typeof src !== "object")
113
177
  return base;
114
178
  const next = { ...base };
@@ -135,12 +199,31 @@ function applyAdvisorConfigSource(base, src) {
135
199
  if (normalized !== undefined)
136
200
  next.agentEffort = normalized;
137
201
  }
202
+ const disabledForModels = normalizeDisabledForModels(src.disabledForModels);
203
+ if (disabledForModels !== undefined) {
204
+ next.disabledForModels = disabledForModels;
205
+ }
206
+ // Benchmark paths merge per field so a plugin option can override one
207
+ // location while the other still falls back to the environment default.
208
+ const benchmarks = normalizeBenchmarks(src.benchmarks);
209
+ if (benchmarks !== undefined) {
210
+ next.benchmarks = { ...next.benchmarks, ...benchmarks };
211
+ }
138
212
  const timeout = toBoundedInt(src.timeoutMs ?? src.timeout_ms, 1);
139
213
  if (timeout !== undefined)
140
214
  next.timeoutMs = timeout;
141
215
  const cap = toBoundedInt(src.maxTranscriptChars ?? src.max_transcript_chars, 0);
142
216
  if (cap !== undefined)
143
217
  next.maxTranscriptChars = cap;
218
+ if (src.typesafe !== undefined) {
219
+ const normalized = normalizeTypeSafeOptions(src.typesafe);
220
+ if ("error" in normalized) {
221
+ throw new Error(normalized.error);
222
+ }
223
+ next.typesafeSource = normalized;
224
+ }
225
+ next.typesafe = resolveTypeSafeConfig(next.typesafeSource, env);
226
+ next.typesafeSettings = next.typesafe.settings;
144
227
  return next;
145
228
  }
146
229
  function envAdvisorConfigSource(env = process.env) {
@@ -151,13 +234,21 @@ function envAdvisorConfigSource(env = process.env) {
151
234
  timeoutMs: env.OCADVISOR_TIMEOUT_MS,
152
235
  maxTranscriptChars: env.OCADVISOR_MAX_TRANSCRIPT_CHARS,
153
236
  agentEffort: env.OCADVISOR_AGENT_EFFORT,
237
+ disabledForModels: env.OCADVISOR_DISABLED_FOR_MODELS,
238
+ benchmarks: env.OCADVISOR_BENCHMARKS_PATH === undefined &&
239
+ env.OCADVISOR_BENCHMARK_MAPPINGS_PATH === undefined
240
+ ? undefined
241
+ : {
242
+ path: env.OCADVISOR_BENCHMARKS_PATH,
243
+ mappingsPath: env.OCADVISOR_BENCHMARK_MAPPINGS_PATH,
244
+ },
154
245
  };
155
246
  }
156
247
  // Precedence (low to high): built-in defaults, environment variables,
157
248
  // plugin options from opencode.json.
158
249
  function resolveAdvisorConfig(options, env = process.env) {
159
- let config = applyAdvisorConfigSource(DEFAULT_ADVISOR_CONFIG, envAdvisorConfigSource(env));
160
- config = applyAdvisorConfigSource(config, options);
250
+ let config = applyAdvisorConfigSource(DEFAULT_ADVISOR_CONFIG, envAdvisorConfigSource(env), env);
251
+ config = applyAdvisorConfigSource(config, options, env);
161
252
  return config;
162
253
  }
163
254
  const SYSTEM_BASE = `You are a senior advisor reviewing a coding agent's work. You have the full session transcript.
@@ -174,15 +265,17 @@ You are a debugger. Analyze error patterns, stack traces, and failed attempts in
174
265
  };
175
266
  const TOOL_DESCRIPTION = `Consult a senior advisor model with your full session transcript — including parent sessions for subagents — for high-quality analysis.
176
267
 
177
- Use advisor selectively on substantial, non-trivial work. Straightforward tasks normally need no consultation.
268
+ Use advisor when an independent perspective could improve the approach, help resolve a problem, or strengthen an implementation review.
269
+
270
+ 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
271
 
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.
272
+ Ask a concrete, focused question. Avoid repeating settled questions without new context. Straightforward tasks usually need no consultation.
181
273
 
182
274
  Rules:
183
275
  - 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.
276
+ - Reconcile an advisor conflict with primary-source evidence via one "followup" call stating both sides.
185
277
  - 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.
278
+ - 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
279
 
187
280
  Args: "mode" (general, review, plan, debug), "trigger" (before_approach, stuck, pre_complete, followup, other), "question" (concrete question focusing the advisor).
188
281
  `;
@@ -195,7 +288,7 @@ function buildToolDescription(config = DEFAULT_ADVISOR_CONFIG) {
195
288
  return (TOOL_DESCRIPTION +
196
289
  `Optional "effort" (one of: ${config.agentEffort.join(", ")}): reasoning effort for this consultation; omit to use the configured variant.\n`);
197
290
  }
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.`;
291
+ 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
292
  const ADVISOR_TRIGGERS = [
200
293
  "before_approach",
201
294
  "stuck",
@@ -240,8 +333,10 @@ function buildAdvisorInputSchema(config = DEFAULT_ADVISOR_CONFIG) {
240
333
  },
241
334
  };
242
335
  }
243
- function openDb() {
244
- return new Database(DB_PATH, { readonly: true });
336
+ function openDb(runtime) {
337
+ return new Database(runtime?.__advisorTest?.dbPath ?? DB_PATH, {
338
+ readonly: true,
339
+ });
245
340
  }
246
341
  function tableExists(db, name) {
247
342
  const row = db
@@ -267,6 +362,22 @@ function isFableModel(model) {
267
362
  const id = String(model.id || model.modelID || "").toLowerCase();
268
363
  return provider.includes("anthropic") && id.includes("fable");
269
364
  }
365
+ function advisorDisabledReason(model, config) {
366
+ if (isFableModel(model)) {
367
+ return { outcome: "skipped_fable", message: FABLE_DISABLED };
368
+ }
369
+ const provider = model?.providerID || model?.provider;
370
+ const id = model?.id || model?.modelID;
371
+ if (!provider || !id)
372
+ return null;
373
+ const ref = `${provider}/${id}`;
374
+ if (!config.disabledForModels.includes(ref))
375
+ return null;
376
+ return {
377
+ outcome: "skipped_model",
378
+ message: `advisor is disabled (model opt-out): ${ref} is listed in disabledForModels.`,
379
+ };
380
+ }
270
381
  function inferTrigger(mode, trigger) {
271
382
  if (trigger && ADVISOR_TRIGGERS.includes(trigger)) {
272
383
  return trigger;
@@ -405,6 +516,13 @@ function isAdvisorToolName(name) {
405
516
  function countPriorAdvisorCalls(db, sessionId) {
406
517
  const callIds = new Set();
407
518
  const modes = [];
519
+ const questions = [];
520
+ const record = (input) => {
521
+ modes.push(String(input?.mode || "general"));
522
+ if (typeof input?.question === "string" && input.question.trim()) {
523
+ questions.push(input.question);
524
+ }
525
+ };
408
526
  try {
409
527
  for (const sid of collectSessionChain(db, sessionId)) {
410
528
  if (tableExists(db, "session_message")) {
@@ -433,7 +551,7 @@ function countPriorAdvisorCalls(db, sessionId) {
433
551
  const cid = String(block.id || block.callID || row.id);
434
552
  if (!callIds.has(cid)) {
435
553
  callIds.add(cid);
436
- modes.push(String(state.input?.mode || "general"));
554
+ record(state.input);
437
555
  }
438
556
  }
439
557
  nested.forEach((call, index) => {
@@ -441,7 +559,7 @@ function countPriorAdvisorCalls(db, sessionId) {
441
559
  const cid = `${block.id || row.id}#${index}`;
442
560
  if (!callIds.has(cid)) {
443
561
  callIds.add(cid);
444
- modes.push(String(call.input?.mode || "general"));
562
+ record(call.input);
445
563
  }
446
564
  }
447
565
  });
@@ -469,22 +587,88 @@ function countPriorAdvisorCalls(db, sessionId) {
469
587
  if (!cid || callIds.has(cid))
470
588
  continue;
471
589
  callIds.add(cid);
472
- modes.push(String(block.state?.input?.mode || "general"));
590
+ record(block.state?.input);
473
591
  }
474
592
  }
475
593
  }
476
594
  }
477
595
  catch { }
478
- return { count: callIds.size, modes };
596
+ return { count: callIds.size, modes, questions };
479
597
  }
480
- async function logAdvisorMetrics(metrics) {
598
+ async function logAdvisorMetrics(runtime, metrics) {
481
599
  try {
482
- await appendFile(METRICS_PATH, JSON.stringify(metrics) + "\n", "utf-8");
600
+ const path = runtime?.__advisorTest?.metricsPath ?? METRICS_PATH;
601
+ await appendFile(path, JSON.stringify(metrics) + "\n", "utf-8");
483
602
  }
484
603
  catch {
485
604
  // Metrics must never break the advisor call.
486
605
  }
487
606
  }
607
+ // Flattens a gate decision into the additive metrics record. A bypassed gate
608
+ // reports `bypass` so reports can separate bypass, skip, fallback, and
609
+ // allowed proceed without inferring from other fields.
610
+ function gateRecordFrom(decision) {
611
+ if (decision.status === "disabled") {
612
+ return {
613
+ status: "bypass",
614
+ reason: "disabled",
615
+ model: null,
616
+ neededProbability: null,
617
+ suggestedEffort: null,
618
+ effortConfidence: null,
619
+ effectiveEffort: null,
620
+ effortSource: null,
621
+ latencyMs: 0,
622
+ stateBytes: 0,
623
+ truncated: false,
624
+ inputTokens: null,
625
+ outputTokens: null,
626
+ };
627
+ }
628
+ const metrics = decision.metrics;
629
+ return {
630
+ status: decision.status,
631
+ reason: decision.reason,
632
+ model: metrics.model,
633
+ neededProbability: metrics.neededProbability,
634
+ suggestedEffort: metrics.suggestedEffort,
635
+ effortConfidence: metrics.effortConfidence,
636
+ effectiveEffort: decision.status === "proceed" ? decision.effectiveEffort : null,
637
+ effortSource: decision.status === "proceed" ? decision.effortSource : null,
638
+ latencyMs: metrics.latencyMs,
639
+ stateBytes: metrics.stateBytes,
640
+ truncated: metrics.truncated,
641
+ inputTokens: metrics.inputTokens,
642
+ outputTokens: metrics.outputTokens,
643
+ };
644
+ }
645
+ // Human-readable gate summary for the tool output. A bypassed gate contributes
646
+ // nothing, so keyless or disabled installs keep the plain footer.
647
+ export function gateSummary(gate) {
648
+ if (!gate || gate.status === "bypass")
649
+ return null;
650
+ const parts = [];
651
+ if (gate.neededProbability !== null) {
652
+ parts.push(`need=${gate.neededProbability.toFixed(2)}`);
653
+ }
654
+ if (gate.effectiveEffort) {
655
+ parts.push(gate.effortSource === "typesafe"
656
+ ? `effort=${gate.effectiveEffort} (gate)`
657
+ : `effort=${gate.effectiveEffort}`);
658
+ }
659
+ switch (gate.status) {
660
+ case "skip":
661
+ parts.push("decision=skip", gate.reason);
662
+ break;
663
+ case "fallback":
664
+ parts.push("decision=fallback", gate.reason);
665
+ break;
666
+ default:
667
+ parts.push("decision=proceed");
668
+ break;
669
+ }
670
+ return `gate: ${parts.join(", ")}`;
671
+ }
488
672
  function getSession(db, sessionId) {
489
673
  return db
490
674
  .query("SELECT id, parent_id, title, directory FROM session WHERE id = ?")
@@ -759,6 +943,20 @@ function resolveAdvisorVariant(model, config = DEFAULT_ADVISOR_CONFIG, requested
759
943
  const ids = model.variants.map((variant) => variant?.id);
760
944
  return ids.includes(config.variant) ? config.variant : undefined;
761
945
  }
946
+ // Effort levels the gate may choose: plugin-allowed candidates intersected
947
+ // with the model's live variants. Unknown discovery yields no candidates,
948
+ // which keeps the configured effort and omits automatic effort selection.
949
+ function resolveSupportedEfforts(model, config) {
950
+ const allowed = config.agentEffort && config.agentEffort.length > 0
951
+ ? config.agentEffort
952
+ : null;
953
+ if (!allowed || !model || !Array.isArray(model.variants))
954
+ return [];
955
+ const variants = new Set(model.variants
956
+ .map((variant) => variant?.id)
957
+ .filter((id) => !!id));
958
+ return allowed.filter((effort) => variants.has(effort));
959
+ }
762
960
  function hasAdvisorConnection(connection) {
763
961
  if (connection === null || connection === undefined)
764
962
  return false;
@@ -772,11 +970,50 @@ function hasAdvisorConnection(connection) {
772
970
  }
773
971
  return true;
774
972
  }
973
+ // Discovery namespaces: the documented V2 `model`/`provider` domains win,
974
+ // the legacy `catalog` namespace stays for older servers and fixtures.
975
+ // Calls stay in method form so any receiver-bound implementation keeps
976
+ // working.
977
+ function discoveryModelApi(runtime) {
978
+ if (runtime.model && typeof runtime.model.list === "function") {
979
+ return runtime.model;
980
+ }
981
+ if (runtime.catalog?.model &&
982
+ typeof runtime.catalog.model.list === "function") {
983
+ return runtime.catalog.model;
984
+ }
985
+ return null;
986
+ }
987
+ function discoveryProviderApi(runtime) {
988
+ if (runtime.provider && typeof runtime.provider.get === "function") {
989
+ return runtime.provider;
990
+ }
991
+ if (runtime.catalog?.provider &&
992
+ typeof runtime.catalog.provider.get === "function") {
993
+ return runtime.catalog.provider;
994
+ }
995
+ return null;
996
+ }
997
+ // Shared by support checks and the effort gate; null means discovery is
998
+ // unavailable or the configured model was not found.
999
+ async function findAdvisorCatalogModel(runtime, config) {
1000
+ const api = discoveryModelApi(runtime);
1001
+ if (!api)
1002
+ return null;
1003
+ try {
1004
+ const models = unwrapData((await api.list()));
1005
+ return findAdvisorModel(models, config);
1006
+ }
1007
+ catch {
1008
+ return null;
1009
+ }
1010
+ }
775
1011
  async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG, requestedVariant) {
776
- if (typeof runtime.catalog?.provider?.get === "function") {
1012
+ const providerApi = discoveryProviderApi(runtime);
1013
+ if (providerApi) {
777
1014
  let provider = null;
778
1015
  try {
779
- provider = unwrapData((await runtime.catalog.provider.get({
1016
+ provider = unwrapData((await providerApi.get({
780
1017
  providerID: config.provider,
781
1018
  })));
782
1019
  }
@@ -794,10 +1031,11 @@ async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG, req
794
1031
  }
795
1032
  }
796
1033
  let variant = requestedVariant ?? config.variant;
797
- if (typeof runtime.catalog?.model?.list === "function") {
1034
+ const modelApi = discoveryModelApi(runtime);
1035
+ if (modelApi) {
798
1036
  let models = null;
799
1037
  try {
800
- models = unwrapData((await runtime.catalog.model.list()));
1038
+ models = unwrapData((await modelApi.list()));
801
1039
  }
802
1040
  catch (err) {
803
1041
  return {
@@ -1025,10 +1263,25 @@ async function callAdvisor(opts) {
1025
1263
  const text = await enqueueAdvisor(() => withTimeout((async () => {
1026
1264
  const sessionId = await ensureAdvisorSession(runtime, support.variant, config);
1027
1265
  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);
1266
+ const startedAt = Date.now();
1267
+ const run = async () => {
1268
+ const result = opts.signal
1269
+ ? await generate(request, { signal: opts.signal })
1270
+ : await generate(request);
1271
+ return extractGeneratedText(result);
1272
+ };
1273
+ // A generation can come back without text (transient provider
1274
+ // behavior, e.g. a reasoning-only response with adaptive thinking).
1275
+ // Retry once while most of the timeout budget remains; never retry
1276
+ // after a caller cancellation.
1277
+ let output = await run();
1278
+ if (!output?.trim() && !opts.signal?.aborted) {
1279
+ const elapsed = Date.now() - startedAt;
1280
+ if (elapsed < config.timeoutMs / 2) {
1281
+ console.log(`[advisor] empty generation; retrying once (session=${sessionId} elapsedMs=${elapsed})`);
1282
+ output = await run();
1283
+ }
1284
+ }
1032
1285
  if (!output?.trim()) {
1033
1286
  throw new Error("Advisor returned an empty response.");
1034
1287
  }
@@ -1044,6 +1297,85 @@ async function callAdvisor(opts) {
1044
1297
  variant: support.variant,
1045
1298
  };
1046
1299
  }
1300
+ // Benchmark stores live for the process, keyed by resolved user paths (and
1301
+ // test seed overrides), so every consultation observes CLI refreshes
1302
+ // without re-reading unchanged files. Tests reset between cases.
1303
+ const benchmarkStores = new Map();
1304
+ function resetBenchmarkStores() {
1305
+ benchmarkStores.clear();
1306
+ }
1307
+ function benchmarkStoreKey(snapshotPath, mappingsPath, seedSnapshotPath, seedMappingsPath) {
1308
+ return [
1309
+ snapshotPath,
1310
+ mappingsPath,
1311
+ seedSnapshotPath ?? "",
1312
+ seedMappingsPath ?? "",
1313
+ ].join("\n");
1314
+ }
1315
+ // Loads one consistent benchmark view and assembles gate evidence from it.
1316
+ // Any failure (bad paths, unreadable files, unexpected errors) yields null
1317
+ // and the consultation proceeds without benchmark enrichment.
1318
+ async function loadBenchmarkEvidence(runtime, config, requester, advisor) {
1319
+ try {
1320
+ const testOpts = runtime?.__advisorTest?.benchmarkStoreOptions;
1321
+ const paths = resolveBenchmarkPaths(config.benchmarks, testOpts?.env ?? process.env);
1322
+ if ("error" in paths)
1323
+ return null;
1324
+ const key = benchmarkStoreKey(paths.snapshotPath, paths.mappingsPath, testOpts?.seedSnapshotPath, testOpts?.seedMappingsPath);
1325
+ let store = benchmarkStores.get(key);
1326
+ if (!store) {
1327
+ store = await createBenchmarkStore({
1328
+ snapshotPath: paths.snapshotPath,
1329
+ mappingsPath: paths.mappingsPath,
1330
+ seedSnapshotPath: testOpts?.seedSnapshotPath,
1331
+ seedMappingsPath: testOpts?.seedMappingsPath,
1332
+ fs: testOpts?.fs,
1333
+ maxBytes: testOpts?.maxBytes,
1334
+ });
1335
+ benchmarkStores.set(key, store);
1336
+ }
1337
+ const view = await store.view();
1338
+ return {
1339
+ view,
1340
+ evidence: buildBenchmarkEvidence({ requester, advisor, view }),
1341
+ };
1342
+ }
1343
+ catch {
1344
+ return null;
1345
+ }
1346
+ }
1347
+ function summarizeBenchmarkEvidence(loaded, finalEffort) {
1348
+ if (!loaded)
1349
+ return undefined;
1350
+ const { view, evidence } = loaded;
1351
+ const { requester, advisor } = evidence.models;
1352
+ const policy = advisor.policy;
1353
+ const finalMatch = finalEffort === null
1354
+ ? null
1355
+ : view.matcher.match({
1356
+ providerID: advisor.providerID,
1357
+ modelID: advisor.modelID,
1358
+ variant: finalEffort,
1359
+ }).status;
1360
+ return {
1361
+ source: evidence.benchmarks.source,
1362
+ contentHash: evidence.benchmarks.contentHash,
1363
+ fetchedAt: evidence.benchmarks.fetchedAt,
1364
+ hashVerified: evidence.benchmarks.hashVerified,
1365
+ requester: requester.providerID && requester.modelID
1366
+ ? `${requester.providerID}/${requester.modelID}${requester.variant ? `#${requester.variant}` : ""} (${requester.provenance})`
1367
+ : null,
1368
+ requesterMatch: evidence.benchmarks.requesterMatch.status,
1369
+ advisorPolicy: policy.kind === "pinned"
1370
+ ? `pinned:${policy.effort}`
1371
+ : policy.kind === "candidates"
1372
+ ? `candidates:${policy.candidates.join(",")}>${policy.fallback ?? "none"}`
1373
+ : `fixed:${policy.effort ?? "none"}`,
1374
+ advisorMatch: evidence.benchmarks.advisorDefaultMatch.status,
1375
+ finalEffort,
1376
+ finalMatch,
1377
+ };
1378
+ }
1047
1379
  async function runAdvisor(opts) {
1048
1380
  const started = Date.now();
1049
1381
  const config = opts.config ?? DEFAULT_ADVISOR_CONFIG;
@@ -1052,7 +1384,7 @@ async function runAdvisor(opts) {
1052
1384
  const sessionId = opts.sessionId;
1053
1385
  const questionChars = opts.question?.length ?? 0;
1054
1386
  if (!sessionId) {
1055
- await logAdvisorMetrics({
1387
+ await logAdvisorMetrics(opts.runtime, {
1056
1388
  ts: new Date().toISOString(),
1057
1389
  sessionId: null,
1058
1390
  callerModel: null,
@@ -1075,13 +1407,14 @@ async function runAdvisor(opts) {
1075
1407
  }
1076
1408
  let db = null;
1077
1409
  try {
1078
- db = openDb();
1410
+ db = openDb(opts.runtime);
1079
1411
  const info = getSessionInfo(db, sessionId);
1080
1412
  const callerModel = callerLabel(info?.model ?? null);
1081
1413
  const callerAgent = opts.callerAgent || info?.agent || null;
1082
1414
  const directory = opts.callerDirectory || info?.directory || null;
1083
- if (isFableModel(info?.model)) {
1084
- await logAdvisorMetrics({
1415
+ const disabled = advisorDisabledReason(info?.model, config);
1416
+ if (disabled) {
1417
+ await logAdvisorMetrics(opts.runtime, {
1085
1418
  ts: new Date().toISOString(),
1086
1419
  sessionId,
1087
1420
  callerModel,
@@ -1091,7 +1424,7 @@ async function runAdvisor(opts) {
1091
1424
  trigger,
1092
1425
  questionChars,
1093
1426
  effort: null,
1094
- outcome: "skipped_fable",
1427
+ outcome: disabled.outcome,
1095
1428
  errorType: null,
1096
1429
  latencyMs: Date.now() - started,
1097
1430
  inputTokens: null,
@@ -1100,12 +1433,12 @@ async function runAdvisor(opts) {
1100
1433
  priorConsultations: 0,
1101
1434
  via: "opencode-session",
1102
1435
  });
1103
- console.log(`[advisor] session=${sessionId} mode=${mode} outcome=skipped_fable (already Fable)`);
1104
- return FABLE_DISABLED;
1436
+ console.log(`[advisor] session=${sessionId} mode=${mode} outcome=${disabled.outcome}`);
1437
+ return disabled.message;
1105
1438
  }
1106
1439
  let transcript = buildTranscript(db, sessionId);
1107
1440
  if (!transcript?.trim()) {
1108
- await logAdvisorMetrics({
1441
+ await logAdvisorMetrics(opts.runtime, {
1109
1442
  ts: new Date().toISOString(),
1110
1443
  sessionId,
1111
1444
  callerModel,
@@ -1134,26 +1467,141 @@ async function runAdvisor(opts) {
1134
1467
  "... (older transcript trimmed to fit maxTranscriptChars) ...\n\n" +
1135
1468
  transcript.slice(-config.maxTranscriptChars);
1136
1469
  }
1470
+ // Invocation-correct requester identity for benchmark matching: the
1471
+ // model that produced this tool call, resolved after the early guards
1472
+ // so skipped calls pay for no extra reads.
1473
+ const requesterProfile = resolveRequesterProfile(db, {
1474
+ sessionId,
1475
+ messageID: opts.callerMessageID,
1476
+ callID: opts.callerCallID,
1477
+ sessionModel: info?.model ?? null,
1478
+ });
1137
1479
  const prior = countPriorAdvisorCalls(db, sessionId);
1138
1480
  const priorNote = prior.count > 0
1139
1481
  ? `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
1482
  : null;
1141
1483
  const systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.general;
1142
1484
  let requestedEffort;
1485
+ let effectiveEffort;
1486
+ let gateRecord;
1487
+ let benchmarkLoaded = null;
1143
1488
  try {
1144
1489
  requestedEffort = resolveRequestedEffort(config, opts.effort);
1490
+ // Optional TypeSafe preflight: one bounded request decides whether this
1491
+ // consultation is worth the expensive generation and, when the caller
1492
+ // did not pin an effort, which supported effort fits. Gate failures
1493
+ // fall back to the ordinary behavior and never fail the call.
1494
+ let gateDecision = null;
1495
+ let advisorProfile = null;
1496
+ if (config.typesafe.keyPresent && !config.typesafeSource.disabled) {
1497
+ const catalogModel = await findAdvisorCatalogModel(opts.runtime, config);
1498
+ const supportedEfforts = resolveSupportedEfforts(catalogModel, config);
1499
+ advisorProfile = resolveAdvisorProfile({
1500
+ providerID: config.provider,
1501
+ modelID: config.model,
1502
+ requestedEffort,
1503
+ supportedEfforts,
1504
+ defaultEffort: requestedEffort ??
1505
+ resolveAdvisorVariant(catalogModel, config) ??
1506
+ null,
1507
+ });
1508
+ const gateSettings = config.typesafe.settings ?? TYPESAFE_DEFAULTS;
1509
+ benchmarkLoaded = await loadBenchmarkEvidence(opts.runtime, config, requesterProfile, advisorProfile);
1510
+ gateDecision = await runTypeSafeGate({
1511
+ state: buildDecisionState(db, sessionId, {
1512
+ question: opts.question ?? null,
1513
+ mode,
1514
+ trigger,
1515
+ explicitEffort: requestedEffort ?? null,
1516
+ caller: callerModel,
1517
+ directory,
1518
+ priorConsultations: prior.count,
1519
+ priorModes: prior.modes,
1520
+ priorQuestions: prior.questions,
1521
+ supportedEfforts,
1522
+ defaultEffort: requestedEffort ?? config.variant ?? null,
1523
+ maxStateBytes: gateSettings.maxStateBytes,
1524
+ models: benchmarkLoaded?.evidence.models ?? null,
1525
+ benchmarks: benchmarkLoaded?.evidence.benchmarks ?? null,
1526
+ formatMessage: formatV2Message,
1527
+ }),
1528
+ input: {
1529
+ mode,
1530
+ trigger,
1531
+ question: opts.question ?? null,
1532
+ explicitEffort: requestedEffort ?? null,
1533
+ supportedEfforts,
1534
+ defaultEffort: requestedEffort ?? config.variant ?? null,
1535
+ },
1536
+ settings: gateSettings,
1537
+ keyPresent: config.typesafe.keyPresent,
1538
+ client: opts.runtime?.__advisorTest?.gateClient,
1539
+ env: opts.runtime?.__advisorTest?.gateEnv,
1540
+ fetchImpl: opts.runtime?.__advisorTest?.gateFetch,
1541
+ signal: opts.signal,
1542
+ });
1543
+ gateRecord = gateRecordFrom(gateDecision);
1544
+ }
1545
+ // Profiles resolve for every consultation: with discovery the advisor
1546
+ // policy reflects live variants, otherwise it fixes to the configured
1547
+ // effort (or the caller's pin).
1548
+ advisorProfile ??= resolveAdvisorProfile({
1549
+ providerID: config.provider,
1550
+ modelID: config.model,
1551
+ requestedEffort,
1552
+ supportedEfforts: [],
1553
+ defaultEffort: config.variant ?? null,
1554
+ });
1555
+ opts.runtime?.__advisorTest?.profileSink?.({
1556
+ requester: requesterProfile,
1557
+ advisor: advisorProfile,
1558
+ });
1559
+ if (gateDecision?.status === "skip") {
1560
+ const latencyMs = Date.now() - started;
1561
+ await logAdvisorMetrics(opts.runtime, {
1562
+ ts: new Date().toISOString(),
1563
+ sessionId,
1564
+ callerModel,
1565
+ callerAgent,
1566
+ directory,
1567
+ mode,
1568
+ trigger,
1569
+ questionChars,
1570
+ effort: null,
1571
+ outcome: "skipped_typesafe",
1572
+ errorType: null,
1573
+ latencyMs,
1574
+ inputTokens: null,
1575
+ outputTokens: null,
1576
+ transcriptChars: transcript.length,
1577
+ priorConsultations: prior.count,
1578
+ via: "opencode-session",
1579
+ gate: gateRecord,
1580
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, null),
1581
+ });
1582
+ console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=skipped_typesafe needed=${gateDecision.metrics.neededProbability ?? "n/a"} latencyMs=${latencyMs}`);
1583
+ const skipNote = gateSummary(gateRecord);
1584
+ return (`advisor consultation skipped (typesafe): the request did not need an advisor at this point` +
1585
+ `${gateDecision.metrics.neededProbability !== null ? ` (need probability ${gateDecision.metrics.neededProbability.toFixed(2)})` : ""}. ` +
1586
+ `Ask again with a concrete unresolved question if the situation changes.` +
1587
+ (skipNote ? `\n_${skipNote}_` : ""));
1588
+ }
1589
+ effectiveEffort =
1590
+ gateDecision?.status === "proceed" && gateDecision.effectiveEffort
1591
+ ? gateDecision.effectiveEffort
1592
+ : requestedEffort;
1145
1593
  const result = await callAdvisor({
1146
1594
  runtime: opts.runtime,
1147
1595
  systemPrompt,
1148
1596
  transcript,
1149
1597
  question: opts.question,
1150
1598
  priorNote,
1151
- effort: requestedEffort,
1599
+ effort: effectiveEffort,
1152
1600
  signal: opts.signal,
1153
1601
  config,
1154
1602
  });
1155
1603
  const latencyMs = Date.now() - started;
1156
- await logAdvisorMetrics({
1604
+ await logAdvisorMetrics(opts.runtime, {
1157
1605
  ts: new Date().toISOString(),
1158
1606
  sessionId,
1159
1607
  callerModel,
@@ -1171,16 +1619,20 @@ async function runAdvisor(opts) {
1171
1619
  transcriptChars: transcript.length,
1172
1620
  priorConsultations: prior.count,
1173
1621
  via: "opencode-session",
1622
+ gate: gateRecord,
1623
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, result.variant ?? effectiveEffort ?? requestedEffort ?? null),
1174
1624
  });
1175
1625
  console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=advisor_response latencyMs=${latencyMs}`);
1626
+ const gateNote = gateSummary(gateRecord);
1176
1627
  return (result.text +
1177
- `\n\n_advisor consultation #${prior.count + 1} in this session chain (trigger=${trigger})_`);
1628
+ `\n\n_advisor consultation #${prior.count + 1} in this session chain (trigger=${trigger})_` +
1629
+ (gateNote ? `\n_${gateNote}_` : ""));
1178
1630
  }
1179
1631
  catch (err) {
1180
1632
  const message = err instanceof Error ? err.message : String(err);
1181
1633
  const errorType = classifyAdvisorError(message);
1182
1634
  const latencyMs = Date.now() - started;
1183
- await logAdvisorMetrics({
1635
+ await logAdvisorMetrics(opts.runtime, {
1184
1636
  ts: new Date().toISOString(),
1185
1637
  sessionId,
1186
1638
  callerModel,
@@ -1189,7 +1641,7 @@ async function runAdvisor(opts) {
1189
1641
  mode,
1190
1642
  trigger,
1191
1643
  questionChars,
1192
- effort: requestedEffort ?? null,
1644
+ effort: effectiveEffort ?? requestedEffort ?? null,
1193
1645
  outcome: "error",
1194
1646
  errorType,
1195
1647
  latencyMs,
@@ -1198,6 +1650,8 @@ async function runAdvisor(opts) {
1198
1650
  transcriptChars: transcript.length,
1199
1651
  priorConsultations: prior.count,
1200
1652
  via: "opencode-session",
1653
+ gate: gateRecord,
1654
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, effectiveEffort ?? requestedEffort ?? null),
1201
1655
  });
1202
1656
  console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=error errorType=${errorType} latencyMs=${latencyMs}`);
1203
1657
  throw new Error(`advisor failed (${errorType}): ${message}`);
@@ -1236,6 +1690,8 @@ export async function setupOcAdvisorV2(ctx) {
1236
1690
  signal: context.abort,
1237
1691
  callerAgent: context.agent,
1238
1692
  callerDirectory: context.directory,
1693
+ callerMessageID: context.messageID,
1694
+ callerCallID: context.id,
1239
1695
  config: advisorConfig,
1240
1696
  });
1241
1697
  return { content: text };
@@ -1251,15 +1707,17 @@ export async function setupOcAdvisorV2(ctx) {
1251
1707
  return;
1252
1708
  // `event.tools` lists the direct tools available to this request.
1253
1709
  // 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.
1710
+ // so the hook can only hide the tool (Fable or opted-out sessions),
1711
+ // never add it. The checkpoint instruction is injected only when
1712
+ // the tool is actually available, e.g. not when a permission rule
1713
+ // removed it.
1714
+ const disabled = advisorDisabledReason(event.model, advisorConfig);
1257
1715
  let available = false;
1258
1716
  for (const key of Object.keys(event.tools)) {
1259
1717
  if (!isAdvisorToolName(key)) {
1260
1718
  continue;
1261
1719
  }
1262
- if (isFableModel(event.model)) {
1720
+ if (disabled) {
1263
1721
  delete event.tools[key];
1264
1722
  }
1265
1723
  else {
@@ -1292,5 +1750,5 @@ export const OcAdvisorPluginV2 = plugin;
1292
1750
  export default plugin;
1293
1751
  // Named exports for unit tests (bun test). The plugin entrypoint is the
1294
1752
  // 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, };
1753
+ 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
1754
  //# sourceMappingURL=ocAdvisor.js.map