@oqtopus-team/pi-qdash 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ import { firstString } from "./payload.js";
5
+ import { ansi, boxed, compactDate, formatNumber } from "./render.js";
6
+
7
+ export type TimeseriesPoint = {
8
+ series: string;
9
+ value: number;
10
+ at?: string;
11
+ unit?: string;
12
+ taskId?: string;
13
+ executionId?: string;
14
+ };
15
+
16
+ export function timeseriesPoints(payload: unknown): TimeseriesPoint[] {
17
+ const points: TimeseriesPoint[] = [];
18
+ const addPoint = (series: string, item: unknown) => {
19
+ if (!item || typeof item !== "object") return;
20
+ const object = item as Record<string, unknown>;
21
+ const value = object.value;
22
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
23
+ points.push({
24
+ series,
25
+ value,
26
+ at: firstString(object, ["calibrated_at", "timestamp", "created_at", "start_at"]),
27
+ unit: firstString(object, ["unit"]),
28
+ taskId: firstString(object, ["task_id"]),
29
+ executionId: firstString(object, ["execution_id"]),
30
+ });
31
+ };
32
+ const visit = (value: unknown, series = "series") => {
33
+ if (Array.isArray(value)) {
34
+ for (const item of value) addPoint(series, item);
35
+ return;
36
+ }
37
+ if (!value || typeof value !== "object") return;
38
+ const object = value as Record<string, unknown>;
39
+ if ("value" in object) {
40
+ addPoint(series, object);
41
+ return;
42
+ }
43
+ for (const [key, nested] of Object.entries(object)) visit(nested, key);
44
+ };
45
+ if (payload && typeof payload === "object" && "data" in payload) visit((payload as Record<string, unknown>).data);
46
+ else visit(payload);
47
+ return points.sort((a, b) => (a.at ?? "").localeCompare(b.at ?? ""));
48
+ }
49
+
50
+ function samplePoints(points: TimeseriesPoint[], width: number): TimeseriesPoint[] {
51
+ if (points.length <= width) return points;
52
+ const sampled: TimeseriesPoint[] = [];
53
+ for (let i = 0; i < width; i++) sampled.push(points[Math.round(i * (points.length - 1) / (width - 1))]);
54
+ return sampled;
55
+ }
56
+
57
+ export function plotSeriesLines(points: TimeseriesPoint[], options: { title: string; height?: number; width?: number; color?: boolean }): string[] {
58
+ const height = Math.max(3, Math.min(20, options.height ?? 8));
59
+ const plotWidth = Math.max(8, Math.min(100, options.width ?? 60));
60
+ const accent = (text: string) => options.color ? ansi("1;36", text) : text;
61
+ const muted = (text: string) => options.color ? ansi("90", text) : text;
62
+ if (points.length === 0) return boxed(options.title, [muted("no numeric data")], options.color);
63
+
64
+ const sampled = samplePoints(points, plotWidth);
65
+ const values = sampled.map((point) => point.value);
66
+ const min = Math.min(...values);
67
+ const max = Math.max(...values);
68
+ const span = max - min || 1;
69
+ const grid = Array.from({ length: height }, () => Array.from({ length: sampled.length }, () => " "));
70
+ sampled.forEach((point, col) => {
71
+ const row = height - 1 - Math.round((point.value - min) / span * (height - 1));
72
+ grid[Math.max(0, Math.min(height - 1, row))][col] = "●";
73
+ });
74
+ const yLabelWidth = Math.max(formatNumber(max).length, formatNumber(min).length, 6);
75
+ const rows = grid.map((row, index) => {
76
+ const value = max - (span * index / (height - 1));
77
+ return `${formatNumber(value).padStart(yLabelWidth)} ┤${row.join("")}`;
78
+ });
79
+ const unit = points.find((point) => point.unit)?.unit;
80
+ const first = points[0];
81
+ const last = points[points.length - 1];
82
+ const body = [
83
+ `${muted("series")} ${accent([...new Set(points.map((point) => point.series))].join(", "))}${unit ? ` ${muted("unit")} ${unit}` : ""}`,
84
+ `${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)}`,
85
+ `${muted("range")} ${compactDate(first.at)} → ${compactDate(last.at)}`,
86
+ "",
87
+ ...rows,
88
+ `${" ".repeat(yLabelWidth)} └${"─".repeat(sampled.length)}`,
89
+ ];
90
+ return boxed(options.title, body, options.color);
91
+ }
92
+
93
+ export function timeseriesPlotComponent(data: unknown, title: string, _theme: Theme) {
94
+ return {
95
+ render(width: number) {
96
+ const points = timeseriesPoints(data);
97
+ return plotSeriesLines(points, { title, width: Math.max(8, width - 16) }).map((line) => truncateToWidth(line, width));
98
+ },
99
+ invalidate() {},
100
+ };
101
+ }
@@ -0,0 +1,169 @@
1
+ export type AttenuationEntry = {
2
+ mux: string;
3
+ line: "control" | "readout_send";
4
+ totalDb: number;
5
+ components: number[];
6
+ panel?: string;
7
+ hardwarePort?: string;
8
+ qid?: string;
9
+ };
10
+
11
+ export type WiringInsights = {
12
+ entries: AttenuationEntry[];
13
+ control: { commonTotalDb?: number; anomalies: AttenuationEntry[]; totals: Record<string, number> };
14
+ readoutSend: { commonTotalDb?: number; anomalies: AttenuationEntry[]; totals: Record<string, number> };
15
+ suggestions: string[];
16
+ };
17
+
18
+ function ansi(code: string, text: string): string {
19
+ return `\u001b[${code}m${text}\u001b[0m`;
20
+ }
21
+
22
+ function boxed(title: string, body: string[], color = false): string[] {
23
+ const rawLines = [` ${title} `, ...body];
24
+ const width = Math.min(100, Math.max(...rawLines.map((line) => line.replace(/\u001b\[[0-9;]*m/g, "").length), title.length + 4));
25
+ const border = (text: string) => color ? ansi("90", text) : text;
26
+ const top = `${border("╭")}${color ? ansi("1;36", ` ${title} `) : ` ${title} `}${border("─".repeat(Math.max(0, width - title.length - 2)))}${border("╮")}`;
27
+ const bottom = border(`╰${"─".repeat(width)}╯`);
28
+ const middle = body.map((line) => `│ ${line}${" ".repeat(Math.max(0, width - line.replace(/\u001b\[[0-9;]*m/g, "").length - 2))} │`);
29
+ return [top, ...middle, bottom];
30
+ }
31
+
32
+ function markdownTableRows(markdown: string): string[][] {
33
+ return markdown.split("\n")
34
+ .map((line) => line.trim())
35
+ .filter((line) => line.startsWith("|") && line.endsWith("|"))
36
+ .map((line) => line.slice(1, -1).split("|").map((cell) => cell.trim()))
37
+ .filter((cells) => cells.length >= 7 && !cells.every((cell) => /^-+$/.test(cell.replace(/\s/g, ""))));
38
+ }
39
+
40
+ function expandPanelSpec(value: string): string[] {
41
+ return value.split(/[、,]/).flatMap((part) => {
42
+ const token = part.trim();
43
+ if (!token) return [];
44
+ const range = token.match(/^([A-Za-z]+)(\d+)\s*-\s*(\d+)$/);
45
+ if (range) {
46
+ const [, prefix, startText, endText] = range;
47
+ const start = Number(startText);
48
+ const end = Number(endText);
49
+ const step = start <= end ? 1 : -1;
50
+ const panels: string[] = [];
51
+ for (let value = start; step > 0 ? value <= end : value >= end; value += step) panels.push(`${prefix}${value}`);
52
+ return panels;
53
+ }
54
+ return [token];
55
+ });
56
+ }
57
+
58
+ function splitPortsAndPanels(cell: string): { ports: string[]; panels: string[] } {
59
+ const [portsText = cell, panelsText = ""] = cell.split(/\s+\/\s+/, 2);
60
+ return {
61
+ ports: portsText.split(/[、,]/).map((part) => part.trim()).filter(Boolean),
62
+ panels: expandPanelSpec(panelsText),
63
+ };
64
+ }
65
+
66
+ function attenuationNumbers(text: string): number[] {
67
+ return [...text.matchAll(/(?:追加\s*)?(\d+(?:\.\d+)?)\s*dB/gi)].map((match) => Number(match[1])).filter(Number.isFinite);
68
+ }
69
+
70
+ function panelAttenuationTotals(cell: string): Array<{ panels?: string[]; components: number[]; totalDb: number }> {
71
+ const parenthesized = [...cell.matchAll(/([^()]+)(([^)]+))/g)].map((match) => {
72
+ const components = attenuationNumbers(match[1]);
73
+ return { panels: expandPanelSpec(match[2]), components, totalDb: components.reduce((sum, value) => sum + value, 0) };
74
+ }).filter((segment) => segment.components.length > 0);
75
+ if (parenthesized.length > 0) return parenthesized;
76
+ const components = attenuationNumbers(cell);
77
+ return components.length > 0 ? [{ components, totalDb: components.reduce((sum, value) => sum + value, 0) }] : [];
78
+ }
79
+
80
+ function mostCommonTotal(entries: AttenuationEntry[]): number | undefined {
81
+ const counts = new Map<number, number>();
82
+ for (const entry of entries) counts.set(entry.totalDb, (counts.get(entry.totalDb) ?? 0) + 1);
83
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0];
84
+ }
85
+
86
+ function totalHistogram(entries: AttenuationEntry[]): Record<string, number> {
87
+ const histogram: Record<string, number> = {};
88
+ for (const entry of entries) histogram[String(entry.totalDb)] = (histogram[String(entry.totalDb)] ?? 0) + 1;
89
+ return histogram;
90
+ }
91
+
92
+ export function analyzeWiringMarkdown(markdown: string | undefined): WiringInsights | undefined {
93
+ if (!markdown) return undefined;
94
+ const rows = markdownTableRows(markdown);
95
+ const dataRows = rows.filter((cells) => /^\d+$/.test(cells[0]));
96
+ const entries: AttenuationEntry[] = [];
97
+ for (const cells of dataRows) {
98
+ const mux = cells[0].padStart(2, "0");
99
+ const muxNumber = Number(cells[0]);
100
+ const control = splitPortsAndPanels(cells[1] ?? "");
101
+ const controlSegments = panelAttenuationTotals(cells[5] ?? "");
102
+ for (let index = 0; index < control.panels.length; index++) {
103
+ const panel = control.panels[index];
104
+ const segment = controlSegments.find((item) => item.panels?.includes(panel)) ?? (controlSegments.length === 1 ? controlSegments[0] : undefined);
105
+ if (!segment) continue;
106
+ entries.push({
107
+ mux,
108
+ line: "control",
109
+ panel,
110
+ hardwarePort: control.ports[index],
111
+ qid: Number.isFinite(muxNumber) ? String(muxNumber * 4 + index) : undefined,
112
+ totalDb: segment.totalDb,
113
+ components: segment.components,
114
+ });
115
+ }
116
+ const readoutSegments = panelAttenuationTotals(cells[6] ?? "");
117
+ const readout = splitPortsAndPanels(cells[2] ?? "");
118
+ if (readoutSegments[0]) {
119
+ entries.push({
120
+ mux,
121
+ line: "readout_send",
122
+ panel: readout.panels[0],
123
+ hardwarePort: readout.ports[0],
124
+ totalDb: readoutSegments[0].totalDb,
125
+ components: readoutSegments[0].components,
126
+ });
127
+ }
128
+ }
129
+ const controlEntries = entries.filter((entry) => entry.line === "control");
130
+ const readoutEntries = entries.filter((entry) => entry.line === "readout_send");
131
+ const controlCommon = mostCommonTotal(controlEntries);
132
+ const readoutCommon = mostCommonTotal(readoutEntries);
133
+ const controlAnomalies = controlCommon === undefined ? [] : controlEntries.filter((entry) => entry.totalDb !== controlCommon);
134
+ const readoutAnomalies = readoutCommon === undefined ? [] : readoutEntries.filter((entry) => entry.totalDb !== readoutCommon);
135
+ const suggestions = [
136
+ controlCommon !== undefined ? `Control の最頻総減衰量は ${controlCommon} dB です。` : undefined,
137
+ controlAnomalies.length > 0
138
+ ? `Control で最頻値と異なるポートが ${controlAnomalies.length} 件あります: ${controlAnomalies.map((entry) => entry.qid ? `q${entry.qid}` : `${entry.mux}/${entry.panel}`).join(", ")}`
139
+ : controlEntries.length > 0 ? "Control の総減衰量は全ポートで揃っています。" : undefined,
140
+ readoutCommon !== undefined ? `Readout send の最頻総減衰量は ${readoutCommon} dB です。` : undefined,
141
+ readoutAnomalies.length > 0
142
+ ? `Readout send で最頻値と異なる MUX が ${readoutAnomalies.length} 件あります: ${readoutAnomalies.map((entry) => `MUX${entry.mux}`).join(", ")}`
143
+ : readoutEntries.length > 0 ? "Readout send の総減衰量は全 MUX で揃っています。" : undefined,
144
+ ].filter((line): line is string => Boolean(line));
145
+ return {
146
+ entries,
147
+ control: { commonTotalDb: controlCommon, anomalies: controlAnomalies, totals: totalHistogram(controlEntries) },
148
+ readoutSend: { commonTotalDb: readoutCommon, anomalies: readoutAnomalies, totals: totalHistogram(readoutEntries) },
149
+ suggestions,
150
+ };
151
+ }
152
+
153
+ export function wiringInsightLines(insights: WiringInsights, color = false): string[] {
154
+ const accent = (text: string) => color ? ansi("1;36", text) : text;
155
+ const muted = (text: string) => color ? ansi("90", text) : text;
156
+ const warning = (text: string) => color ? ansi("33", text) : text;
157
+ 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("+")})`;
158
+ return boxed("QDash Wiring Insights", [
159
+ ...insights.suggestions.map((line) => `• ${line}`),
160
+ "",
161
+ accent("Control totals"),
162
+ ` ${Object.entries(insights.control.totals).map(([total, count]) => `${total} dB × ${count}`).join(", ") || muted("none")}`,
163
+ ...(insights.control.anomalies.length > 0 ? ["", accent("Control anomalies"), ...insights.control.anomalies.map((entry) => ` ${formatEntry(entry)}`)] : []),
164
+ "",
165
+ accent("Readout send totals"),
166
+ ` ${Object.entries(insights.readoutSend.totals).map(([total, count]) => `${total} dB × ${count}`).join(", ") || muted("none")}`,
167
+ ...(insights.readoutSend.anomalies.length > 0 ? ["", accent("Readout send anomalies"), ...insights.readoutSend.anomalies.map((entry) => ` ${formatEntry(entry)}`)] : []),
168
+ ], color);
169
+ }
@@ -0,0 +1,35 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const WRITE_TOOL_NAMES = new Set([
4
+ "qdash_create_agent_session",
5
+ "qdash_submit_agent_action",
6
+ "qdash_commit_agent_candidate",
7
+ "qdash_execute_agent_action",
8
+ "qdash_commit_agent_campaign_candidates",
9
+ "qdash_apply_agent_candidate_commit",
10
+ "qdash_create_forum_post",
11
+ "qdash_update_forum_post",
12
+ "qdash_create_forum_evidence_reply",
13
+ ]);
14
+
15
+ export function installQDashWriteGate(pi: ExtensionAPI): void {
16
+ pi.on("tool_call", async (event, ctx) => {
17
+ if (!WRITE_TOOL_NAMES.has(event.toolName)) return;
18
+ const input = event.input as { confirmWrite?: boolean };
19
+ if (input.confirmWrite === true) return;
20
+
21
+ if (!ctx.hasUI) {
22
+ return {
23
+ block: true,
24
+ reason: `${event.toolName} is a QDash write operation and requires confirmWrite: true in non-interactive mode.`,
25
+ };
26
+ }
27
+
28
+ const ok = await ctx.ui.confirm(
29
+ "Approve QDash write operation?",
30
+ `${event.toolName} will create or modify data in QDash. Continue?`,
31
+ );
32
+ if (!ok) return { block: true, reason: "QDash write operation rejected by user" };
33
+ input.confirmWrite = true;
34
+ });
35
+ }