@oqtopus-team/pi-qdash 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -448,23 +436,6 @@ type WiringInsightParams = CooldownWiringParams & {
448
436
  color?: boolean;
449
437
  };
450
438
 
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
439
  function newestCooldown(cooldowns: CooldownRecord[]): CooldownRecord | undefined {
469
440
  return [...cooldowns].sort((a, b) => {
470
441
  const activeDifference = Number(a.ended_at != null) - Number(b.ended_at != null);
@@ -473,145 +444,6 @@ function newestCooldown(cooldowns: CooldownRecord[]): CooldownRecord | undefined
473
444
  })[0];
474
445
  }
475
446
 
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
447
  async function resolveCooldownWiring(params: CooldownWiringParams) {
616
448
  params = applyQDashContext(params);
617
449
  const client = await makeClient(params);
@@ -682,84 +514,6 @@ async function resolveCooldownWiring(params: CooldownWiringParams) {
682
514
  };
683
515
  }
684
516
 
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
517
  function configProfiles(configPath = defaultConfigPath()): { path: string; exists: boolean; profiles: string[] } {
764
518
  if (!existsSync(configPath)) return { path: configPath, exists: false, profiles: [] };
765
519
  const contents = readFileSync(configPath, "utf8");
@@ -767,64 +521,6 @@ function configProfiles(configPath = defaultConfigPath()): { path: string; exist
767
521
  return { path: configPath, exists: true, profiles };
768
522
  }
769
523
 
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
524
  async function buildDashboard(params: { profile?: string; configPath?: string; useEnv?: boolean; chipId?: string; limit?: number }) {
829
525
  params = applyQDashContext(params);
830
526
  const limit = params.limit ?? 5;
@@ -866,58 +562,6 @@ async function buildDashboard(params: { profile?: string; configPath?: string; u
866
562
  };
867
563
  }
868
564
 
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
565
  function dashboardLines(dashboard: Awaited<ReturnType<typeof buildDashboard>>, color = false): string[] {
922
566
  const chips = arrayFromPayload(dashboard.chips);
923
567
  const profile = dashboard.context.profile ?? "env/default";
@@ -1713,451 +1357,6 @@ async function buildForumEvidenceReply(client: QDashClient, params: ForumEvidenc
1713
1357
  return { request, preview: content, parent, task, figures };
1714
1358
  }
1715
1359
 
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
1360
  async function executeQuery(params: QDashQueryParams) {
2162
1361
  params = applyQDashContext(params);
2163
1362
  const client = await makeClient(params);
@@ -2215,25 +1414,7 @@ async function executeQuery(params: QDashQueryParams) {
2215
1414
  }
2216
1415
 
2217
1416
  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
- });
1417
+ installQDashWriteGate(pi);
2237
1418
 
2238
1419
  pi.registerTool({
2239
1420
  name: "qdash_config_info",