@oqtopus-team/pi-qdash 0.1.4 → 0.1.6

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 CHANGED
@@ -20,7 +20,8 @@ It uses [`@oqtopus-team/qdash-client`](https://www.npmjs.com/package/@oqtopus-te
20
20
  - **Dashboards and insights**: compact dashboards, triage overviews, target-level incident reports, degradation reports, and wiring insights.
21
21
  - **Agent calibration workflow**: create agent sessions, submit and track agent actions, and commit/apply calibration candidates. Write operations are approval-gated.
22
22
  - **Figures**: fetch calibration PNG/JSON figures and render them in the interactive TUI.
23
- - **Forum integration**: read Forum posts and publish evidence replies built from task results.
23
+ - **Generic timeseries comparison**: inspect and align arbitrary QDash parameters/targets with explicitly mapped local CSV sensor columns, reuse schema-validated investigation presets, and report correlations plus optional shared-period/phase evidence without hard-coded metrics or schemas.
24
+ - **Forum integration**: read Forum posts, publish task-result evidence, and upload locally generated analysis images to replies.
24
25
 
25
26
  ## Installation
26
27
 
@@ -42,7 +43,9 @@ Or try it without installing:
42
43
  pi -e npm:@oqtopus-team/pi-qdash
43
44
  ```
44
45
 
45
- Requires pi v0.74.0 or later.
46
+ Requires Node.js 20 or later and pi v0.74.0 or later. The extension tracks
47
+ `@oqtopus-team/qdash-client` 1.9.x and uses its authenticated high-level and
48
+ generated API clients.
46
49
 
47
50
  ## Quick Start
48
51
 
@@ -0,0 +1,82 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { basename, extname, resolve } from "node:path";
3
+
4
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
5
+ const MAX_IMAGES_PER_REPLY = 8;
6
+ const IMAGE_TYPES: Record<string, string> = {
7
+ ".gif": "image/gif",
8
+ ".jpeg": "image/jpeg",
9
+ ".jpg": "image/jpeg",
10
+ ".png": "image/png",
11
+ ".webp": "image/webp",
12
+ };
13
+
14
+ export type LocalForumImage = {
15
+ path: string;
16
+ filename: string;
17
+ mimeType: string;
18
+ sizeBytes: number;
19
+ blob: Blob;
20
+ };
21
+
22
+ export type UploadedForumImage = Omit<LocalForumImage, "blob"> & { url: string };
23
+
24
+ export function loadLocalForumImages(paths: string[]): LocalForumImage[] {
25
+ if (paths.length === 0) throw new Error("At least one image path is required");
26
+ if (paths.length > MAX_IMAGES_PER_REPLY) {
27
+ throw new Error(`A forum image reply supports at most ${MAX_IMAGES_PER_REPLY} images`);
28
+ }
29
+
30
+ const uniquePaths = [...new Set(paths.map((path) => resolve(path)))];
31
+ if (uniquePaths.length !== paths.length) throw new Error("Duplicate image paths are not allowed");
32
+
33
+ return uniquePaths.map((path) => {
34
+ const mimeType = IMAGE_TYPES[extname(path).toLowerCase()];
35
+ if (!mimeType) throw new Error(`Unsupported forum image type: ${path}`);
36
+ const stat = statSync(path);
37
+ if (!stat.isFile()) throw new Error(`Forum image path is not a file: ${path}`);
38
+ if (stat.size > MAX_IMAGE_BYTES) throw new Error(`Forum image exceeds 5 MB: ${path}`);
39
+ const bytes = readFileSync(path);
40
+ return {
41
+ path,
42
+ filename: basename(path),
43
+ mimeType,
44
+ sizeBytes: stat.size,
45
+ blob: new Blob([new Uint8Array(bytes)], { type: mimeType }),
46
+ };
47
+ });
48
+ }
49
+
50
+ export function forumImageReplyContent(
51
+ title: string,
52
+ images: Array<{ filename: string; url: string }>,
53
+ interpretation: string,
54
+ ): string {
55
+ return [
56
+ `## ${title}`,
57
+ "",
58
+ "分析で作成した画像を追加します。",
59
+ "",
60
+ ...images.flatMap((image) => [`![${image.filename}](${image.url})`, ""]),
61
+ interpretation.trim(),
62
+ "",
63
+ "— 🤖 by pi-qdash",
64
+ ].join("\n");
65
+ }
66
+
67
+ export function forumImageReplyPreview(
68
+ title: string,
69
+ images: Array<{ path: string; filename: string; sizeBytes: number }>,
70
+ interpretation: string,
71
+ ): string {
72
+ return [
73
+ `## ${title}`,
74
+ "",
75
+ "The following local images will be uploaded to QDash and embedded in a forum reply:",
76
+ ...images.map((image) => `- ${image.filename} (${(image.sizeBytes / 1024).toFixed(1)} KiB) — ${image.path}`),
77
+ "",
78
+ interpretation.trim(),
79
+ "",
80
+ "— 🤖 by pi-qdash",
81
+ ].join("\n");
82
+ }
@@ -0,0 +1,169 @@
1
+ import type { CsvTimeFormat, ExternalCsvSpec, TimeseriesTransformOptions } from "./timeseries-analysis.js";
2
+
3
+ export type InvestigationQDashSeries = {
4
+ parameter: string;
5
+ qid?: string;
6
+ tag?: string;
7
+ label?: string;
8
+ unit?: string;
9
+ scale?: number;
10
+ offset?: number;
11
+ };
12
+
13
+ export type TimeseriesInvestigationContext = {
14
+ name?: string;
15
+ startAt?: string;
16
+ endAt?: string;
17
+ timezoneOffsetMinutes?: number;
18
+ tag?: string;
19
+ qdashSeries?: InvestigationQDashSeries[];
20
+ csvSeries?: ExternalCsvSpec[];
21
+ analysis?: Omit<TimeseriesTransformOptions, "startAt" | "endAt" | "includeAlignedData">;
22
+ };
23
+
24
+ const TIME_FORMATS: CsvTimeFormat[] = ["iso", "yyyy/M/d H:mm", "yyyy-MM-dd HH:mm:ss", "unix-seconds", "unix-milliseconds"];
25
+
26
+ function optionalString(value: unknown, path: string): string | undefined {
27
+ if (value === undefined) return undefined;
28
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${path} must be a non-empty string`);
29
+ return value.trim();
30
+ }
31
+
32
+ function requiredString(value: unknown, path: string): string {
33
+ const result = optionalString(value, path);
34
+ if (result === undefined) throw new Error(`${path} is required`);
35
+ return result;
36
+ }
37
+
38
+ function optionalNumber(value: unknown, path: string): number | undefined {
39
+ if (value === undefined) return undefined;
40
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path} must be a finite number`);
41
+ return value;
42
+ }
43
+
44
+ function validateIso(value: string | undefined, path: string): void {
45
+ if (value !== undefined && !Number.isFinite(Date.parse(value))) throw new Error(`${path} must be a valid ISO timestamp`);
46
+ }
47
+
48
+ function validateQDashSeries(value: unknown): InvestigationQDashSeries[] | undefined {
49
+ if (value === undefined) return undefined;
50
+ if (!Array.isArray(value) || value.length > 12) throw new Error("investigation.qdashSeries must be an array with at most 12 entries");
51
+ return value.map((item, index) => {
52
+ if (!item || typeof item !== "object") throw new Error(`investigation.qdashSeries[${index}] must be an object`);
53
+ const input = item as Record<string, unknown>;
54
+ return {
55
+ parameter: requiredString(input.parameter, `investigation.qdashSeries[${index}].parameter`),
56
+ qid: optionalString(input.qid, `investigation.qdashSeries[${index}].qid`),
57
+ tag: optionalString(input.tag, `investigation.qdashSeries[${index}].tag`),
58
+ label: optionalString(input.label, `investigation.qdashSeries[${index}].label`),
59
+ unit: optionalString(input.unit, `investigation.qdashSeries[${index}].unit`),
60
+ scale: optionalNumber(input.scale, `investigation.qdashSeries[${index}].scale`),
61
+ offset: optionalNumber(input.offset, `investigation.qdashSeries[${index}].offset`),
62
+ };
63
+ });
64
+ }
65
+
66
+ function validateCsvSeries(value: unknown): ExternalCsvSpec[] | undefined {
67
+ if (value === undefined) return undefined;
68
+ if (!Array.isArray(value) || value.length > 8) throw new Error("investigation.csvSeries must be an array with at most 8 entries");
69
+ return value.map((item, index) => {
70
+ if (!item || typeof item !== "object") throw new Error(`investigation.csvSeries[${index}] must be an object`);
71
+ const input = item as Record<string, unknown>;
72
+ if (!Array.isArray(input.valueColumns) || input.valueColumns.length < 1 || input.valueColumns.length > 12) {
73
+ throw new Error(`investigation.csvSeries[${index}].valueColumns must contain 1 to 12 entries`);
74
+ }
75
+ const timeFormat = optionalString(input.timeFormat, `investigation.csvSeries[${index}].timeFormat`) as CsvTimeFormat | undefined;
76
+ if (timeFormat && !TIME_FORMATS.includes(timeFormat)) throw new Error(`Unsupported investigation.csvSeries[${index}].timeFormat: ${timeFormat}`);
77
+ const filters = input.filters === undefined ? undefined : input.filters;
78
+ if (filters !== undefined && (!filters || typeof filters !== "object" || Array.isArray(filters))) {
79
+ throw new Error(`investigation.csvSeries[${index}].filters must be an object of string arrays`);
80
+ }
81
+ const validatedFilters = filters === undefined ? undefined : Object.fromEntries(Object.entries(filters as Record<string, unknown>).map(([column, accepted]) => {
82
+ if (!Array.isArray(accepted) || !accepted.every((entry) => typeof entry === "string")) throw new Error(`investigation.csvSeries[${index}].filters.${column} must be a string array`);
83
+ return [column, accepted];
84
+ }));
85
+ return {
86
+ path: requiredString(input.path, `investigation.csvSeries[${index}].path`),
87
+ timeColumn: requiredString(input.timeColumn, `investigation.csvSeries[${index}].timeColumn`),
88
+ timeFormat,
89
+ timezoneOffsetMinutes: optionalNumber(input.timezoneOffsetMinutes, `investigation.csvSeries[${index}].timezoneOffsetMinutes`),
90
+ delimiter: optionalString(input.delimiter, `investigation.csvSeries[${index}].delimiter`),
91
+ skipRows: optionalNumber(input.skipRows, `investigation.csvSeries[${index}].skipRows`),
92
+ filters: validatedFilters,
93
+ valueColumns: input.valueColumns.map((entry, columnIndex) => {
94
+ if (!entry || typeof entry !== "object") throw new Error(`investigation.csvSeries[${index}].valueColumns[${columnIndex}] must be an object`);
95
+ const column = entry as Record<string, unknown>;
96
+ return {
97
+ column: requiredString(column.column, `investigation.csvSeries[${index}].valueColumns[${columnIndex}].column`),
98
+ label: optionalString(column.label, `investigation.csvSeries[${index}].valueColumns[${columnIndex}].label`),
99
+ unit: optionalString(column.unit, `investigation.csvSeries[${index}].valueColumns[${columnIndex}].unit`),
100
+ scale: optionalNumber(column.scale, `investigation.csvSeries[${index}].valueColumns[${columnIndex}].scale`),
101
+ offset: optionalNumber(column.offset, `investigation.csvSeries[${index}].valueColumns[${columnIndex}].offset`),
102
+ };
103
+ }),
104
+ };
105
+ });
106
+ }
107
+
108
+ function validateAnalysis(value: unknown): TimeseriesInvestigationContext["analysis"] {
109
+ if (value === undefined) return undefined;
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("investigation.analysis must be an object");
111
+ const input = value as Record<string, unknown>;
112
+ const detrend = optionalString(input.detrend, "investigation.analysis.detrend");
113
+ const normalize = optionalString(input.normalize, "investigation.analysis.normalize");
114
+ if (detrend && detrend !== "none" && detrend !== "linear") throw new Error("investigation.analysis.detrend must be none or linear");
115
+ if (normalize && normalize !== "none" && normalize !== "zscore") throw new Error("investigation.analysis.normalize must be none or zscore");
116
+ let periodSearch: { minMinutes: number; maxMinutes: number; stepMinutes?: number } | undefined;
117
+ if (input.periodSearch !== undefined) {
118
+ if (!input.periodSearch || typeof input.periodSearch !== "object" || Array.isArray(input.periodSearch)) throw new Error("investigation.analysis.periodSearch must be an object");
119
+ const period = input.periodSearch as Record<string, unknown>;
120
+ const minMinutes = optionalNumber(period.minMinutes, "investigation.analysis.periodSearch.minMinutes");
121
+ const maxMinutes = optionalNumber(period.maxMinutes, "investigation.analysis.periodSearch.maxMinutes");
122
+ if (minMinutes === undefined || maxMinutes === undefined || minMinutes <= 0 || maxMinutes <= minMinutes) throw new Error("investigation.analysis.periodSearch requires 0 < minMinutes < maxMinutes");
123
+ periodSearch = { minMinutes, maxMinutes, stepMinutes: optionalNumber(period.stepMinutes, "investigation.analysis.periodSearch.stepMinutes") };
124
+ }
125
+ const analysis: TimeseriesInvestigationContext["analysis"] = {
126
+ resampleMinutes: optionalNumber(input.resampleMinutes, "investigation.analysis.resampleMinutes"),
127
+ smoothingWindowMinutes: optionalNumber(input.smoothingWindowMinutes, "investigation.analysis.smoothingWindowMinutes"),
128
+ detrend: detrend as "none" | "linear" | undefined,
129
+ normalize: normalize as "none" | "zscore" | undefined,
130
+ maxInterpolationGapMinutes: optionalNumber(input.maxInterpolationGapMinutes, "investigation.analysis.maxInterpolationGapMinutes"),
131
+ periodSearch,
132
+ };
133
+ if (analysis.resampleMinutes !== undefined && analysis.resampleMinutes <= 0) throw new Error("investigation.analysis.resampleMinutes must be greater than zero");
134
+ if (analysis.smoothingWindowMinutes !== undefined && analysis.smoothingWindowMinutes < 0) throw new Error("investigation.analysis.smoothingWindowMinutes must not be negative");
135
+ if (analysis.maxInterpolationGapMinutes !== undefined && analysis.maxInterpolationGapMinutes <= 0) throw new Error("investigation.analysis.maxInterpolationGapMinutes must be greater than zero");
136
+ if (analysis.periodSearch?.stepMinutes !== undefined && analysis.periodSearch.stepMinutes <= 0) throw new Error("investigation.analysis.periodSearch.stepMinutes must be greater than zero");
137
+ return analysis;
138
+ }
139
+
140
+ export function validateInvestigationContext(value: unknown): TimeseriesInvestigationContext {
141
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("investigation context must be a JSON object");
142
+ const input = value as Record<string, unknown>;
143
+ const context: TimeseriesInvestigationContext = {
144
+ name: optionalString(input.name, "investigation.name"),
145
+ startAt: optionalString(input.startAt, "investigation.startAt"),
146
+ endAt: optionalString(input.endAt, "investigation.endAt"),
147
+ timezoneOffsetMinutes: optionalNumber(input.timezoneOffsetMinutes, "investigation.timezoneOffsetMinutes"),
148
+ tag: optionalString(input.tag, "investigation.tag"),
149
+ qdashSeries: validateQDashSeries(input.qdashSeries),
150
+ csvSeries: validateCsvSeries(input.csvSeries),
151
+ analysis: validateAnalysis(input.analysis),
152
+ };
153
+ validateIso(context.startAt, "investigation.startAt");
154
+ validateIso(context.endAt, "investigation.endAt");
155
+ if (context.startAt && context.endAt && Date.parse(context.startAt) >= Date.parse(context.endAt)) throw new Error("investigation.startAt must be before endAt");
156
+ if (context.timezoneOffsetMinutes !== undefined && (!Number.isInteger(context.timezoneOffsetMinutes) || Math.abs(context.timezoneOffsetMinutes) > 14 * 60)) {
157
+ throw new Error("investigation.timezoneOffsetMinutes must be an integer from -840 to 840");
158
+ }
159
+ if ((context.qdashSeries?.length ?? 0) + (context.csvSeries?.flatMap((series) => series.valueColumns).length ?? 0) < 2) {
160
+ throw new Error("investigation context must define at least two QDash/CSV value series");
161
+ }
162
+ return context;
163
+ }
164
+
165
+ export function investigationContextSummary(context: TimeseriesInvestigationContext): string {
166
+ const qdashCount = context.qdashSeries?.length ?? 0;
167
+ const csvCount = context.csvSeries?.reduce((sum, series) => sum + series.valueColumns.length, 0) ?? 0;
168
+ return `${context.name ?? "unnamed"}: ${qdashCount + csvCount} series, ${context.startAt ?? "auto-start"} → ${context.endAt ?? "auto-end"}${context.tag ? `, tag ${context.tag}` : ""}`;
169
+ }