@pfoundation/ocadvisor 26.9.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.
@@ -0,0 +1,1127 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { appendFile } from "fs/promises";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ const DB_PATH = join(homedir(), ".local/share/opencode/opencode.db");
6
+ const METRICS_PATH = join(homedir(), ".local/share/opencode/ocAdvisor-metrics.jsonl");
7
+ const ADVISOR_PROVIDER = "anthropic";
8
+ const ADVISOR_MODEL = "claude-fable-5-1";
9
+ const ADVISOR_VARIANT = "max";
10
+ const ADVISOR_SESSION_TITLE = "advisor";
11
+ // Sessions created before the ocAdvisor → advisor rename keep working: title
12
+ // discovery accepts both, and the storage key below is unchanged.
13
+ const LEGACY_ADVISOR_SESSION_TITLE = "ocAdvisor";
14
+ const ADVISOR_STORAGE_KEY = "advisorSessionID";
15
+ const ADVISOR_TIMEOUT_MS = 300_000;
16
+ const FABLE_DISABLED = "advisor is disabled for anthropic/claude-fable-* sessions — the current model is already Fable.";
17
+ const DEFAULT_ADVISOR_CONFIG = {
18
+ provider: ADVISOR_PROVIDER,
19
+ model: ADVISOR_MODEL,
20
+ variant: ADVISOR_VARIANT,
21
+ timeoutMs: ADVISOR_TIMEOUT_MS,
22
+ maxTranscriptChars: 0,
23
+ };
24
+ function normalizeVariant(value) {
25
+ if (value === null || value === undefined)
26
+ return undefined;
27
+ const text = String(value).trim();
28
+ if (!text || text.toLowerCase() === "none")
29
+ return undefined;
30
+ return text;
31
+ }
32
+ function toBoundedInt(value, min) {
33
+ const raw = typeof value === "number"
34
+ ? value
35
+ : typeof value === "string" && value.trim()
36
+ ? Number(value)
37
+ : NaN;
38
+ return Number.isFinite(raw) && raw >= min ? Math.floor(raw) : undefined;
39
+ }
40
+ // Splits a "provider/model#variant" reference. All three parts are optional;
41
+ // a bare "claude-opus-5" (no slash) is treated as a model id.
42
+ function parseModelRef(ref) {
43
+ const out = {};
44
+ let rest = ref.trim();
45
+ if (!rest)
46
+ return out;
47
+ const hash = rest.indexOf("#");
48
+ if (hash >= 0) {
49
+ const variant = rest.slice(hash + 1).trim();
50
+ if (variant)
51
+ out.variant = variant;
52
+ rest = rest.slice(0, hash).trim();
53
+ }
54
+ const slash = rest.indexOf("/");
55
+ if (slash >= 0) {
56
+ const provider = rest.slice(0, slash).trim();
57
+ const model = rest.slice(slash + 1).trim();
58
+ if (provider)
59
+ out.provider = provider;
60
+ if (model)
61
+ out.model = model;
62
+ }
63
+ else if (rest) {
64
+ out.model = rest;
65
+ }
66
+ return out;
67
+ }
68
+ function applyAdvisorConfigSource(base, src) {
69
+ if (!src || typeof src !== "object")
70
+ return base;
71
+ const next = { ...base };
72
+ if (typeof src.model === "string" && src.model.trim()) {
73
+ const parsed = parseModelRef(src.model);
74
+ if (parsed.provider)
75
+ next.provider = parsed.provider;
76
+ if (parsed.model)
77
+ next.model = parsed.model;
78
+ if (parsed.variant !== undefined) {
79
+ next.variant = normalizeVariant(parsed.variant);
80
+ }
81
+ }
82
+ if (typeof src.provider === "string" && src.provider.trim()) {
83
+ next.provider = src.provider.trim();
84
+ }
85
+ // An explicit `variant` (including null / "none") overrides the pin.
86
+ if (src.variant !== undefined) {
87
+ next.variant = normalizeVariant(src.variant);
88
+ }
89
+ const timeout = toBoundedInt(src.timeoutMs ?? src.timeout_ms, 1);
90
+ if (timeout !== undefined)
91
+ next.timeoutMs = timeout;
92
+ const cap = toBoundedInt(src.maxTranscriptChars ?? src.max_transcript_chars, 0);
93
+ if (cap !== undefined)
94
+ next.maxTranscriptChars = cap;
95
+ return next;
96
+ }
97
+ function envAdvisorConfigSource(env = process.env) {
98
+ return {
99
+ model: env.OCADVISOR_MODEL,
100
+ provider: env.OCADVISOR_PROVIDER,
101
+ variant: env.OCADVISOR_VARIANT,
102
+ timeoutMs: env.OCADVISOR_TIMEOUT_MS,
103
+ maxTranscriptChars: env.OCADVISOR_MAX_TRANSCRIPT_CHARS,
104
+ };
105
+ }
106
+ // Precedence (low to high): built-in defaults, environment variables,
107
+ // plugin options from opencode.json.
108
+ function resolveAdvisorConfig(options, env = process.env) {
109
+ let config = applyAdvisorConfigSource(DEFAULT_ADVISOR_CONFIG, envAdvisorConfigSource(env));
110
+ config = applyAdvisorConfigSource(config, options);
111
+ return config;
112
+ }
113
+ const SYSTEM_BASE = `You are a senior advisor reviewing a coding agent's work. You have the full session transcript.
114
+ Respond in 500-750 words with structured analysis and enumerated steps. Be direct and actionable.`;
115
+ const SYSTEM_PROMPTS = {
116
+ general: `${SYSTEM_BASE}
117
+ You are a senior software engineer. Analyze the conversation, identify issues, misunderstandings, or missed opportunities. Suggest corrections and improvements. If the agent is on the right track, confirm and suggest optimizations.`,
118
+ review: `${SYSTEM_BASE}
119
+ You are a code reviewer. Focus on correctness, edge cases, security vulnerabilities, performance issues, and adherence to best practices. Evaluate the code changes in context of the broader codebase patterns visible in the transcript.`,
120
+ plan: `${SYSTEM_BASE}
121
+ You are a software architect. Evaluate the current approach and plan. Identify risks, suggest alternatives, flag missing considerations. Assess whether the scope is appropriate and dependencies are accounted for.`,
122
+ debug: `${SYSTEM_BASE}
123
+ You are a debugger. Analyze error patterns, stack traces, and failed attempts in the transcript. Identify root causes, explain why previous fixes didn't work, and propose targeted solutions.`,
124
+ };
125
+ const TOOL_DESCRIPTION = `Consult a senior advisor model with your full session transcript — including parent sessions for subagents — for high-quality analysis.
126
+
127
+ Use advisor selectively on substantial, non-trivial work. Straightforward tasks normally need no consultation.
128
+
129
+ - 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.
130
+ - mode "general": a second opinion that does not fit the above.
131
+
132
+ Rules:
133
+ - Always pass a concrete "question" naming the decision or artifact under review.
134
+ - 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.
135
+ - 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.
136
+
137
+ Args: "mode" (general, review, plan, debug), "trigger" (before_approach, stuck, pre_complete, followup, other), "question" (concrete question focusing the advisor).
138
+ `;
139
+ 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.`;
140
+ const ADVISOR_TRIGGERS = [
141
+ "before_approach",
142
+ "stuck",
143
+ "pre_complete",
144
+ "followup",
145
+ "other",
146
+ ];
147
+ const ADVISOR_INPUT_SCHEMA = {
148
+ type: "object",
149
+ properties: {
150
+ mode: {
151
+ type: "string",
152
+ enum: ["general", "review", "plan", "debug"],
153
+ description: "Advisory mode: general, review, plan, or debug",
154
+ },
155
+ trigger: {
156
+ type: "string",
157
+ enum: ["before_approach", "stuck", "pre_complete", "followup", "other"],
158
+ description: "Why you are consulting now: before_approach, stuck, pre_complete, followup, or other",
159
+ },
160
+ question: {
161
+ type: "string",
162
+ description: "Concrete question naming the decision or artifact under review",
163
+ },
164
+ },
165
+ };
166
+ function openDb() {
167
+ return new Database(DB_PATH, { readonly: true });
168
+ }
169
+ function tableExists(db, name) {
170
+ const row = db
171
+ .query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
172
+ .get(name);
173
+ return Boolean(row);
174
+ }
175
+ function parseModelJson(raw) {
176
+ if (!raw)
177
+ return null;
178
+ try {
179
+ const parsed = JSON.parse(raw);
180
+ if (parsed && typeof parsed === "object")
181
+ return parsed;
182
+ }
183
+ catch { }
184
+ return null;
185
+ }
186
+ function isFableModel(model) {
187
+ if (!model)
188
+ return false;
189
+ const provider = String(model.providerID || model.provider || "").toLowerCase();
190
+ const id = String(model.id || model.modelID || "").toLowerCase();
191
+ return provider.includes("anthropic") && id.includes("fable");
192
+ }
193
+ function inferTrigger(mode, trigger) {
194
+ if (trigger && ADVISOR_TRIGGERS.includes(trigger)) {
195
+ return trigger;
196
+ }
197
+ switch ((mode || "general").toLowerCase()) {
198
+ case "plan":
199
+ return "before_approach";
200
+ case "debug":
201
+ return "stuck";
202
+ case "review":
203
+ return "pre_complete";
204
+ default:
205
+ return "other";
206
+ }
207
+ }
208
+ function classifyAdvisorError(message) {
209
+ const text = message.toLowerCase();
210
+ if (text.includes("credit balance is too low") ||
211
+ text.includes("insufficient")) {
212
+ return "insufficient_credit";
213
+ }
214
+ if (text.includes("rate_limit") || text.includes(" 429")) {
215
+ return "rate_limit";
216
+ }
217
+ if (text.includes("failed to parse json") ||
218
+ text.includes("unexpected token")) {
219
+ return "json_parse";
220
+ }
221
+ if (text.includes("aborted") || text.includes("abort")) {
222
+ return "aborted";
223
+ }
224
+ if (text.includes("timeout") || text.includes("timed out")) {
225
+ return "timeout";
226
+ }
227
+ if (text.includes(" 401") ||
228
+ text.includes("unauthorized") ||
229
+ text.includes("invalid x-api-key") ||
230
+ text.includes("authentication") ||
231
+ text.includes("no anthropic connection") ||
232
+ text.includes("connection configured") ||
233
+ text.includes("not connected") ||
234
+ text.includes("needs authentication") ||
235
+ text.includes("missing credential")) {
236
+ return "auth";
237
+ }
238
+ if (text.includes("model unavailable") ||
239
+ text.includes("model not found") ||
240
+ (text.includes("model") &&
241
+ (text.includes("not enabled") || text.includes("disabled")))) {
242
+ return "model_unavailable";
243
+ }
244
+ if (text.includes("provider") &&
245
+ (text.includes("unavailable") ||
246
+ text.includes("disabled") ||
247
+ text.includes("not found") ||
248
+ text.includes("service unavailable"))) {
249
+ return "provider_unavailable";
250
+ }
251
+ if (text.includes("no transcript"))
252
+ return "no_transcript";
253
+ if (text.includes("no session"))
254
+ return "no_session";
255
+ return "api_error";
256
+ }
257
+ function callerLabel(model) {
258
+ if (!model)
259
+ return null;
260
+ const provider = model.providerID || model.provider || "unknown";
261
+ const id = model.id || model.modelID || "unknown";
262
+ return `${provider}/${id}`;
263
+ }
264
+ function getSessionInfo(db, sessionId) {
265
+ for (const table of ["session_v2", "session"]) {
266
+ if (!tableExists(db, table))
267
+ continue;
268
+ try {
269
+ const row = db
270
+ .query(`SELECT model, agent, directory, parent_id FROM ${table} WHERE id = ?`)
271
+ .get(sessionId);
272
+ if (row) {
273
+ return {
274
+ model: parseModelJson(row.model),
275
+ agent: row.agent ?? null,
276
+ directory: row.directory ?? null,
277
+ parentId: row.parent_id ?? null,
278
+ };
279
+ }
280
+ }
281
+ catch { }
282
+ }
283
+ return null;
284
+ }
285
+ function collectSessionChain(db, sessionId) {
286
+ const chain = [];
287
+ const visited = new Set();
288
+ let current = sessionId;
289
+ while (current && !visited.has(current) && chain.length < 10) {
290
+ visited.add(current);
291
+ chain.push(current);
292
+ current = getSessionInfo(db, current)?.parentId ?? null;
293
+ }
294
+ return chain;
295
+ }
296
+ function isTerminalToolStatus(status) {
297
+ return status === "completed" || status === "error";
298
+ }
299
+ // Matches the current "advisor" tool name and the pre-rename "ocAdvisor"
300
+ // name so history counting and the context hook keep working across the
301
+ // rename. Normalization mirrors the hook: lowercase, letters only.
302
+ function isAdvisorToolName(name) {
303
+ if (typeof name !== "string")
304
+ return false;
305
+ const normalized = name.toLowerCase().replace(/[^a-z]/g, "");
306
+ return normalized === "advisor" || normalized === "ocadvisor";
307
+ }
308
+ function countPriorAdvisorCalls(db, sessionId) {
309
+ const callIds = new Set();
310
+ const modes = [];
311
+ try {
312
+ for (const sid of collectSessionChain(db, sessionId)) {
313
+ if (tableExists(db, "session_message")) {
314
+ const rows = db
315
+ .query("SELECT id, data FROM session_message WHERE session_id = ? AND type = 'assistant'")
316
+ .all(sid);
317
+ for (const row of rows) {
318
+ let data;
319
+ try {
320
+ data = JSON.parse(row.data);
321
+ }
322
+ catch {
323
+ continue;
324
+ }
325
+ for (const block of data.content || []) {
326
+ if (!block || typeof block !== "object")
327
+ continue;
328
+ if (block.type !== "tool")
329
+ continue;
330
+ const name = String(block.name || block.tool || "");
331
+ const state = block.state || {};
332
+ if (!isTerminalToolStatus(state.status))
333
+ continue;
334
+ const nested = state.metadata?.toolCalls || [];
335
+ if (isAdvisorToolName(name)) {
336
+ const cid = String(block.id || block.callID || row.id);
337
+ if (!callIds.has(cid)) {
338
+ callIds.add(cid);
339
+ modes.push(String(state.input?.mode || "general"));
340
+ }
341
+ }
342
+ nested.forEach((call, index) => {
343
+ if (isAdvisorToolName(call.tool || call.name)) {
344
+ const cid = `${block.id || row.id}#${index}`;
345
+ if (!callIds.has(cid)) {
346
+ callIds.add(cid);
347
+ modes.push(String(call.input?.mode || "general"));
348
+ }
349
+ }
350
+ });
351
+ }
352
+ }
353
+ }
354
+ if (tableExists(db, "part")) {
355
+ const rows = db
356
+ .query("SELECT data FROM part WHERE session_id = ? AND json_extract(data, '$.type') = 'tool'")
357
+ .all(sid);
358
+ for (const row of rows) {
359
+ let block;
360
+ try {
361
+ block = JSON.parse(row.data);
362
+ }
363
+ catch {
364
+ continue;
365
+ }
366
+ const name = String(block.tool || block.name || "");
367
+ if (!isAdvisorToolName(name))
368
+ continue;
369
+ if (!isTerminalToolStatus(block.state?.status))
370
+ continue;
371
+ const cid = String(block.callID || block.id || "");
372
+ if (!cid || callIds.has(cid))
373
+ continue;
374
+ callIds.add(cid);
375
+ modes.push(String(block.state?.input?.mode || "general"));
376
+ }
377
+ }
378
+ }
379
+ }
380
+ catch { }
381
+ return { count: callIds.size, modes };
382
+ }
383
+ async function logAdvisorMetrics(metrics) {
384
+ try {
385
+ await appendFile(METRICS_PATH, JSON.stringify(metrics) + "\n", "utf-8");
386
+ }
387
+ catch {
388
+ // Metrics must never break the advisor call.
389
+ }
390
+ }
391
+ function getSession(db, sessionId) {
392
+ return db
393
+ .query("SELECT id, parent_id, title, directory FROM session WHERE id = ?")
394
+ .get(sessionId);
395
+ }
396
+ function getSessionV2(db, sessionId) {
397
+ return db
398
+ .query("SELECT id, parent_id, title, directory FROM session_v2 WHERE id = ?")
399
+ .get(sessionId);
400
+ }
401
+ function getMessages(db, sessionId) {
402
+ return db
403
+ .query("SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC")
404
+ .all(sessionId);
405
+ }
406
+ function getParts(db, messageId) {
407
+ return db
408
+ .query("SELECT data, time_created FROM part WHERE message_id = ? ORDER BY time_created ASC")
409
+ .all(messageId);
410
+ }
411
+ function getSessionMessages(db, sessionId) {
412
+ return db
413
+ .query("SELECT type, data FROM session_message WHERE session_id = ? ORDER BY seq ASC")
414
+ .all(sessionId);
415
+ }
416
+ function truncate(value, max) {
417
+ return value.length > max ? value.slice(0, max) + "\n... (truncated)" : value;
418
+ }
419
+ function stringifyUnknown(value) {
420
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
421
+ }
422
+ function formatPartContent(part) {
423
+ switch (part.type) {
424
+ case "text":
425
+ return part.text || null;
426
+ case "tool": {
427
+ const name = part.tool || part.name || "unknown";
428
+ const status = part.state?.status || "unknown";
429
+ const lines = [`[Tool: ${name}] (${status})`];
430
+ if (part.state?.input) {
431
+ lines.push(`Input: ${truncate(stringifyUnknown(part.state.input), 2000)}`);
432
+ }
433
+ const output = part.state?.output ?? part.state?.content;
434
+ if (output) {
435
+ lines.push(`Output: ${truncate(stringifyUnknown(output), 3000)}`);
436
+ }
437
+ if (part.state?.error)
438
+ lines.push(`Error: ${part.state.error}`);
439
+ return lines.join("\n");
440
+ }
441
+ case "compaction":
442
+ return `[Context compaction occurred${part.auto ? " (auto)" : " (manual)"}]`;
443
+ default:
444
+ return null;
445
+ }
446
+ }
447
+ function formatV2Content(block) {
448
+ switch (block.type) {
449
+ case "text":
450
+ return block.text || null;
451
+ case "reasoning":
452
+ return null;
453
+ case "tool":
454
+ return formatPartContent({
455
+ type: "tool",
456
+ tool: block.name || block.tool,
457
+ state: block.state,
458
+ });
459
+ default:
460
+ return typeof block.text === "string" && block.text ? block.text : null;
461
+ }
462
+ }
463
+ function formatV2Message(type, data) {
464
+ if (type === "user") {
465
+ const text = typeof data.text === "string" ? data.text : "";
466
+ return text ? `## User\n${text}` : null;
467
+ }
468
+ if (type === "assistant") {
469
+ const agent = data.agent ? ` (agent: ${data.agent})` : "";
470
+ const modelId = data.model?.id || data.model?.modelID;
471
+ const model = modelId ? ` [${modelId}]` : "";
472
+ const parts = [];
473
+ for (const block of data.content || []) {
474
+ if (!block || typeof block !== "object")
475
+ continue;
476
+ const content = formatV2Content(block);
477
+ if (content)
478
+ parts.push(content);
479
+ }
480
+ if (parts.length === 0)
481
+ return null;
482
+ return `## Assistant${agent}${model}\n${parts.join("\n\n")}`;
483
+ }
484
+ if (type === "compaction") {
485
+ const auto = data.reason === "auto" || data.auto;
486
+ const summary = typeof data.summary === "string" && data.summary
487
+ ? `\n${truncate(data.summary, 2000)}`
488
+ : "";
489
+ return `[Context compaction occurred${auto ? " (auto)" : " (manual)"}]${summary}`;
490
+ }
491
+ if (type === "synthetic" && typeof data.text === "string" && data.text) {
492
+ return `## Synthetic\n${truncate(data.text, 3000)}`;
493
+ }
494
+ if (type === "system" && typeof data.text === "string" && data.text) {
495
+ return `## System\n${truncate(data.text, 2000)}`;
496
+ }
497
+ return null;
498
+ }
499
+ function buildTranscriptV1(db, sessionId, visited = new Set()) {
500
+ if (visited.has(sessionId))
501
+ return "";
502
+ visited.add(sessionId);
503
+ const session = getSession(db, sessionId);
504
+ if (!session)
505
+ return "";
506
+ const sections = [];
507
+ if (session.parent_id && !visited.has(session.parent_id)) {
508
+ const parentTranscript = buildTranscriptV1(db, session.parent_id, visited);
509
+ if (parentTranscript) {
510
+ sections.push(`═══ Parent Session: ${getSession(db, session.parent_id)?.title || session.parent_id} ═══\n`, parentTranscript, `\n═══ Current Subagent Session: ${session.title} ═══\n`);
511
+ }
512
+ }
513
+ const messages = getMessages(db, sessionId);
514
+ for (const msg of messages) {
515
+ let msgData;
516
+ try {
517
+ msgData = JSON.parse(msg.data);
518
+ }
519
+ catch {
520
+ continue;
521
+ }
522
+ const role = msgData.role === "assistant" ? "Assistant" : "User";
523
+ const agent = msgData.agent ? ` (agent: ${msgData.agent})` : "";
524
+ const model = msgData.model?.modelID ? ` [${msgData.model.modelID}]` : "";
525
+ const parts = getParts(db, msg.id);
526
+ const partTexts = [];
527
+ for (const part of parts) {
528
+ let partData;
529
+ try {
530
+ partData = JSON.parse(part.data);
531
+ }
532
+ catch {
533
+ continue;
534
+ }
535
+ const content = formatPartContent(partData);
536
+ if (content)
537
+ partTexts.push(content);
538
+ }
539
+ if (partTexts.length > 0) {
540
+ sections.push(`## ${role}${agent}${model}\n${partTexts.join("\n\n")}`);
541
+ }
542
+ }
543
+ return sections.join("\n\n");
544
+ }
545
+ function buildTranscriptV2(db, sessionId, visited = new Set()) {
546
+ if (visited.has(sessionId))
547
+ return "";
548
+ visited.add(sessionId);
549
+ const session = getSessionV2(db, sessionId);
550
+ if (!session)
551
+ return "";
552
+ const sections = [];
553
+ if (session.parent_id && !visited.has(session.parent_id)) {
554
+ const parentTranscript = buildTranscriptV2(db, session.parent_id, visited);
555
+ if (parentTranscript) {
556
+ sections.push(`═══ Parent Session: ${getSessionV2(db, session.parent_id)?.title || session.parent_id} ═══\n`, parentTranscript, `\n═══ Current Subagent Session: ${session.title} ═══\n`);
557
+ }
558
+ }
559
+ for (const msg of getSessionMessages(db, sessionId)) {
560
+ let data;
561
+ try {
562
+ data = JSON.parse(msg.data);
563
+ }
564
+ catch {
565
+ continue;
566
+ }
567
+ const formatted = formatV2Message(msg.type, data);
568
+ if (formatted)
569
+ sections.push(formatted);
570
+ }
571
+ return sections.join("\n\n");
572
+ }
573
+ function buildTranscript(db, sessionId) {
574
+ if (tableExists(db, "session_message")) {
575
+ const row = db
576
+ .query("SELECT 1 AS ok FROM session_message WHERE session_id = ? LIMIT 1")
577
+ .get(sessionId);
578
+ if (row)
579
+ return buildTranscriptV2(db, sessionId);
580
+ }
581
+ return buildTranscriptV1(db, sessionId);
582
+ }
583
+ function buildAdvisorPrompt(systemPrompt, transcript, question, priorNote) {
584
+ const body = priorNote ? `${transcript}\n\n---\n${priorNote}` : transcript;
585
+ const task = question
586
+ ? `Specific question: ${question}`
587
+ : "Please analyze the above session and provide your advisory guidance.";
588
+ return `${systemPrompt}\n\nHere is the full session transcript:\n\n${body}\n\n---\n\n${task}`;
589
+ }
590
+ function unwrapData(value) {
591
+ if (value === null || value === undefined)
592
+ return null;
593
+ if (typeof value === "object" &&
594
+ "data" in value) {
595
+ return value.data ?? null;
596
+ }
597
+ return value;
598
+ }
599
+ function extractGeneratedText(value) {
600
+ if (typeof value === "string")
601
+ return value;
602
+ if (!value || typeof value !== "object")
603
+ return null;
604
+ const record = value;
605
+ if (typeof record.text === "string")
606
+ return record.text;
607
+ return extractGeneratedText(record.data);
608
+ }
609
+ async function withTimeout(promise, ms, label) {
610
+ let timer;
611
+ try {
612
+ return await Promise.race([
613
+ promise,
614
+ new Promise((_, reject) => {
615
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms} ms`)), ms);
616
+ }),
617
+ ]);
618
+ }
619
+ finally {
620
+ if (timer)
621
+ clearTimeout(timer);
622
+ }
623
+ }
624
+ function isProviderUsable(provider) {
625
+ if (!provider)
626
+ return false;
627
+ return provider.activation !== "disabled";
628
+ }
629
+ function findAdvisorModel(models, config = DEFAULT_ADVISOR_CONFIG) {
630
+ if (!Array.isArray(models))
631
+ return null;
632
+ for (const model of models) {
633
+ if (!model || typeof model !== "object")
634
+ continue;
635
+ if (model.providerID !== config.provider)
636
+ continue;
637
+ if (model.id !== config.model && model.modelID !== config.model)
638
+ continue;
639
+ if (model.enabled === false)
640
+ continue;
641
+ return model;
642
+ }
643
+ return null;
644
+ }
645
+ function resolveAdvisorVariant(model, config = DEFAULT_ADVISOR_CONFIG) {
646
+ if (config.variant === undefined)
647
+ return undefined;
648
+ if (!model || !Array.isArray(model.variants))
649
+ return config.variant;
650
+ const ids = model.variants.map((variant) => variant?.id);
651
+ return ids.includes(config.variant) ? config.variant : undefined;
652
+ }
653
+ function hasAdvisorConnection(connection) {
654
+ if (connection === null || connection === undefined)
655
+ return false;
656
+ if (typeof connection === "object" && !Array.isArray(connection)) {
657
+ const record = connection;
658
+ if (record.active === false || record.available === false)
659
+ return false;
660
+ if (record.status === "disconnected" || record.status === "expired") {
661
+ return false;
662
+ }
663
+ }
664
+ return true;
665
+ }
666
+ async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG) {
667
+ if (typeof runtime.catalog?.provider?.get === "function") {
668
+ let provider = null;
669
+ try {
670
+ provider = unwrapData((await runtime.catalog.provider.get({
671
+ providerID: config.provider,
672
+ })));
673
+ }
674
+ catch (err) {
675
+ return {
676
+ supported: false,
677
+ reason: `Provider ${config.provider} unavailable in OpenCode: ${err instanceof Error ? err.message : String(err)}`,
678
+ };
679
+ }
680
+ if (!isProviderUsable(provider)) {
681
+ return {
682
+ supported: false,
683
+ reason: `Advisor provider is disabled or unavailable in OpenCode (provider: ${config.provider}).`,
684
+ };
685
+ }
686
+ }
687
+ let variant = config.variant;
688
+ if (typeof runtime.catalog?.model?.list === "function") {
689
+ let models = null;
690
+ try {
691
+ models = unwrapData((await runtime.catalog.model.list()));
692
+ }
693
+ catch (err) {
694
+ return {
695
+ supported: false,
696
+ reason: `Could not list OpenCode models: ${err instanceof Error ? err.message : String(err)}`,
697
+ };
698
+ }
699
+ const model = findAdvisorModel(models, config);
700
+ if (!model) {
701
+ return {
702
+ supported: false,
703
+ reason: `Model unavailable: ${config.provider}/${config.model}`,
704
+ };
705
+ }
706
+ variant = resolveAdvisorVariant(model, config);
707
+ }
708
+ if (typeof runtime.integration?.connection?.active === "function") {
709
+ let connection = null;
710
+ try {
711
+ connection = await runtime.integration.connection.active(config.provider);
712
+ }
713
+ catch (err) {
714
+ return {
715
+ supported: false,
716
+ reason: `Could not check the ${config.provider} connection in OpenCode: ${err instanceof Error ? err.message : String(err)}`,
717
+ };
718
+ }
719
+ if (!hasAdvisorConnection(connection)) {
720
+ return {
721
+ supported: false,
722
+ reason: `No ${config.provider} connection configured in OpenCode (sign in or connect an API key first).`,
723
+ };
724
+ }
725
+ }
726
+ return { supported: true, variant };
727
+ }
728
+ let cachedAdvisorSessionId = null;
729
+ let advisorQueue = Promise.resolve();
730
+ function resetAdvisorSessionCache() {
731
+ cachedAdvisorSessionId = null;
732
+ }
733
+ function enqueueAdvisor(task) {
734
+ const next = advisorQueue.then(task, task);
735
+ advisorQueue = next.catch(() => { });
736
+ return next;
737
+ }
738
+ function readSessionId(value) {
739
+ const session = unwrapData(value);
740
+ return typeof session?.id === "string" ? session.id : null;
741
+ }
742
+ function readSessionList(value) {
743
+ const list = unwrapData(value);
744
+ return Array.isArray(list)
745
+ ? list.filter((entry) => !!entry && typeof entry === "object")
746
+ : [];
747
+ }
748
+ async function readStoredAdvisorSessionId(runtime) {
749
+ if (typeof runtime.storage?.get !== "function")
750
+ return null;
751
+ try {
752
+ const stored = await runtime.storage.get(ADVISOR_STORAGE_KEY);
753
+ return typeof stored === "string" && stored.startsWith("ses")
754
+ ? stored
755
+ : null;
756
+ }
757
+ catch {
758
+ return null;
759
+ }
760
+ }
761
+ async function storeAdvisorSessionId(runtime, sessionId) {
762
+ if (typeof runtime.storage?.set !== "function")
763
+ return;
764
+ try {
765
+ await runtime.storage.set(ADVISOR_STORAGE_KEY, sessionId);
766
+ }
767
+ catch { }
768
+ }
769
+ async function getAdvisorSession(runtime, sessionId) {
770
+ if (typeof runtime.session?.get !== "function")
771
+ return null;
772
+ try {
773
+ const session = unwrapData((await runtime.session.get({ sessionID: sessionId })));
774
+ return session && typeof session === "object" ? session : null;
775
+ }
776
+ catch {
777
+ return null;
778
+ }
779
+ }
780
+ function advisorSessionNeedsModel(session, config = DEFAULT_ADVISOR_CONFIG) {
781
+ if (!session)
782
+ return true;
783
+ const model = session.model;
784
+ if (!model || typeof model !== "object")
785
+ return true;
786
+ const provider = String(model.providerID || "").toLowerCase();
787
+ const id = String(model.id || model.modelID || "").toLowerCase();
788
+ return (provider !== config.provider.toLowerCase() ||
789
+ id !== config.model.toLowerCase());
790
+ }
791
+ async function switchAdvisorSessionModel(runtime, sessionId, variant, config = DEFAULT_ADVISOR_CONFIG) {
792
+ if (typeof runtime.session?.switchModel !== "function") {
793
+ throw new Error("OpenCode runtime cannot switch the advisor session model (session.switchModel unavailable).");
794
+ }
795
+ const model = {
796
+ providerID: config.provider,
797
+ id: config.model,
798
+ };
799
+ if (variant)
800
+ model.variant = variant;
801
+ await runtime.session.switchModel({ sessionID: sessionId, model });
802
+ }
803
+ async function ensureAdvisorSession(runtime, variant, config = DEFAULT_ADVISOR_CONFIG) {
804
+ const candidates = [
805
+ cachedAdvisorSessionId,
806
+ await readStoredAdvisorSessionId(runtime),
807
+ ];
808
+ for (const candidate of candidates) {
809
+ if (!candidate)
810
+ continue;
811
+ const session = await getAdvisorSession(runtime, candidate);
812
+ if (!session)
813
+ continue;
814
+ if (advisorSessionNeedsModel(session, config)) {
815
+ await switchAdvisorSessionModel(runtime, candidate, variant, config);
816
+ }
817
+ cachedAdvisorSessionId = candidate;
818
+ return candidate;
819
+ }
820
+ if (typeof runtime.session?.list === "function") {
821
+ try {
822
+ const sessions = readSessionList(await runtime.session.list());
823
+ const existing = sessions.find((session) => session.title === ADVISOR_SESSION_TITLE ||
824
+ session.title === LEGACY_ADVISOR_SESSION_TITLE);
825
+ const existingId = typeof existing?.id === "string" ? existing.id : null;
826
+ if (existing && existingId) {
827
+ if (advisorSessionNeedsModel(existing, config)) {
828
+ await switchAdvisorSessionModel(runtime, existingId, variant, config);
829
+ }
830
+ cachedAdvisorSessionId = existingId;
831
+ await storeAdvisorSessionId(runtime, existingId);
832
+ return existingId;
833
+ }
834
+ }
835
+ catch { }
836
+ }
837
+ if (typeof runtime.session?.create !== "function") {
838
+ throw new Error("OpenCode runtime cannot create the advisor session (session.create unavailable).");
839
+ }
840
+ const created = await runtime.session.create({
841
+ title: ADVISOR_SESSION_TITLE,
842
+ });
843
+ const sessionId = readSessionId(created);
844
+ if (!sessionId) {
845
+ throw new Error("OpenCode did not return an advisor session id.");
846
+ }
847
+ await switchAdvisorSessionModel(runtime, sessionId, variant, config);
848
+ cachedAdvisorSessionId = sessionId;
849
+ await storeAdvisorSessionId(runtime, sessionId);
850
+ return sessionId;
851
+ }
852
+ async function callAdvisor(opts) {
853
+ const runtime = opts.runtime;
854
+ const config = opts.config ?? DEFAULT_ADVISOR_CONFIG;
855
+ if (typeof runtime?.session?.generate !== "function") {
856
+ throw new Error("advisor requires the OpenCode V2 plugin runtime (session.generate unavailable).");
857
+ }
858
+ const support = await checkAdvisorSupport(runtime, config);
859
+ if (!support.supported) {
860
+ throw new Error(support.reason);
861
+ }
862
+ const prompt = buildAdvisorPrompt(opts.systemPrompt, opts.transcript, opts.question, opts.priorNote);
863
+ const generate = runtime.session.generate.bind(runtime.session);
864
+ // The timeout wraps only the generation, not the time spent waiting in the
865
+ // queue behind other advisor calls — otherwise a backlog guarantees timeouts.
866
+ const text = await enqueueAdvisor(() => withTimeout((async () => {
867
+ const sessionId = await ensureAdvisorSession(runtime, support.variant, config);
868
+ const request = { sessionID: sessionId, prompt };
869
+ const result = opts.signal
870
+ ? await generate(request, { signal: opts.signal })
871
+ : await generate(request);
872
+ const output = extractGeneratedText(result);
873
+ if (!output?.trim()) {
874
+ throw new Error("Advisor returned an empty response.");
875
+ }
876
+ return output;
877
+ })(), config.timeoutMs, "Advisor generation"));
878
+ const modelLabel = support.variant
879
+ ? `${config.provider}/${config.model} (effort=${support.variant})`
880
+ : `${config.provider}/${config.model}`;
881
+ return {
882
+ text: `${text}\n\n---\n_advisor via OpenCode: ${modelLabel} (token usage unavailable via session generation)_`,
883
+ inputTokens: null,
884
+ outputTokens: null,
885
+ };
886
+ }
887
+ async function runAdvisor(opts) {
888
+ const started = Date.now();
889
+ const config = opts.config ?? DEFAULT_ADVISOR_CONFIG;
890
+ const mode = opts.mode || "general";
891
+ const trigger = inferTrigger(opts.mode, opts.trigger);
892
+ const sessionId = opts.sessionId;
893
+ const questionChars = opts.question?.length ?? 0;
894
+ if (!sessionId) {
895
+ await logAdvisorMetrics({
896
+ ts: new Date().toISOString(),
897
+ sessionId: null,
898
+ callerModel: null,
899
+ callerAgent: opts.callerAgent || null,
900
+ directory: opts.callerDirectory || null,
901
+ mode,
902
+ trigger,
903
+ questionChars,
904
+ outcome: "no_session",
905
+ errorType: "no_session",
906
+ latencyMs: Date.now() - started,
907
+ inputTokens: null,
908
+ outputTokens: null,
909
+ transcriptChars: 0,
910
+ priorConsultations: 0,
911
+ via: "opencode-session",
912
+ });
913
+ throw new Error("advisor requires a valid OpenCode session context (no session ID available).");
914
+ }
915
+ let db = null;
916
+ try {
917
+ db = openDb();
918
+ const info = getSessionInfo(db, sessionId);
919
+ const callerModel = callerLabel(info?.model ?? null);
920
+ const callerAgent = opts.callerAgent || info?.agent || null;
921
+ const directory = opts.callerDirectory || info?.directory || null;
922
+ if (isFableModel(info?.model)) {
923
+ await logAdvisorMetrics({
924
+ ts: new Date().toISOString(),
925
+ sessionId,
926
+ callerModel,
927
+ callerAgent,
928
+ directory,
929
+ mode,
930
+ trigger,
931
+ questionChars,
932
+ outcome: "skipped_fable",
933
+ errorType: null,
934
+ latencyMs: Date.now() - started,
935
+ inputTokens: null,
936
+ outputTokens: null,
937
+ transcriptChars: 0,
938
+ priorConsultations: 0,
939
+ via: "opencode-session",
940
+ });
941
+ console.log(`[advisor] session=${sessionId} mode=${mode} outcome=skipped_fable (already Fable)`);
942
+ return FABLE_DISABLED;
943
+ }
944
+ let transcript = buildTranscript(db, sessionId);
945
+ if (!transcript?.trim()) {
946
+ await logAdvisorMetrics({
947
+ ts: new Date().toISOString(),
948
+ sessionId,
949
+ callerModel,
950
+ callerAgent,
951
+ directory,
952
+ mode,
953
+ trigger,
954
+ questionChars,
955
+ outcome: "no_transcript",
956
+ errorType: "no_transcript",
957
+ latencyMs: Date.now() - started,
958
+ inputTokens: null,
959
+ outputTokens: null,
960
+ transcriptChars: 0,
961
+ priorConsultations: 0,
962
+ via: "opencode-session",
963
+ });
964
+ throw new Error(`No transcript found for session ${sessionId}.`);
965
+ }
966
+ // Cap oversized transcripts, keeping the most recent tail. Large
967
+ // transcripts drive advisor latency and can blow the generation timeout.
968
+ if (config.maxTranscriptChars > 0 &&
969
+ transcript.length > config.maxTranscriptChars) {
970
+ transcript =
971
+ "... (older transcript trimmed to fit maxTranscriptChars) ...\n\n" +
972
+ transcript.slice(-config.maxTranscriptChars);
973
+ }
974
+ const prior = countPriorAdvisorCalls(db, sessionId);
975
+ const priorNote = prior.count > 0
976
+ ? `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.`
977
+ : null;
978
+ const systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.general;
979
+ try {
980
+ const result = await callAdvisor({
981
+ runtime: opts.runtime,
982
+ systemPrompt,
983
+ transcript,
984
+ question: opts.question,
985
+ priorNote,
986
+ signal: opts.signal,
987
+ config,
988
+ });
989
+ const latencyMs = Date.now() - started;
990
+ await logAdvisorMetrics({
991
+ ts: new Date().toISOString(),
992
+ sessionId,
993
+ callerModel,
994
+ callerAgent,
995
+ directory,
996
+ mode,
997
+ trigger,
998
+ questionChars,
999
+ outcome: "advisor_response",
1000
+ errorType: null,
1001
+ latencyMs,
1002
+ inputTokens: result.inputTokens,
1003
+ outputTokens: result.outputTokens,
1004
+ transcriptChars: transcript.length,
1005
+ priorConsultations: prior.count,
1006
+ via: "opencode-session",
1007
+ });
1008
+ console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=advisor_response latencyMs=${latencyMs}`);
1009
+ return (result.text +
1010
+ `\n\n_advisor consultation #${prior.count + 1} in this session chain (trigger=${trigger})_`);
1011
+ }
1012
+ catch (err) {
1013
+ const message = err instanceof Error ? err.message : String(err);
1014
+ const errorType = classifyAdvisorError(message);
1015
+ const latencyMs = Date.now() - started;
1016
+ await logAdvisorMetrics({
1017
+ ts: new Date().toISOString(),
1018
+ sessionId,
1019
+ callerModel,
1020
+ callerAgent,
1021
+ directory,
1022
+ mode,
1023
+ trigger,
1024
+ questionChars,
1025
+ outcome: "error",
1026
+ errorType,
1027
+ latencyMs,
1028
+ inputTokens: null,
1029
+ outputTokens: null,
1030
+ transcriptChars: transcript.length,
1031
+ priorConsultations: prior.count,
1032
+ via: "opencode-session",
1033
+ });
1034
+ console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=error errorType=${errorType} latencyMs=${latencyMs}`);
1035
+ throw new Error(`advisor failed (${errorType}): ${message}`);
1036
+ }
1037
+ }
1038
+ finally {
1039
+ db?.close();
1040
+ }
1041
+ }
1042
+ export async function setupOcAdvisorV2(ctx) {
1043
+ const registrations = [];
1044
+ const advisorConfig = resolveAdvisorConfig(ctx.options);
1045
+ if (typeof ctx.tool?.transform === "function") {
1046
+ const reg = await ctx.tool.transform((draft) => {
1047
+ draft.add({
1048
+ name: "advisor",
1049
+ description: TOOL_DESCRIPTION,
1050
+ input: ADVISOR_INPUT_SCHEMA,
1051
+ // Register as a direct tool, not a Code Mode tool. OpenCode 2 only
1052
+ // exposes tools with `codemode: false` to the model directly; every
1053
+ // other tool is reachable solely through `execute`, whose tool log
1054
+ // records just the nested call's input and hides the script output
1055
+ // on success — so the advisor's answer never appeared in the TUI.
1056
+ // A direct call renders as the input fields (mode, trigger,
1057
+ // question) followed by `output:` with the answer, and it also
1058
+ // avoids Code Mode's output-size truncation.
1059
+ options: { codemode: false },
1060
+ async execute(input, context) {
1061
+ const text = await runAdvisor({
1062
+ runtime: ctx,
1063
+ sessionId: context.sessionID ?? context.sessionId,
1064
+ mode: input?.mode,
1065
+ trigger: input?.trigger,
1066
+ question: input?.question,
1067
+ signal: context.abort,
1068
+ callerAgent: context.agent,
1069
+ callerDirectory: context.directory,
1070
+ config: advisorConfig,
1071
+ });
1072
+ return { content: text };
1073
+ },
1074
+ });
1075
+ });
1076
+ if (reg)
1077
+ registrations.push(reg);
1078
+ }
1079
+ if (typeof ctx.session?.hook === "function") {
1080
+ const reg = await ctx.session.hook("context", (event) => {
1081
+ if (!event.tools)
1082
+ return;
1083
+ // `event.tools` lists the direct tools available to this request.
1084
+ // OpenCode drops entries a hook adds for tools it did not register,
1085
+ // so the hook can only hide the tool (Fable sessions), never add it.
1086
+ // The checkpoint instruction is injected only when the tool is
1087
+ // actually available, e.g. not when a permission rule removed it.
1088
+ let available = false;
1089
+ for (const key of Object.keys(event.tools)) {
1090
+ if (!isAdvisorToolName(key)) {
1091
+ continue;
1092
+ }
1093
+ if (isFableModel(event.model)) {
1094
+ delete event.tools[key];
1095
+ }
1096
+ else {
1097
+ available = true;
1098
+ }
1099
+ }
1100
+ if (!available || !Array.isArray(event.system))
1101
+ return;
1102
+ event.system.push({ type: "text", text: CHECKPOINT_INSTRUCTION });
1103
+ });
1104
+ if (reg)
1105
+ registrations.push(reg);
1106
+ }
1107
+ return () => {
1108
+ for (const reg of registrations) {
1109
+ try {
1110
+ reg.dispose?.();
1111
+ }
1112
+ catch { }
1113
+ }
1114
+ };
1115
+ }
1116
+ const plugin = {
1117
+ // Plugin id intentionally unchanged by the ocAdvisor → advisor rename:
1118
+ // plugin storage (the pinned advisor session id) is scoped to it.
1119
+ id: "oc-advisor",
1120
+ setup: setupOcAdvisorV2,
1121
+ };
1122
+ export const OcAdvisorPluginV2 = plugin;
1123
+ export default plugin;
1124
+ // Named exports for unit tests (bun test). The plugin entrypoint is the
1125
+ // default export above.
1126
+ export { ADVISOR_TRIGGERS, CHECKPOINT_INSTRUCTION, DEFAULT_ADVISOR_CONFIG, TOOL_DESCRIPTION, buildAdvisorPrompt, checkAdvisorSupport, classifyAdvisorError, ensureAdvisorSession, extractGeneratedText, findAdvisorModel, hasAdvisorConnection, inferTrigger, isAdvisorToolName, isFableModel, isProviderUsable, parseModelRef, resolveAdvisorConfig, resolveAdvisorVariant, resetAdvisorSessionCache, unwrapData, withTimeout, };
1127
+ //# sourceMappingURL=ocAdvisor.js.map