@oqtopus-team/pi-qdash 0.1.1 → 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.
- package/README.md +24 -0
- package/extensions/lib/figure-analysis.ts +112 -0
- package/extensions/lib/figures.ts +78 -0
- package/extensions/lib/forum-render.ts +152 -0
- package/extensions/lib/links.ts +57 -0
- package/extensions/lib/payload.ts +70 -0
- package/extensions/lib/render.ts +110 -0
- package/extensions/lib/results.ts +30 -0
- package/extensions/lib/timeseries-plot.ts +101 -0
- package/extensions/lib/wiring-analysis.ts +169 -0
- package/extensions/lib/write-gate.ts +35 -0
- package/extensions/qdash.ts +48 -736
- package/package.json +1 -1
- package/skills/qdash/SKILL.md +2 -1
- package/skills/{qdash-calibration → qdash-calibration-agent}/SKILL.md +8 -4
- package/skills/qdash-two-qubit-calibration-diagnosis/SKILL.md +125 -0
package/extensions/qdash.ts
CHANGED
|
@@ -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 {
|
|
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,355 +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
1360
|
async function executeQuery(params: QDashQueryParams) {
|
|
2066
1361
|
params = applyQDashContext(params);
|
|
2067
1362
|
const client = await makeClient(params);
|
|
@@ -2119,25 +1414,7 @@ async function executeQuery(params: QDashQueryParams) {
|
|
|
2119
1414
|
}
|
|
2120
1415
|
|
|
2121
1416
|
export default function qdashExtension(pi: ExtensionAPI) {
|
|
2122
|
-
pi
|
|
2123
|
-
if (!WRITE_TOOL_NAMES.has(event.toolName)) return;
|
|
2124
|
-
const input = event.input as { confirmWrite?: boolean };
|
|
2125
|
-
if (input.confirmWrite === true) return;
|
|
2126
|
-
|
|
2127
|
-
if (!ctx.hasUI) {
|
|
2128
|
-
return {
|
|
2129
|
-
block: true,
|
|
2130
|
-
reason: `${event.toolName} is a QDash write operation and requires confirmWrite: true in non-interactive mode.`,
|
|
2131
|
-
};
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
const ok = await ctx.ui.confirm(
|
|
2135
|
-
"Approve QDash write operation?",
|
|
2136
|
-
`${event.toolName} will create or modify data in QDash. Continue?`,
|
|
2137
|
-
);
|
|
2138
|
-
if (!ok) return { block: true, reason: "QDash write operation rejected by user" };
|
|
2139
|
-
input.confirmWrite = true;
|
|
2140
|
-
});
|
|
1417
|
+
installQDashWriteGate(pi);
|
|
2141
1418
|
|
|
2142
1419
|
pi.registerTool({
|
|
2143
1420
|
name: "qdash_config_info",
|
|
@@ -3150,6 +2427,41 @@ export default function qdashExtension(pi: ExtensionAPI) {
|
|
|
3150
2427
|
},
|
|
3151
2428
|
});
|
|
3152
2429
|
|
|
2430
|
+
pi.registerTool({
|
|
2431
|
+
name: "qdash_analyze_figure_json",
|
|
2432
|
+
label: "QDash Analyze Figure JSON",
|
|
2433
|
+
description: "Read-only helper that fetches a Plotly JSON calibration figure and summarizes numeric traces for agentic diagnosis.",
|
|
2434
|
+
promptSnippet: "Analyze QDash calibration figure JSON traces without changing parameters",
|
|
2435
|
+
promptGuidelines: [
|
|
2436
|
+
"Use qdash_analyze_figure_json when a calibration figure has json_figure_path and you need numeric evidence from scatter or heatmap traces.",
|
|
2437
|
+
"Treat the output as evidence for a skill/runbook decision; do not commit/apply parameters from this analysis alone.",
|
|
2438
|
+
],
|
|
2439
|
+
parameters: Type.Object({
|
|
2440
|
+
...connectionParams,
|
|
2441
|
+
path: Type.Optional(Type.String({ description: "Absolute JSON figure path. If omitted, taskId is required." })),
|
|
2442
|
+
taskId: Type.Optional(Type.String({ description: "Task result ID used to select json_figure_path." })),
|
|
2443
|
+
index: Type.Optional(Type.Number({ description: "JSON figure index for taskId selection. Defaults to 0." })),
|
|
2444
|
+
includeData: Type.Optional(Type.Boolean({ description: "Include parsed Plotly JSON in structured data. Defaults to false." })),
|
|
2445
|
+
}),
|
|
2446
|
+
async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; path?: string; taskId?: string; index?: number; includeData?: boolean }) {
|
|
2447
|
+
const client = await makeClient(params);
|
|
2448
|
+
let path = params.path;
|
|
2449
|
+
let text = "";
|
|
2450
|
+
if (path) {
|
|
2451
|
+
const details = await fetchFigureDetails(client, path);
|
|
2452
|
+
text = details.text ?? "";
|
|
2453
|
+
} else {
|
|
2454
|
+
const taskId = requireValue(params.taskId, "taskId");
|
|
2455
|
+
const file: TaskResultFigureFile = await client.getTaskResultFigure(taskId, { index: params.index, preferJson: true });
|
|
2456
|
+
path = file.path;
|
|
2457
|
+
text = Buffer.from(file.data).toString("utf8");
|
|
2458
|
+
}
|
|
2459
|
+
const figure = JSON.parse(text) as unknown;
|
|
2460
|
+
const analysis = analyzePlotlyFigure(path ?? "", figure, params.taskId);
|
|
2461
|
+
return toTextToolResult(figureAnalysisText(analysis), { analysis, figure: params.includeData ? figure : undefined }, { tool: "qdash_analyze_figure_json", path, data: analysis });
|
|
2462
|
+
},
|
|
2463
|
+
});
|
|
2464
|
+
|
|
3153
2465
|
registerQueryTool({
|
|
3154
2466
|
name: "qdash_get_provenance_stats",
|
|
3155
2467
|
label: "QDash Provenance Stats",
|