@oqtopus-team/pi-qdash 0.1.2 → 0.1.4

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.
@@ -5,9 +5,20 @@ import { homedir } from "node:os";
5
5
 
6
6
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
7
7
  import { QDashClient, defaultConfigPath, type DownloadedFile, type TaskResultFigureOptions } from "@oqtopus-team/qdash-client";
8
- import { Image, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import { Type } from "typebox";
10
10
 
11
+ import { analyzePlotlyFigure, figureAnalysisText } from "./lib/figure-analysis.js";
12
+ import { fetchFigureDetails, figureComponent, figureResultText, mediaTypeForPath, type FigureDetails } from "./lib/figures.js";
13
+ import { forumDetailComponent, forumDetailLines, forumListLines, forumPostTitle, forumPostsFromPayload } from "./lib/forum-render.js";
14
+ import { qdashObjectLinks, qdashWebBaseUrl, qdashWebUrl, safeConfig, withQDashLinks } from "./lib/links.js";
15
+ import { arrayFromPayload, compactItems, firstNumber, firstString, formatItem, payloadTotal, statusIcon } from "./lib/payload.js";
16
+ import { toTextToolResult, toToolResult } from "./lib/results.js";
17
+ import { ansi, boxLinesToWidth, boxed, formatNumber, textComponent } from "./lib/render.js";
18
+ import { plotSeriesLines, timeseriesPlotComponent, timeseriesPoints } from "./lib/timeseries-plot.js";
19
+ import { installQDashWriteGate } from "./lib/write-gate.js";
20
+ import { analyzeWiringMarkdown, wiringInsightLines, type WiringInsights } from "./lib/wiring-analysis.js";
21
+
11
22
  type QDashQueryParams = {
12
23
  profile?: string;
13
24
  configPath?: string;
@@ -111,18 +122,6 @@ type ConfirmableParams = {
111
122
  confirmWrite?: boolean;
112
123
  };
113
124
 
114
- type FigureDetails = {
115
- tool: string;
116
- path: string;
117
- mediaType: string;
118
- sizeBytes: number;
119
- base64?: string;
120
- text?: string;
121
- taskId?: string;
122
- figurePaths?: string[];
123
- jsonFigurePaths?: string[];
124
- };
125
-
126
125
  type QDashContextState = {
127
126
  profile?: string;
128
127
  chipId?: string;
@@ -136,17 +135,6 @@ type QDashContextState = {
136
135
 
137
136
  const CONTEXT_ENTRY_TYPE = "qdash-context";
138
137
  const GLOBAL_CONTEXT_PATH = join(homedir(), ".pi", "agent", "qdash-context.json");
139
- const WRITE_TOOL_NAMES = new Set([
140
- "qdash_create_agent_session",
141
- "qdash_submit_agent_action",
142
- "qdash_commit_agent_candidate",
143
- "qdash_execute_agent_action",
144
- "qdash_commit_agent_campaign_candidates",
145
- "qdash_apply_agent_candidate_commit",
146
- "qdash_create_forum_post",
147
- "qdash_update_forum_post",
148
- "qdash_create_forum_evidence_reply",
149
- ]);
150
138
  let currentContext: QDashContextState = {};
151
139
 
152
140
  function applyQDashContext<T extends { profile?: string; chipId?: string; sessionId?: string }>(params: T): T {
@@ -405,6 +393,58 @@ type TaskResultFigureFile = DownloadedFile & {
405
393
  jsonFigurePaths: string[];
406
394
  };
407
395
 
396
+ type CalibrationEvidence = {
397
+ id?: string;
398
+ source: string;
399
+ target?: Record<string, unknown>;
400
+ task?: Record<string, unknown>;
401
+ metrics?: Record<string, number | string | boolean | null>;
402
+ parameters?: Record<string, number | string | boolean | null>;
403
+ resultJson?: unknown;
404
+ figures?: Array<Record<string, unknown>>;
405
+ logs?: string[];
406
+ notes?: string;
407
+ history?: Array<Record<string, unknown>>;
408
+ };
409
+
410
+ function parameterValueRecord(value: unknown): Record<string, number | string | boolean | null> {
411
+ const output: Record<string, number | string | boolean | null> = {};
412
+ const input = value && typeof value === "object" ? value as Record<string, unknown> : {};
413
+ for (const [key, raw] of Object.entries(input)) {
414
+ const object = raw && typeof raw === "object" ? raw as Record<string, unknown> : undefined;
415
+ const candidate = object && "value" in object ? object.value : raw;
416
+ if (typeof candidate === "number" || typeof candidate === "string" || typeof candidate === "boolean" || candidate === null) output[key] = candidate;
417
+ }
418
+ return output;
419
+ }
420
+
421
+ function firstStringField(object: Record<string, unknown>, keys: string[]): string | undefined {
422
+ for (const key of keys) {
423
+ const value = object[key];
424
+ if (typeof value === "string" && value) return value;
425
+ }
426
+ return undefined;
427
+ }
428
+
429
+ function qcalEvidenceText(evidence: CalibrationEvidence): string {
430
+ const metrics = Object.entries(evidence.metrics ?? {}).slice(0, 8).map(([key, value]) => ` - ${key}: ${String(value)}`);
431
+ const figures = evidence.figures ?? [];
432
+ return boxed("QDash QCal Evidence", [
433
+ `id ${evidence.id ?? "n/a"}`,
434
+ `source ${evidence.source}`,
435
+ `target ${evidence.target?.id ?? evidence.target?.couplingId ?? evidence.target?.qids ?? "n/a"}`,
436
+ `task ${evidence.task?.name ?? "n/a"} (${evidence.task?.status ?? "unknown"})`,
437
+ "",
438
+ "Metrics",
439
+ ...(metrics.length ? metrics : [" - none"]),
440
+ "",
441
+ `Figures ${figures.length}`,
442
+ ...figures.map((figure, index) => ` - #${index + 1} ${String(figure.role ?? "plot")} ${String(figure.path ?? figure.url ?? "")}`),
443
+ "",
444
+ "Use qcal_evaluate_bundle with details.evidence to evaluate this evidence with pi-qcal.",
445
+ ]).join("\n");
446
+ }
447
+
408
448
  function cleanQuery(values: Record<string, unknown> = {}): Record<string, unknown> {
409
449
  return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null));
410
450
  }
@@ -448,23 +488,6 @@ type WiringInsightParams = CooldownWiringParams & {
448
488
  color?: boolean;
449
489
  };
450
490
 
451
- type AttenuationEntry = {
452
- mux: string;
453
- line: "control" | "readout_send";
454
- totalDb: number;
455
- components: number[];
456
- panel?: string;
457
- hardwarePort?: string;
458
- qid?: string;
459
- };
460
-
461
- type WiringInsights = {
462
- entries: AttenuationEntry[];
463
- control: { commonTotalDb?: number; anomalies: AttenuationEntry[]; totals: Record<string, number> };
464
- readoutSend: { commonTotalDb?: number; anomalies: AttenuationEntry[]; totals: Record<string, number> };
465
- suggestions: string[];
466
- };
467
-
468
491
  function newestCooldown(cooldowns: CooldownRecord[]): CooldownRecord | undefined {
469
492
  return [...cooldowns].sort((a, b) => {
470
493
  const activeDifference = Number(a.ended_at != null) - Number(b.ended_at != null);
@@ -473,145 +496,6 @@ function newestCooldown(cooldowns: CooldownRecord[]): CooldownRecord | undefined
473
496
  })[0];
474
497
  }
475
498
 
476
- function markdownTableRows(markdown: string): string[][] {
477
- return markdown.split("\n")
478
- .map((line) => line.trim())
479
- .filter((line) => line.startsWith("|") && line.endsWith("|"))
480
- .map((line) => line.slice(1, -1).split("|").map((cell) => cell.trim()))
481
- .filter((cells) => cells.length >= 7 && !cells.every((cell) => /^-+$/.test(cell.replace(/\s/g, ""))));
482
- }
483
-
484
- function expandPanelSpec(value: string): string[] {
485
- return value.split(/[、,]/).flatMap((part) => {
486
- const token = part.trim();
487
- if (!token) return [];
488
- const range = token.match(/^([A-Za-z]+)(\d+)\s*-\s*(\d+)$/);
489
- if (range) {
490
- const [, prefix, startText, endText] = range;
491
- const start = Number(startText);
492
- const end = Number(endText);
493
- const step = start <= end ? 1 : -1;
494
- const panels: string[] = [];
495
- for (let value = start; step > 0 ? value <= end : value >= end; value += step) panels.push(`${prefix}${value}`);
496
- return panels;
497
- }
498
- return [token];
499
- });
500
- }
501
-
502
- function splitPortsAndPanels(cell: string): { ports: string[]; panels: string[] } {
503
- const [portsText = cell, panelsText = ""] = cell.split(/\s+\/\s+/, 2);
504
- return {
505
- ports: portsText.split(/[、,]/).map((part) => part.trim()).filter(Boolean),
506
- panels: expandPanelSpec(panelsText),
507
- };
508
- }
509
-
510
- function attenuationNumbers(text: string): number[] {
511
- return [...text.matchAll(/(?:追加\s*)?(\d+(?:\.\d+)?)\s*dB/gi)].map((match) => Number(match[1])).filter(Number.isFinite);
512
- }
513
-
514
- function panelAttenuationTotals(cell: string): Array<{ panels?: string[]; components: number[]; totalDb: number }> {
515
- const parenthesized = [...cell.matchAll(/([^()]+)(([^)]+))/g)].map((match) => {
516
- const components = attenuationNumbers(match[1]);
517
- return { panels: expandPanelSpec(match[2]), components, totalDb: components.reduce((sum, value) => sum + value, 0) };
518
- }).filter((segment) => segment.components.length > 0);
519
- if (parenthesized.length > 0) return parenthesized;
520
- const components = attenuationNumbers(cell);
521
- return components.length > 0 ? [{ components, totalDb: components.reduce((sum, value) => sum + value, 0) }] : [];
522
- }
523
-
524
- function mostCommonTotal(entries: AttenuationEntry[]): number | undefined {
525
- const counts = new Map<number, number>();
526
- for (const entry of entries) counts.set(entry.totalDb, (counts.get(entry.totalDb) ?? 0) + 1);
527
- return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0];
528
- }
529
-
530
- function totalHistogram(entries: AttenuationEntry[]): Record<string, number> {
531
- const histogram: Record<string, number> = {};
532
- for (const entry of entries) histogram[String(entry.totalDb)] = (histogram[String(entry.totalDb)] ?? 0) + 1;
533
- return histogram;
534
- }
535
-
536
- function analyzeWiringMarkdown(markdown: string | undefined): WiringInsights | undefined {
537
- if (!markdown) return undefined;
538
- const rows = markdownTableRows(markdown);
539
- const dataRows = rows.filter((cells) => /^\d+$/.test(cells[0]));
540
- const entries: AttenuationEntry[] = [];
541
- for (const cells of dataRows) {
542
- const mux = cells[0].padStart(2, "0");
543
- const muxNumber = Number(cells[0]);
544
- const control = splitPortsAndPanels(cells[1] ?? "");
545
- const controlSegments = panelAttenuationTotals(cells[5] ?? "");
546
- for (let index = 0; index < control.panels.length; index++) {
547
- const panel = control.panels[index];
548
- const segment = controlSegments.find((item) => item.panels?.includes(panel)) ?? (controlSegments.length === 1 ? controlSegments[0] : undefined);
549
- if (!segment) continue;
550
- entries.push({
551
- mux,
552
- line: "control",
553
- panel,
554
- hardwarePort: control.ports[index],
555
- qid: Number.isFinite(muxNumber) ? String(muxNumber * 4 + index) : undefined,
556
- totalDb: segment.totalDb,
557
- components: segment.components,
558
- });
559
- }
560
- const readoutSegments = panelAttenuationTotals(cells[6] ?? "");
561
- const readout = splitPortsAndPanels(cells[2] ?? "");
562
- if (readoutSegments[0]) {
563
- entries.push({
564
- mux,
565
- line: "readout_send",
566
- panel: readout.panels[0],
567
- hardwarePort: readout.ports[0],
568
- totalDb: readoutSegments[0].totalDb,
569
- components: readoutSegments[0].components,
570
- });
571
- }
572
- }
573
- const controlEntries = entries.filter((entry) => entry.line === "control");
574
- const readoutEntries = entries.filter((entry) => entry.line === "readout_send");
575
- const controlCommon = mostCommonTotal(controlEntries);
576
- const readoutCommon = mostCommonTotal(readoutEntries);
577
- const controlAnomalies = controlCommon === undefined ? [] : controlEntries.filter((entry) => entry.totalDb !== controlCommon);
578
- const readoutAnomalies = readoutCommon === undefined ? [] : readoutEntries.filter((entry) => entry.totalDb !== readoutCommon);
579
- const suggestions = [
580
- controlCommon !== undefined ? `Control の最頻総減衰量は ${controlCommon} dB です。` : undefined,
581
- controlAnomalies.length > 0
582
- ? `Control で最頻値と異なるポートが ${controlAnomalies.length} 件あります: ${controlAnomalies.map((entry) => entry.qid ? `q${entry.qid}` : `${entry.mux}/${entry.panel}`).join(", ")}`
583
- : controlEntries.length > 0 ? "Control の総減衰量は全ポートで揃っています。" : undefined,
584
- readoutCommon !== undefined ? `Readout send の最頻総減衰量は ${readoutCommon} dB です。` : undefined,
585
- readoutAnomalies.length > 0
586
- ? `Readout send で最頻値と異なる MUX が ${readoutAnomalies.length} 件あります: ${readoutAnomalies.map((entry) => `MUX${entry.mux}`).join(", ")}`
587
- : readoutEntries.length > 0 ? "Readout send の総減衰量は全 MUX で揃っています。" : undefined,
588
- ].filter((line): line is string => Boolean(line));
589
- return {
590
- entries,
591
- control: { commonTotalDb: controlCommon, anomalies: controlAnomalies, totals: totalHistogram(controlEntries) },
592
- readoutSend: { commonTotalDb: readoutCommon, anomalies: readoutAnomalies, totals: totalHistogram(readoutEntries) },
593
- suggestions,
594
- };
595
- }
596
-
597
- function wiringInsightLines(insights: WiringInsights, color = false): string[] {
598
- const accent = (text: string) => color ? ansi("1;36", text) : text;
599
- const muted = (text: string) => color ? ansi("90", text) : text;
600
- const warning = (text: string) => color ? ansi("33", text) : text;
601
- const formatEntry = (entry: AttenuationEntry) => `${entry.qid ? `q${entry.qid}` : `MUX${entry.mux}`} ${muted(`MUX${entry.mux}`)} ${entry.panel ?? ""}${entry.hardwarePort ? `/${entry.hardwarePort}` : ""}: ${warning(`${entry.totalDb} dB`)} (${entry.components.join("+")})`;
602
- return boxed("QDash Wiring Insights", [
603
- ...insights.suggestions.map((line) => `• ${line}`),
604
- "",
605
- accent("Control totals"),
606
- ` ${Object.entries(insights.control.totals).map(([total, count]) => `${total} dB × ${count}`).join(", ") || muted("none")}`,
607
- ...(insights.control.anomalies.length > 0 ? ["", accent("Control anomalies"), ...insights.control.anomalies.map((entry) => ` ${formatEntry(entry)}`)] : []),
608
- "",
609
- accent("Readout send totals"),
610
- ` ${Object.entries(insights.readoutSend.totals).map(([total, count]) => `${total} dB × ${count}`).join(", ") || muted("none")}`,
611
- ...(insights.readoutSend.anomalies.length > 0 ? ["", accent("Readout send anomalies"), ...insights.readoutSend.anomalies.map((entry) => ` ${formatEntry(entry)}`)] : []),
612
- ], color);
613
- }
614
-
615
499
  async function resolveCooldownWiring(params: CooldownWiringParams) {
616
500
  params = applyQDashContext(params);
617
501
  const client = await makeClient(params);
@@ -682,84 +566,6 @@ async function resolveCooldownWiring(params: CooldownWiringParams) {
682
566
  };
683
567
  }
684
568
 
685
- function qdashWebBaseUrl(client: QDashClient): string {
686
- return client.config.baseUrl.replace(/\/api\/?$/, "").replace(/\/$/, "");
687
- }
688
-
689
- function qdashWebUrl(client: QDashClient, path: string): string {
690
- return `${qdashWebBaseUrl(client)}${path.startsWith("/") ? path : `/${path}`}`;
691
- }
692
-
693
- function qdashObjectLinks(client: QDashClient, object: Record<string, unknown>): Record<string, string> {
694
- const links: Record<string, string> = {};
695
- const taskId = firstString(object, ["task_id", "taskId"]);
696
- const executionId = firstString(object, ["execution_id", "executionId"]);
697
- const postId = firstString(object, ["post_id", "forum_post_id", "id"]);
698
- const issueId = firstString(object, ["issue_id"]);
699
- const sessionId = firstString(object, ["session_id", "sessionId"]);
700
- if (taskId) links.task_result = qdashWebUrl(client, `/task-results/${encodeURIComponent(taskId)}`);
701
- if (executionId) links.execution = qdashWebUrl(client, `/executions/${encodeURIComponent(executionId)}`);
702
- if (postId) links.forum_post = qdashWebUrl(client, `/forum/posts/${encodeURIComponent(postId)}`);
703
- if (issueId) links.issue = qdashWebUrl(client, `/issues/${encodeURIComponent(issueId)}`);
704
- if (sessionId) links.agent_session = qdashWebUrl(client, `/agent-sessions/${encodeURIComponent(sessionId)}`);
705
- return links;
706
- }
707
-
708
- function withQDashLinks(client: QDashClient, data: unknown): unknown {
709
- if (!data || typeof data !== "object" || Array.isArray(data)) return data;
710
- const object = data as Record<string, unknown>;
711
- const links = qdashObjectLinks(client, object);
712
- if (Object.keys(links).length === 0) return data;
713
- return { ...object, _links: links };
714
- }
715
-
716
- function safeConfig(client: QDashClient, source: string) {
717
- const config = client.config;
718
- return {
719
- source,
720
- baseUrl: config.baseUrl,
721
- projectId: config.projectId ?? null,
722
- timeoutSeconds: config.timeoutSeconds,
723
- verifyTls: config.verifyTls,
724
- proxyConfigured: Boolean(config.proxy),
725
- apiTokenConfigured: Boolean(config.apiToken),
726
- cloudflareAccessConfigured: Boolean(config.cfAccessClientId || config.cfAccessClientSecret),
727
- userAgent: config.userAgent,
728
- retry: config.retry,
729
- };
730
- }
731
-
732
- function redact(value: unknown): unknown {
733
- if (Array.isArray(value)) return value.map(redact);
734
- if (value && typeof value === "object") {
735
- return Object.fromEntries(
736
- Object.entries(value as Record<string, unknown>).map(([key, item]) => {
737
- if (/token|password|secret|authorization|api[_-]?key/i.test(key)) return [key, "[redacted]"];
738
- return [key, redact(item)];
739
- }),
740
- );
741
- }
742
- return value;
743
- }
744
-
745
- function toToolResult(data: unknown, details: Record<string, unknown> = {}) {
746
- const safeData = redact(data);
747
- let text = JSON.stringify(safeData, null, 2);
748
- if (text.length > 20_000) text = `${text.slice(0, 20_000)}\n... [truncated]`;
749
- return {
750
- content: [{ type: "text" as const, text }],
751
- details: { ...details, data: safeData },
752
- };
753
- }
754
-
755
- function toTextToolResult(text: string, data: unknown, details: Record<string, unknown> = {}) {
756
- const safeData = redact(data);
757
- return {
758
- content: [{ type: "text" as const, text }],
759
- details: { ...details, data: safeData },
760
- };
761
- }
762
-
763
569
  function configProfiles(configPath = defaultConfigPath()): { path: string; exists: boolean; profiles: string[] } {
764
570
  if (!existsSync(configPath)) return { path: configPath, exists: false, profiles: [] };
765
571
  const contents = readFileSync(configPath, "utf8");
@@ -767,64 +573,6 @@ function configProfiles(configPath = defaultConfigPath()): { path: string; exist
767
573
  return { path: configPath, exists: true, profiles };
768
574
  }
769
575
 
770
- function arrayFromPayload(payload: unknown): unknown[] {
771
- if (Array.isArray(payload)) return payload;
772
- if (payload && typeof payload === "object") {
773
- const object = payload as Record<string, unknown>;
774
- for (const key of ["items", "results", "data", "issues", "executions", "chips", "tasks", "insights"]) {
775
- if (Array.isArray(object[key])) return object[key];
776
- }
777
- }
778
- return [];
779
- }
780
-
781
- function compactItems(payload: unknown, keys: string[], limit: number): Record<string, unknown>[] {
782
- return arrayFromPayload(payload).slice(0, limit).map((item) => {
783
- if (!item || typeof item !== "object") return { value: item };
784
- const object = item as Record<string, unknown>;
785
- return Object.fromEntries(keys.filter((key) => key in object).map((key) => [key, object[key]]));
786
- });
787
- }
788
-
789
- function payloadTotal(payload: unknown): number {
790
- const fallback = arrayFromPayload(payload).length;
791
- if (!payload || typeof payload !== "object") return fallback;
792
- const object = payload as Record<string, unknown>;
793
- for (const key of ["total", "total_count", "count", "total_items"]) {
794
- const value = object[key];
795
- if (typeof value === "number" && Number.isFinite(value)) return value;
796
- }
797
- return fallback;
798
- }
799
-
800
- function firstString(object: Record<string, unknown>, keys: string[]): string | undefined {
801
- for (const key of keys) {
802
- const value = object[key];
803
- if (typeof value === "string" && value.length > 0) return value;
804
- if (typeof value === "number" && Number.isFinite(value)) return String(value);
805
- }
806
- return undefined;
807
- }
808
-
809
- function statusIcon(status: string | undefined): string {
810
- const normalized = status?.toLowerCase() ?? "";
811
- if (["completed", "success", "succeeded", "active", "applied"].includes(normalized)) return "✓";
812
- if (["failed", "error", "crashed"].includes(normalized)) return "✗";
813
- if (["running", "pending", "queued", "in_progress"].includes(normalized)) return "…";
814
- if (["cancelled", "canceled"].includes(normalized)) return "-";
815
- return "•";
816
- }
817
-
818
- function formatItem(item: Record<string, unknown>, fallbackId: string): string {
819
- const id = firstString(item, ["issue_id", "execution_id", "flow_run_id", "task_id", "id"]) ?? fallbackId;
820
- const title = firstString(item, ["title", "task_name", "flow_name", "name"]);
821
- const status = firstString(item, ["status", "execution_status", "activity_status"]);
822
- const target = firstString(item, ["qid", "coupling_id"]);
823
- return [statusIcon(status), shortId(id, 14), title, target ? `(${target})` : undefined, status ? `[${status}]` : undefined]
824
- .filter(Boolean)
825
- .join(" ");
826
- }
827
-
828
576
  async function buildDashboard(params: { profile?: string; configPath?: string; useEnv?: boolean; chipId?: string; limit?: number }) {
829
577
  params = applyQDashContext(params);
830
578
  const limit = params.limit ?? 5;
@@ -866,58 +614,6 @@ async function buildDashboard(params: { profile?: string; configPath?: string; u
866
614
  };
867
615
  }
868
616
 
869
- function ansi(code: string, text: string): string {
870
- return `\u001b[${code}m${text}\u001b[0m`;
871
- }
872
-
873
- function stripAnsi(text: string): string {
874
- return text.replace(/\u001b\[[0-9;]*m/g, "");
875
- }
876
-
877
- function displayWidth(text: string): number {
878
- return visibleWidth(stripAnsi(text));
879
- }
880
-
881
- function padAnsi(text: string, width: number): string {
882
- return text + " ".repeat(Math.max(0, width - displayWidth(text)));
883
- }
884
-
885
- function truncateDisplay(text: string, width: number): string {
886
- if (displayWidth(text) <= width) return text;
887
- const ellipsis = "…";
888
- const target = Math.max(0, width - visibleWidth(ellipsis));
889
- let out = "";
890
- let used = 0;
891
- for (const char of Array.from(text)) {
892
- const charWidth = visibleWidth(char);
893
- if (used + charWidth > target) break;
894
- out += char;
895
- used += charWidth;
896
- }
897
- return out + ellipsis;
898
- }
899
-
900
- function boxed(title: string, body: string[], color = false): string[] {
901
- const plainTitle = ` ${title} `;
902
- const contentWidth = Math.min(
903
- 92,
904
- Math.max(36, ...body.map((line) => displayWidth(line)), visibleWidth(plainTitle)),
905
- );
906
- const innerWidth = contentWidth + 2;
907
- const borderColor = (text: string) => color ? ansi("90", text) : text;
908
- const titleText = color ? ansi("1;36", plainTitle) : plainTitle;
909
- const top = borderColor("╭") + titleText + borderColor("─".repeat(Math.max(0, innerWidth - plainTitle.length))) + borderColor("╮");
910
- const bottom = borderColor("╰" + "─".repeat(innerWidth) + "╯");
911
- return [
912
- top,
913
- ...body.map((line) => {
914
- const clipped = color ? truncateToWidth(line, contentWidth) : truncateDisplay(line, contentWidth);
915
- return `${borderColor("│")} ${padAnsi(clipped, contentWidth)} ${borderColor("│")}`;
916
- }),
917
- bottom,
918
- ];
919
- }
920
-
921
617
  function dashboardLines(dashboard: Awaited<ReturnType<typeof buildDashboard>>, color = false): string[] {
922
618
  const chips = arrayFromPayload(dashboard.chips);
923
619
  const profile = dashboard.context.profile ?? "env/default";
@@ -1713,451 +1409,6 @@ async function buildForumEvidenceReply(client: QDashClient, params: ForumEvidenc
1713
1409
  return { request, preview: content, parent, task, figures };
1714
1410
  }
1715
1411
 
1716
- function forumPostsFromPayload(payload: unknown): Record<string, unknown>[] {
1717
- if (Array.isArray(payload)) return payload.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
1718
- if (payload && typeof payload === "object") {
1719
- const object = payload as Record<string, unknown>;
1720
- for (const key of ["posts", "items", "results", "data"]) {
1721
- if (Array.isArray(object[key])) return object[key].filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
1722
- }
1723
- }
1724
- return [];
1725
- }
1726
-
1727
- function forumPostId(post: Record<string, unknown>): string {
1728
- return firstString(post, ["post_id", "id", "forum_post_id"]) ?? "unknown";
1729
- }
1730
-
1731
- function forumPostTitle(post: Record<string, unknown>): string {
1732
- return firstString(post, ["title", "subject", "summary"]) ?? firstString(post, ["content", "body", "text"])?.slice(0, 60) ?? "(untitled)";
1733
- }
1734
-
1735
- function firstNumber(object: Record<string, unknown>, keys: string[]): number | undefined {
1736
- for (const key of keys) {
1737
- const value = object[key];
1738
- if (typeof value === "number" && Number.isFinite(value)) return value;
1739
- }
1740
- return undefined;
1741
- }
1742
-
1743
- function stringList(value: unknown): string[] {
1744
- return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
1745
- }
1746
-
1747
- function forumPostLine(post: Record<string, unknown>): string {
1748
- const number = firstNumber(post, ["number"]);
1749
- const id = shortId(forumPostId(post), 10);
1750
- const title = forumPostTitle(post);
1751
- const status = firstString(post, ["status"]);
1752
- const category = firstString(post, ["category"]);
1753
- const targetType = firstString(post, ["target_type"]);
1754
- const target = firstString(post, ["target_id", "chip_id", "task_id", "qid"]);
1755
- const replies = firstNumber(post, ["reply_count"]);
1756
- const assignee = firstString(post, ["assignee_username", "username"]);
1757
- return [
1758
- "•",
1759
- number ? `#${number}` : id,
1760
- title,
1761
- category ? `[${category}]` : undefined,
1762
- status ? `[${status}]` : undefined,
1763
- target ? `(${targetType ? `${targetType}:` : ""}${target})` : undefined,
1764
- replies && replies > 0 ? `↩${replies}` : undefined,
1765
- assignee ? `@${assignee}` : undefined,
1766
- ].filter(Boolean).join(" ");
1767
- }
1768
-
1769
- function inlineContentText(value: unknown): string {
1770
- if (typeof value === "string") return value;
1771
- if (!Array.isArray(value)) return "";
1772
- return value.map((item) => {
1773
- if (typeof item === "string") return item;
1774
- if (!item || typeof item !== "object") return "";
1775
- const object = item as Record<string, unknown>;
1776
- if (typeof object.text === "string") return object.text;
1777
- if (object.type === "link") return inlineContentText(object.content);
1778
- return "";
1779
- }).join("");
1780
- }
1781
-
1782
- function forumBlockLines(blocks: unknown): string[] {
1783
- if (!Array.isArray(blocks)) return [];
1784
- const lines: string[] = [];
1785
- for (const item of blocks) {
1786
- if (!item || typeof item !== "object") continue;
1787
- const block = item as Record<string, unknown>;
1788
- const type = firstString(block, ["type"]) ?? "block";
1789
- const props = block.props && typeof block.props === "object" ? block.props as Record<string, unknown> : {};
1790
- const text = inlineContentText(block.content).trimEnd();
1791
- if (type === "heading") {
1792
- const level = firstNumber(props, ["level"]) ?? 2;
1793
- lines.push(`${"#".repeat(Math.max(1, Math.min(6, level)))} ${text}`.trimEnd());
1794
- } else if (type === "image") {
1795
- const name = firstString(props, ["name", "caption"]) ?? "image";
1796
- const url = firstString(props, ["url"]);
1797
- lines.push(`🖼 ${name}${url ? ` ${url}` : ""}`);
1798
- } else if (type === "bulletListItem") {
1799
- lines.push(`- ${text}`);
1800
- } else if (type === "numberedListItem") {
1801
- lines.push(`1. ${text}`);
1802
- } else if (text) {
1803
- lines.push(...text.split("\n"));
1804
- }
1805
- const childLines = forumBlockLines(block.children);
1806
- if (childLines.length > 0) lines.push(...childLines.map((line) => ` ${line}`));
1807
- }
1808
- return lines;
1809
- }
1810
-
1811
- function forumContentLines(object: Record<string, unknown>): string[] {
1812
- const blockLines = forumBlockLines(object.content_blocks);
1813
- if (blockLines.length > 0) return blockLines;
1814
- const content = firstString(object, ["content", "body", "text", "message", "description"]);
1815
- return content ? content.split("\n") : ["(no content)"];
1816
- }
1817
-
1818
- function forumListLines(payload: unknown, title = "QDash Forum", color = false): string[] {
1819
- const posts = forumPostsFromPayload(payload);
1820
- const total = payloadTotal(payload);
1821
- const accent = (text: string) => color ? ansi("1;36", text) : text;
1822
- const dim = (text: string) => color ? ansi("2", text) : text;
1823
- return boxed(title, [
1824
- `posts ${accent(`${posts.length}/${total}`)}`,
1825
- "",
1826
- ...(posts.length > 0 ? posts.map((post) => ` ${forumPostLine(post)}`) : [` ${dim("none")}`]),
1827
- ], color);
1828
- }
1829
-
1830
- function forumDetailBodyLines(post: unknown, color = false): string[] {
1831
- const object = post && typeof post === "object" ? post as Record<string, unknown> : {};
1832
- const accent = (text: string) => color ? ansi("1;36", text) : text;
1833
- const muted = (text: string) => color ? ansi("90", text) : text;
1834
- const labels = stringList(object.labels);
1835
- const replyCount = firstNumber(object, ["reply_count"]);
1836
- const meta = [
1837
- firstString(object, ["category"]) ? `${muted("category")} ${firstString(object, ["category"])}` : undefined,
1838
- firstString(object, ["status"]) ? `${muted("status")} ${firstString(object, ["status"])}` : undefined,
1839
- firstString(object, ["target_type"]) || firstString(object, ["target_id"]) ? `${muted("target")} ${[firstString(object, ["target_type"]), firstString(object, ["target_id"])].filter(Boolean).join(":")}` : undefined,
1840
- firstString(object, ["chip_id"]) ? `${muted("chip")} ${firstString(object, ["chip_id"])}` : undefined,
1841
- replyCount !== undefined ? `${muted("replies")} ${replyCount}` : undefined,
1842
- ].filter((line): line is string => typeof line === "string");
1843
- return [
1844
- `${muted("id")} ${accent(forumPostId(object))}${firstNumber(object, ["number"]) ? ` ${muted("#")} ${firstNumber(object, ["number"])}` : ""}`,
1845
- `${muted("title")} ${forumPostTitle(object)}`,
1846
- `${muted("author")} ${firstString(object, ["username", "user_id"]) ?? "unknown"}${firstString(object, ["assignee_username"]) ? ` ${muted("assignee")} ${firstString(object, ["assignee_username"])}` : ""}`,
1847
- meta.join(" "),
1848
- labels.length > 0 ? `${muted("labels")} ${labels.map((label) => `#${label}`).join(" ")}` : undefined,
1849
- firstString(object, ["created_at"]) ? `${muted("created")} ${firstString(object, ["created_at"])}${firstString(object, ["updated_at"]) ? ` ${muted("updated")} ${firstString(object, ["updated_at"])}` : ""}` : undefined,
1850
- "",
1851
- ...forumContentLines(object).map((line) => ` ${line}`),
1852
- ].filter((line): line is string => typeof line === "string");
1853
- }
1854
-
1855
- function forumDetailLines(post: unknown, title = "QDash Forum Post", color = false): string[] {
1856
- return boxed(title, forumDetailBodyLines(post, color), color);
1857
- }
1858
-
1859
- function wrapPlainLine(line: string, width: number): string[] {
1860
- if (width <= 0 || visibleWidth(line) <= width) return [line];
1861
- const output: string[] = [];
1862
- let rest = line;
1863
- while (visibleWidth(rest) > width) {
1864
- let slice = "";
1865
- for (const char of rest) {
1866
- if (visibleWidth(slice + char) > width) break;
1867
- slice += char;
1868
- }
1869
- output.push(slice);
1870
- rest = rest.slice(slice.length);
1871
- }
1872
- if (rest.length > 0) output.push(rest);
1873
- return output;
1874
- }
1875
-
1876
- function boxLinesToWidth(title: string, body: string[], width: number, theme?: Theme): string[] {
1877
- const contentWidth = Math.max(24, Math.min(100, width - 4));
1878
- const border = (text: string) => theme ? theme.fg("borderMuted", text) : text;
1879
- const titleText = ` ${title} `;
1880
- const top = `${border("╭")}${theme ? theme.fg("accent", theme.bold(titleText)) : titleText}${border("─".repeat(Math.max(0, contentWidth + 2 - visibleWidth(titleText))))}${border("╮")}`;
1881
- const bottom = border(`╰${"─".repeat(contentWidth + 2)}╯`);
1882
- const rows = body.flatMap((line) => wrapPlainLine(line, contentWidth));
1883
- return [
1884
- top,
1885
- ...rows.map((line) => `${border("│")} ${padAnsi(truncateDisplay(line, contentWidth), contentWidth)} ${border("│")}`),
1886
- bottom,
1887
- ];
1888
- }
1889
-
1890
- function textComponent(lines: string[], _theme: Theme, wrap = false) {
1891
- return {
1892
- render(width: number) {
1893
- if (!wrap) return lines.map((line) => truncateToWidth(line, width));
1894
- return lines.flatMap((line) => wrapPlainLine(line, width));
1895
- },
1896
- invalidate() {},
1897
- };
1898
- }
1899
-
1900
- function forumDetailComponent(post: unknown, theme: Theme) {
1901
- return {
1902
- render(width: number) {
1903
- return boxLinesToWidth("QDash Forum Post", forumDetailBodyLines(post), width, theme);
1904
- },
1905
- invalidate() {},
1906
- };
1907
- }
1908
-
1909
- type TimeseriesPoint = {
1910
- series: string;
1911
- value: number;
1912
- at?: string;
1913
- unit?: string;
1914
- taskId?: string;
1915
- executionId?: string;
1916
- };
1917
-
1918
- function timeseriesPoints(payload: unknown): TimeseriesPoint[] {
1919
- const points: TimeseriesPoint[] = [];
1920
- const addPoint = (series: string, item: unknown) => {
1921
- if (!item || typeof item !== "object") return;
1922
- const object = item as Record<string, unknown>;
1923
- const value = object.value;
1924
- if (typeof value !== "number" || !Number.isFinite(value)) return;
1925
- points.push({
1926
- series,
1927
- value,
1928
- at: firstString(object, ["calibrated_at", "timestamp", "created_at", "start_at"]),
1929
- unit: firstString(object, ["unit"]),
1930
- taskId: firstString(object, ["task_id"]),
1931
- executionId: firstString(object, ["execution_id"]),
1932
- });
1933
- };
1934
- const visit = (value: unknown, series = "series") => {
1935
- if (Array.isArray(value)) {
1936
- for (const item of value) addPoint(series, item);
1937
- return;
1938
- }
1939
- if (!value || typeof value !== "object") return;
1940
- const object = value as Record<string, unknown>;
1941
- if ("value" in object) {
1942
- addPoint(series, object);
1943
- return;
1944
- }
1945
- for (const [key, nested] of Object.entries(object)) visit(nested, key);
1946
- };
1947
- if (payload && typeof payload === "object" && "data" in payload) visit((payload as Record<string, unknown>).data);
1948
- else visit(payload);
1949
- return points.sort((a, b) => (a.at ?? "").localeCompare(b.at ?? ""));
1950
- }
1951
-
1952
- function formatNumber(value: number): string {
1953
- const abs = Math.abs(value);
1954
- if (abs >= 1000 || (abs > 0 && abs < 0.001)) return value.toExponential(2);
1955
- if (abs >= 100) return value.toFixed(1);
1956
- if (abs >= 10) return value.toFixed(3);
1957
- return value.toFixed(5).replace(/0+$/, "").replace(/\.$/, "");
1958
- }
1959
-
1960
- function compactDate(value: string | undefined): string {
1961
- if (!value) return "";
1962
- const date = new Date(value.endsWith("Z") ? value : `${value}Z`);
1963
- if (Number.isNaN(date.getTime())) return value.slice(0, 10);
1964
- return `${String(date.getUTCMonth() + 1).padStart(2, "0")}/${String(date.getUTCDate()).padStart(2, "0")} ${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}`;
1965
- }
1966
-
1967
- function samplePoints(points: TimeseriesPoint[], width: number): TimeseriesPoint[] {
1968
- if (points.length <= width) return points;
1969
- const sampled: TimeseriesPoint[] = [];
1970
- for (let i = 0; i < width; i++) sampled.push(points[Math.round(i * (points.length - 1) / (width - 1))]);
1971
- return sampled;
1972
- }
1973
-
1974
- function plotSeriesLines(points: TimeseriesPoint[], options: { title: string; height?: number; width?: number; color?: boolean }): string[] {
1975
- const height = Math.max(3, Math.min(20, options.height ?? 8));
1976
- const plotWidth = Math.max(8, Math.min(100, options.width ?? 60));
1977
- const accent = (text: string) => options.color ? ansi("1;36", text) : text;
1978
- const muted = (text: string) => options.color ? ansi("90", text) : text;
1979
- if (points.length === 0) return boxed(options.title, [muted("no numeric data")], options.color);
1980
-
1981
- const sampled = samplePoints(points, plotWidth);
1982
- const values = sampled.map((point) => point.value);
1983
- const min = Math.min(...values);
1984
- const max = Math.max(...values);
1985
- const span = max - min || 1;
1986
- const grid = Array.from({ length: height }, () => Array.from({ length: sampled.length }, () => " "));
1987
- sampled.forEach((point, col) => {
1988
- const row = height - 1 - Math.round((point.value - min) / span * (height - 1));
1989
- grid[Math.max(0, Math.min(height - 1, row))][col] = "●";
1990
- });
1991
- const yLabelWidth = Math.max(formatNumber(max).length, formatNumber(min).length, 6);
1992
- const rows = grid.map((row, index) => {
1993
- const value = max - (span * index / (height - 1));
1994
- return `${formatNumber(value).padStart(yLabelWidth)} ┤${row.join("")}`;
1995
- });
1996
- const unit = points.find((point) => point.unit)?.unit;
1997
- const first = points[0];
1998
- const last = points[points.length - 1];
1999
- const body = [
2000
- `${muted("series")} ${accent([...new Set(points.map((point) => point.series))].join(", "))}${unit ? ` ${muted("unit")} ${unit}` : ""}`,
2001
- `${muted("count")} ${points.length} ${muted("min")} ${formatNumber(Math.min(...points.map((point) => point.value)))} ${muted("max")} ${formatNumber(Math.max(...points.map((point) => point.value)))} ${muted("last")} ${formatNumber(last.value)}`,
2002
- `${muted("range")} ${compactDate(first.at)} → ${compactDate(last.at)}`,
2003
- "",
2004
- ...rows,
2005
- `${" ".repeat(yLabelWidth)} └${"─".repeat(sampled.length)}`,
2006
- ];
2007
- return boxed(options.title, body, options.color);
2008
- }
2009
-
2010
- function timeseriesPlotComponent(data: unknown, title: string, theme: Theme) {
2011
- return {
2012
- render(width: number) {
2013
- const points = timeseriesPoints(data);
2014
- return plotSeriesLines(points, { title, width: Math.max(8, width - 16) }).map((line) => truncateToWidth(line, width));
2015
- },
2016
- invalidate() {},
2017
- };
2018
- }
2019
-
2020
- function mediaTypeForPath(path: string): string {
2021
- const lower = path.toLowerCase();
2022
- if (lower.endsWith(".png")) return "image/png";
2023
- if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
2024
- if (lower.endsWith(".gif")) return "image/gif";
2025
- if (lower.endsWith(".webp")) return "image/webp";
2026
- if (lower.endsWith(".json")) return "application/json";
2027
- return "application/octet-stream";
2028
- }
2029
-
2030
- async function fetchFigureDetails(client: QDashClient, path: string): Promise<FigureDetails> {
2031
- const file = await client.getExecutionFigure(path);
2032
- const bytes = Buffer.from(file.data);
2033
- const mediaType = file.mediaType || mediaTypeForPath(path);
2034
- const details: FigureDetails = {
2035
- tool: "qdash_get_figure",
2036
- path,
2037
- mediaType,
2038
- sizeBytes: bytes.byteLength,
2039
- };
2040
- if (mediaType.startsWith("image/")) details.base64 = bytes.toString("base64");
2041
- else details.text = bytes.toString("utf8");
2042
- return details;
2043
- }
2044
-
2045
- function figureResultText(details: FigureDetails): string {
2046
- const lines = boxed("QDash Figure", [
2047
- `path ${details.path}`,
2048
- `type ${details.mediaType}`,
2049
- `size ${details.sizeBytes} bytes`,
2050
- ...(details.taskId ? [`task ${details.taskId}`] : []),
2051
- ...(details.figurePaths?.length ? [`figures ${details.figurePaths.length}`] : []),
2052
- ...(details.jsonFigurePaths?.length ? [`json figures ${details.jsonFigurePaths.length}`] : []),
2053
- ...(details.text ? ["", ...details.text.split("\n").slice(0, 12).map((line) => ` ${line}`)] : []),
2054
- ]);
2055
- return lines.join("\n");
2056
- }
2057
-
2058
- function figureComponent(details: FigureDetails, theme: Theme) {
2059
- if (details.base64 && details.mediaType.startsWith("image/")) {
2060
- return new Image(details.base64, details.mediaType, { fallbackColor: (text: string) => theme.fg("dim", text) }, { maxWidthCells: 90, maxHeightCells: 30 });
2061
- }
2062
- return textComponent(figureResultText(details).split("\n"), theme);
2063
- }
2064
-
2065
- type PlotlyTrace = Record<string, unknown>;
2066
-
2067
- type FigureAnalysis = {
2068
- path: string;
2069
- taskId?: string;
2070
- traceCount: number;
2071
- summaries: string[];
2072
- warnings: string[];
2073
- };
2074
-
2075
- function numericArray(value: unknown): number[] {
2076
- return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number" && Number.isFinite(item)) : [];
2077
- }
2078
-
2079
- function numericMatrix(value: unknown): number[][] {
2080
- return Array.isArray(value)
2081
- ? value.map((row) => numericArray(row)).filter((row) => row.length > 0)
2082
- : [];
2083
- }
2084
-
2085
- function matrixValue(matrix: number[][], row: number, column: number): number | undefined {
2086
- return matrix[row]?.[column];
2087
- }
2088
-
2089
- function compactStats(values: number[]): string {
2090
- if (!values.length) return "no numeric values";
2091
- const min = Math.min(...values);
2092
- const max = Math.max(...values);
2093
- const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
2094
- return `n=${values.length} min=${formatNumber(min)} max=${formatNumber(max)} mean=${formatNumber(mean)}`;
2095
- }
2096
-
2097
- function formatMaybeNumber(value: number | undefined): string {
2098
- return typeof value === "number" && Number.isFinite(value) ? formatNumber(value) : "n/a";
2099
- }
2100
-
2101
- function analyzePlotlyFigure(path: string, figure: unknown, taskId?: string): FigureAnalysis {
2102
- const object = figure && typeof figure === "object" ? figure as Record<string, unknown> : {};
2103
- const traces = Array.isArray(object.data) ? object.data.filter((trace): trace is PlotlyTrace => Boolean(trace) && typeof trace === "object") : [];
2104
- const summaries: string[] = [];
2105
- const warnings: string[] = [];
2106
-
2107
- traces.forEach((trace, index) => {
2108
- const type = typeof trace.type === "string" ? trace.type : "unknown";
2109
- const name = typeof trace.name === "string" ? trace.name : `trace ${index}`;
2110
- if (type === "scatter" || Array.isArray(trace.y)) {
2111
- const y = numericArray(trace.y);
2112
- const x = numericArray(trace.x);
2113
- const err = trace.error_y && typeof trace.error_y === "object" ? (trace.error_y as Record<string, unknown>).value : undefined;
2114
- summaries.push(`${index}: scatter ${name} y(${compactStats(y)}) first=${formatMaybeNumber(y[0])} last=${formatMaybeNumber(y[y.length - 1])}${typeof err === "number" ? ` err=${formatNumber(err)}` : ""}`);
2115
- if (y.length >= 5) {
2116
- const tail = y.slice(Math.floor(y.length / 2));
2117
- const tailSpan = Math.max(...tail) - Math.min(...tail);
2118
- if (tailSpan > 0.4) warnings.push(`scatter ${name}: late repetitions still have large span ${formatNumber(tailSpan)}; repeated-pulse response may be unstable`);
2119
- const firstStep = Math.abs((y[1] ?? y[0]) - y[0]);
2120
- if (firstStep > 0.6 && Math.abs(y[y.length - 1]) > 0.1) warnings.push(`scatter ${name}: large first-step contrast but non-zero final offset ${formatNumber(y[y.length - 1])}`);
2121
- }
2122
- if (x.length && y.length && x.length !== y.length) warnings.push(`scatter ${name}: x/y length mismatch ${x.length}/${y.length}`);
2123
- return;
2124
- }
2125
-
2126
- if (type === "heatmap" || Array.isArray(trace.z)) {
2127
- const z = numericMatrix(trace.z);
2128
- const flat = z.flat();
2129
- summaries.push(`${index}: heatmap ${z.length}x${z[0]?.length ?? 0} z(${compactStats(flat)})`);
2130
- if (z.length === 4 && z.every((row) => row.length === 4)) {
2131
- const diag = [0, 1, 2, 3].map((i) => z[i][i]);
2132
- const pop10 = diag[2];
2133
- const coh0011Re = matrixValue(z, 0, 3);
2134
- summaries.push(` 4x4 diag=[${diag.map(formatNumber).join(", ")}] z00,11=${formatMaybeNumber(coh0011Re)}`);
2135
- if (pop10 > 0.15) warnings.push(`4x4 heatmap: |10> population/component is high (${formatNumber(pop10)}); suspect Bell/Zx angle, phase, or cancel error`);
2136
- }
2137
- return;
2138
- }
2139
-
2140
- summaries.push(`${index}: ${type}`);
2141
- });
2142
-
2143
- if (!traces.length) warnings.push("no Plotly data traces found");
2144
- return { path, taskId, traceCount: traces.length, summaries, warnings };
2145
- }
2146
-
2147
- function figureAnalysisText(analysis: FigureAnalysis): string {
2148
- return boxed("QDash Figure Analysis", [
2149
- `path ${analysis.path}`,
2150
- ...(analysis.taskId ? [`task ${analysis.taskId}`] : []),
2151
- `traces ${analysis.traceCount}`,
2152
- "",
2153
- "Summaries",
2154
- ...analysis.summaries.map((line) => ` - ${line}`),
2155
- "",
2156
- "Warnings",
2157
- ...(analysis.warnings.length ? analysis.warnings.map((line) => ` - ${line}`) : [" - none"]),
2158
- ]).join("\n");
2159
- }
2160
-
2161
1412
  async function executeQuery(params: QDashQueryParams) {
2162
1413
  params = applyQDashContext(params);
2163
1414
  const client = await makeClient(params);
@@ -2215,25 +1466,7 @@ async function executeQuery(params: QDashQueryParams) {
2215
1466
  }
2216
1467
 
2217
1468
  export default function qdashExtension(pi: ExtensionAPI) {
2218
- pi.on("tool_call", async (event, ctx) => {
2219
- if (!WRITE_TOOL_NAMES.has(event.toolName)) return;
2220
- const input = event.input as { confirmWrite?: boolean };
2221
- if (input.confirmWrite === true) return;
2222
-
2223
- if (!ctx.hasUI) {
2224
- return {
2225
- block: true,
2226
- reason: `${event.toolName} is a QDash write operation and requires confirmWrite: true in non-interactive mode.`,
2227
- };
2228
- }
2229
-
2230
- const ok = await ctx.ui.confirm(
2231
- "Approve QDash write operation?",
2232
- `${event.toolName} will create or modify data in QDash. Continue?`,
2233
- );
2234
- if (!ok) return { block: true, reason: "QDash write operation rejected by user" };
2235
- input.confirmWrite = true;
2236
- });
1469
+ installQDashWriteGate(pi);
2237
1470
 
2238
1471
  pi.registerTool({
2239
1472
  name: "qdash_config_info",
@@ -3281,6 +2514,105 @@ export default function qdashExtension(pi: ExtensionAPI) {
3281
2514
  },
3282
2515
  });
3283
2516
 
2517
+ pi.registerTool({
2518
+ name: "qdash_build_qcal_evidence",
2519
+ label: "QDash Build QCal Evidence",
2520
+ description: "Build a provider-neutral CalibrationEvidence bundle from a QDash task result for evaluation by pi-qcal.",
2521
+ promptSnippet: "Convert QDash calibration task results into pi-qcal CalibrationEvidence",
2522
+ promptGuidelines: [
2523
+ "Use qdash_build_qcal_evidence before qcal_evaluate_bundle when the user asks to evaluate QDash calibration data with pi-qcal.",
2524
+ "qdash_build_qcal_evidence is read-only and only converts QDash data into a generic evidence bundle; it does not evaluate, execute, commit, or apply calibration parameters.",
2525
+ "After qdash_build_qcal_evidence, pass details.evidence to qcal_evaluate_bundle and treat the result as advisory evidence.",
2526
+ ],
2527
+ parameters: Type.Object({
2528
+ ...connectionParams,
2529
+ taskId: Type.String(),
2530
+ includeFigures: Type.Optional(Type.Boolean({ description: "Embed task image figures as data URLs. Defaults to true." })),
2531
+ includeFigureAnalysis: Type.Optional(Type.Boolean({ description: "Analyze Plotly JSON figures and include summaries/warnings in notes. Defaults to true." })),
2532
+ maxFigures: Type.Optional(Type.Number({ description: "Maximum image figures to embed. Defaults to 1." })),
2533
+ includeRawTaskResult: Type.Optional(Type.Boolean({ description: "Include the full raw task result in resultJson. Defaults to false." })),
2534
+ }),
2535
+ async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; taskId: string; includeFigures?: boolean; includeFigureAnalysis?: boolean; maxFigures?: number; includeRawTaskResult?: boolean }) {
2536
+ const client = await makeClient(params);
2537
+ const task = await client.getTaskResult(params.taskId) as unknown as Record<string, unknown>;
2538
+ const figures: Array<Record<string, unknown>> = [];
2539
+ const notes: string[] = [];
2540
+ const figurePaths = Array.isArray(task.figure_path) ? task.figure_path.filter((item): item is string => typeof item === "string") : [];
2541
+ const jsonFigurePaths = Array.isArray(task.json_figure_path) ? task.json_figure_path.filter((item): item is string => typeof item === "string") : [];
2542
+
2543
+ if (params.includeFigureAnalysis !== false) {
2544
+ const limit = Math.min(jsonFigurePaths.length, params.maxFigures ?? 1);
2545
+ for (let index = 0; index < limit; index += 1) {
2546
+ try {
2547
+ const file: TaskResultFigureFile = await client.getTaskResultFigure(params.taskId, { index, preferJson: true });
2548
+ const figure = JSON.parse(Buffer.from(file.data).toString("utf8")) as unknown;
2549
+ const analysis = analyzePlotlyFigure(file.path ?? jsonFigurePaths[index] ?? "", figure, params.taskId);
2550
+ notes.push(`Figure JSON analysis #${index + 1}:`, ...analysis.summaries.map((line) => `- ${line}`));
2551
+ if (analysis.warnings.length) notes.push("Warnings:", ...analysis.warnings.map((line) => `- ${line}`));
2552
+ figures.push({ path: file.path ?? jsonFigurePaths[index], role: "plotly", data: figure });
2553
+ } catch (error) {
2554
+ notes.push(`Figure JSON analysis #${index + 1} failed: ${error instanceof Error ? error.message : String(error)}`);
2555
+ }
2556
+ }
2557
+ }
2558
+
2559
+ if (params.includeFigures !== false) {
2560
+ const limit = Math.min(figurePaths.length, params.maxFigures ?? 1);
2561
+ for (let index = 0; index < limit; index += 1) {
2562
+ try {
2563
+ const file: TaskResultFigureFile = await client.getTaskResultFigure(params.taskId, { index, preferJson: false });
2564
+ const bytes = Buffer.from(file.data);
2565
+ const mediaType = file.mediaType || mediaTypeForPath(file.path ?? figurePaths[index] ?? "");
2566
+ if (mediaType.startsWith("image/")) {
2567
+ figures.push({ path: file.path ?? figurePaths[index], url: `data:${mediaType};base64,${bytes.toString("base64")}`, mimeType: mediaType, role: "plot" });
2568
+ }
2569
+ } catch (error) {
2570
+ notes.push(`Figure image #${index + 1} embedding failed: ${error instanceof Error ? error.message : String(error)}`);
2571
+ }
2572
+ }
2573
+ }
2574
+
2575
+ const couplingId = firstStringField(task, ["coupling_id", "couplingId", "qid"]);
2576
+ const qid = firstStringField(task, ["qid"]);
2577
+ const evidence: CalibrationEvidence = {
2578
+ id: params.taskId,
2579
+ source: "external",
2580
+ target: {
2581
+ kind: couplingId && couplingId.includes("-") ? "coupling" : qid ? "qubit" : "system",
2582
+ id: couplingId ?? qid,
2583
+ couplingId: couplingId && couplingId.includes("-") ? couplingId : undefined,
2584
+ qids: qid && !qid.includes("-") ? [qid] : undefined,
2585
+ chipId: firstStringField(task, ["chip_id", "chipId"]),
2586
+ },
2587
+ task: {
2588
+ name: firstStringField(task, ["task_name", "taskName", "name"]),
2589
+ objective: `Evaluate QDash calibration task ${firstStringField(task, ["task_name", "taskName", "name"]) ?? params.taskId}`,
2590
+ backend: firstStringField(task, ["backend", "chip_id", "chipId"]),
2591
+ startedAt: firstStringField(task, ["start_at", "startAt"]),
2592
+ completedAt: firstStringField(task, ["end_at", "endAt"]),
2593
+ status: firstStringField(task, ["status"]),
2594
+ },
2595
+ metrics: parameterValueRecord(task.output_parameters),
2596
+ parameters: { ...parameterValueRecord(task.input_parameters), ...parameterValueRecord(task.run_parameters) },
2597
+ figures: figures.length ? figures : undefined,
2598
+ notes: [
2599
+ typeof task.message === "string" ? `Task message: ${task.message}` : undefined,
2600
+ notes.length ? notes.join("\n") : undefined,
2601
+ ].filter(Boolean).join("\n\n") || undefined,
2602
+ resultJson: params.includeRawTaskResult ? task : {
2603
+ task_id: task.task_id,
2604
+ execution_id: task.execution_id,
2605
+ flow_name: task.flow_name,
2606
+ figure_path: figurePaths,
2607
+ json_figure_path: jsonFigurePaths,
2608
+ links: task._links,
2609
+ },
2610
+ };
2611
+
2612
+ return toTextToolResult(qcalEvidenceText(evidence), { evidence }, { tool: "qdash_build_qcal_evidence", taskId: params.taskId, evidence });
2613
+ },
2614
+ });
2615
+
3284
2616
  registerQueryTool({
3285
2617
  name: "qdash_get_provenance_stats",
3286
2618
  label: "QDash Provenance Stats",