@oqtopus-team/pi-qdash 0.1.4 → 0.1.5

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.
@@ -10,13 +10,16 @@ import { Type } from "typebox";
10
10
 
11
11
  import { analyzePlotlyFigure, figureAnalysisText } from "./lib/figure-analysis.js";
12
12
  import { fetchFigureDetails, figureComponent, figureResultText, mediaTypeForPath, type FigureDetails } from "./lib/figures.js";
13
+ import { forumImageReplyContent, forumImageReplyPreview, loadLocalForumImages, type LocalForumImage } from "./lib/forum-images.js";
13
14
  import { forumDetailComponent, forumDetailLines, forumListLines, forumPostTitle, forumPostsFromPayload } from "./lib/forum-render.js";
15
+ import { investigationContextSummary, validateInvestigationContext, type TimeseriesInvestigationContext } from "./lib/investigation-context.js";
14
16
  import { qdashObjectLinks, qdashWebBaseUrl, qdashWebUrl, safeConfig, withQDashLinks } from "./lib/links.js";
15
17
  import { arrayFromPayload, compactItems, firstNumber, firstString, formatItem, payloadTotal, statusIcon } from "./lib/payload.js";
16
18
  import { toTextToolResult, toToolResult } from "./lib/results.js";
17
19
  import { ansi, boxLinesToWidth, boxed, formatNumber, textComponent } from "./lib/render.js";
18
20
  import { plotSeriesLines, timeseriesPlotComponent, timeseriesPoints } from "./lib/timeseries-plot.js";
19
21
  import { installQDashWriteGate } from "./lib/write-gate.js";
22
+ import { registerTimeseriesComparisonTool } from "./tools/timeseries.js";
20
23
  import { analyzeWiringMarkdown, wiringInsightLines, type WiringInsights } from "./lib/wiring-analysis.js";
21
24
 
22
25
  type QDashQueryParams = {
@@ -131,6 +134,7 @@ type QDashContextState = {
131
134
  taskName?: string;
132
135
  lastExecutionId?: string;
133
136
  lastTaskId?: string;
137
+ investigation?: TimeseriesInvestigationContext;
134
138
  };
135
139
 
136
140
  const CONTEXT_ENTRY_TYPE = "qdash-context";
@@ -151,7 +155,8 @@ function contextSummary(): string {
151
155
  const chip = currentContext.chipId ?? "auto-chip";
152
156
  const session = currentContext.agentSessionId ? ` session:${shortId(currentContext.agentSessionId)}` : "";
153
157
  const target = currentContext.qid ? ` q${currentContext.qid}` : currentContext.couplingId ? ` c${currentContext.couplingId}` : "";
154
- return `qdash ${profile} ${chip}${target}${session}`;
158
+ const investigation = currentContext.investigation?.name ? ` investigation:${currentContext.investigation.name}` : "";
159
+ return `qdash ${profile} ${chip}${target}${session}${investigation}`;
155
160
  }
156
161
 
157
162
  function contextStatusLine(theme?: Theme): string {
@@ -179,8 +184,16 @@ function contextStatusLine(theme?: Theme): string {
179
184
  function isQDashContextState(value: unknown): value is QDashContextState {
180
185
  if (!value || typeof value !== "object") return false;
181
186
  const context = value as QDashContextState;
182
- return [context.profile, context.chipId, context.agentSessionId, context.qid, context.couplingId, context.taskName, context.lastExecutionId, context.lastTaskId]
187
+ const stringsValid = [context.profile, context.chipId, context.agentSessionId, context.qid, context.couplingId, context.taskName, context.lastExecutionId, context.lastTaskId]
183
188
  .every((item) => item === undefined || typeof item === "string");
189
+ if (!stringsValid) return false;
190
+ if (context.investigation === undefined) return true;
191
+ try {
192
+ validateInvestigationContext(context.investigation);
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
184
197
  }
185
198
 
186
199
  function loadGlobalContext(): QDashContextState {
@@ -194,8 +207,9 @@ function loadGlobalContext(): QDashContextState {
194
207
  }
195
208
 
196
209
  function saveGlobalContext(context: QDashContextState): void {
210
+ const { investigation: _investigation, ...globalContext } = context;
197
211
  mkdirSync(dirname(GLOBAL_CONTEXT_PATH), { recursive: true });
198
- writeFileSync(GLOBAL_CONTEXT_PATH, `${JSON.stringify(context, null, 2)}\n`, "utf8");
212
+ writeFileSync(GLOBAL_CONTEXT_PATH, `${JSON.stringify(globalContext, null, 2)}\n`, "utf8");
199
213
  }
200
214
 
201
215
  function adoptContextFromToolInput(input: unknown): boolean {
@@ -1340,6 +1354,56 @@ function taskFigurePaths(task: Record<string, unknown>, maxFigures: number): str
1340
1354
  return Array.isArray(paths) ? paths.filter((item): item is string => typeof item === "string").slice(0, maxFigures) : [];
1341
1355
  }
1342
1356
 
1357
+ type ForumImageReplyParams = {
1358
+ parentPostId: string;
1359
+ imagePaths: string[];
1360
+ interpretation: string;
1361
+ title?: string;
1362
+ };
1363
+
1364
+ type PreparedForumImageReply = {
1365
+ parent: Record<string, unknown>;
1366
+ images: LocalForumImage[];
1367
+ title: string;
1368
+ preview: string;
1369
+ };
1370
+
1371
+ async function prepareForumImageReply(client: QDashClient, params: ForumImageReplyParams): Promise<PreparedForumImageReply> {
1372
+ const parent = await client.getForumPost(params.parentPostId) as unknown as Record<string, unknown>;
1373
+ const images = loadLocalForumImages(params.imagePaths);
1374
+ const title = params.title ?? `${new Date().toISOString().slice(0, 10)} 追加分析画像`;
1375
+ const preview = forumImageReplyPreview(title, images, params.interpretation);
1376
+ return { parent, images, title, preview };
1377
+ }
1378
+
1379
+ async function createForumImageReply(client: QDashClient, params: ForumImageReplyParams, prepared: PreparedForumImageReply) {
1380
+ const uploaded = [];
1381
+ for (const image of prepared.images) {
1382
+ const result = await client.uploadForumImage(image.blob, image.filename);
1383
+ uploaded.push({ ...image, url: result.url });
1384
+ }
1385
+ const content = forumImageReplyContent(prepared.title, uploaded, params.interpretation);
1386
+ const parent = prepared.parent;
1387
+ const request = {
1388
+ category: firstString(parent, ["category"]) ?? "other",
1389
+ title: null,
1390
+ content,
1391
+ content_blocks: [],
1392
+ parent_id: params.parentPostId,
1393
+ chip_id: firstString(parent, ["chip_id", "chipId"]),
1394
+ target_type: firstString(parent, ["target_type", "targetType"]),
1395
+ target_id: firstString(parent, ["target_id", "targetId"]),
1396
+ cooldown_id: firstString(parent, ["cooldown_id", "cooldownId"]),
1397
+ status: firstString(parent, ["status"]) ?? "open",
1398
+ };
1399
+ const reply = await client.createForumPost(request as never);
1400
+ return {
1401
+ reply,
1402
+ content,
1403
+ images: uploaded.map(({ blob: _blob, ...image }) => image),
1404
+ };
1405
+ }
1406
+
1343
1407
  async function buildForumEvidenceReply(client: QDashClient, params: ForumEvidenceReplyParams) {
1344
1408
  const [parent, task] = await Promise.all([
1345
1409
  client.getForumPost(params.parentPostId) as unknown as Promise<Record<string, unknown>>,
@@ -1728,6 +1792,12 @@ export default function qdashExtension(pi: ExtensionAPI) {
1728
1792
  },
1729
1793
  });
1730
1794
 
1795
+ registerTimeseriesComparisonTool(pi, {
1796
+ makeClient,
1797
+ defaultChipId,
1798
+ investigationContext: () => currentContext.investigation,
1799
+ });
1800
+
1731
1801
  registerQueryTool({
1732
1802
  name: "qdash_list_task_results",
1733
1803
  label: "QDash List Task Results",
@@ -2239,6 +2309,65 @@ export default function qdashExtension(pi: ExtensionAPI) {
2239
2309
  },
2240
2310
  });
2241
2311
 
2312
+ pi.registerTool({
2313
+ name: "qdash_preview_forum_image_reply",
2314
+ label: "QDash Preview Forum Image Reply",
2315
+ description: "Preview a QDash forum reply that will upload and embed locally generated analysis images. Read-only; no image or post is created.",
2316
+ promptSnippet: "Preview local analysis images and forum reply content before uploading",
2317
+ promptGuidelines: [
2318
+ "Use qdash_preview_forum_image_reply before uploading locally generated analysis images to a forum thread.",
2319
+ "Show the image paths and reply content, then require explicit confirmation before publishing.",
2320
+ ],
2321
+ parameters: Type.Object({
2322
+ ...connectionParams,
2323
+ parentPostId: Type.String(),
2324
+ imagePaths: Type.Array(Type.String(), { minItems: 1, maxItems: 8 }),
2325
+ interpretation: Type.String({ description: "Human-readable interpretation or context shown after the images." }),
2326
+ title: Type.Optional(Type.String()),
2327
+ }),
2328
+ async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; parentPostId: string; imagePaths: string[]; interpretation: string; title?: string }) {
2329
+ const client = await makeClient(params);
2330
+ const prepared = await prepareForumImageReply(client, params);
2331
+ return toTextToolResult(prepared.preview, {
2332
+ preview: prepared.preview,
2333
+ parent: prepared.parent,
2334
+ images: prepared.images.map(({ blob: _blob, ...image }) => image),
2335
+ }, { tool: "qdash_preview_forum_image_reply" });
2336
+ },
2337
+ });
2338
+
2339
+ pi.registerTool({
2340
+ name: "qdash_create_forum_image_reply",
2341
+ label: "QDash Create Forum Image Reply",
2342
+ description: "Upload locally generated analysis images and create a QDash forum reply embedding them. This is a write operation and requires confirmation.",
2343
+ promptSnippet: "Upload local analysis images and create a forum reply after explicit confirmation",
2344
+ promptGuidelines: [
2345
+ "Use qdash_create_forum_image_reply only after previewing the exact image paths and reply content with qdash_preview_forum_image_reply.",
2346
+ "Require explicit confirmation before uploading images or creating the reply.",
2347
+ ],
2348
+ parameters: Type.Object({
2349
+ ...connectionParams,
2350
+ parentPostId: Type.String(),
2351
+ imagePaths: Type.Array(Type.String(), { minItems: 1, maxItems: 8 }),
2352
+ interpretation: Type.String({ description: "Human-readable interpretation or context shown after the images." }),
2353
+ title: Type.Optional(Type.String()),
2354
+ confirmWrite: Type.Optional(Type.Boolean()),
2355
+ }),
2356
+ async execute(_toolCallId, params: ConfirmableParams & { profile?: string; configPath?: string; useEnv?: boolean; parentPostId: string; imagePaths: string[]; interpretation: string; title?: string }, _signal, _onUpdate, ctx) {
2357
+ const client = await makeClient(params);
2358
+ const prepared = await prepareForumImageReply(client, params);
2359
+ if (!params.confirmWrite) {
2360
+ const confirmed = ctx.hasUI && await ctx.ui.confirm("Upload images and create QDash forum reply?", prepared.preview.slice(0, 1800));
2361
+ if (!confirmed) throw new Error("qdash_create_forum_image_reply requires explicit confirmation");
2362
+ }
2363
+ const created = await createForumImageReply(client, params, prepared);
2364
+ const postId = firstString(created.reply as unknown as Record<string, unknown>, ["id"]);
2365
+ const parentUrl = qdashWebUrl(client, `/forum/posts/${encodeURIComponent(params.parentPostId)}`);
2366
+ const text = [`created forum image reply${postId ? ` ${postId}` : ""}`, `parent ${parentUrl}`, "", created.content].join("\n");
2367
+ return toTextToolResult(text, created, { tool: "qdash_create_forum_image_reply", url: parentUrl });
2368
+ },
2369
+ });
2370
+
2242
2371
  pi.registerTool({
2243
2372
  name: "qdash_create_forum_evidence_reply",
2244
2373
  label: "QDash Create Forum Evidence Reply",
@@ -3067,8 +3196,49 @@ export default function qdashExtension(pi: ExtensionAPI) {
3067
3196
  },
3068
3197
  });
3069
3198
 
3199
+ pi.registerCommand("qdash-investigation-setup", {
3200
+ description: "Create or edit a reusable generic timeseries investigation context; accepts optional JSON",
3201
+ handler: async (args, ctx) => {
3202
+ const template: TimeseriesInvestigationContext = currentContext.investigation ?? {
3203
+ name: "timeseries-investigation",
3204
+ timezoneOffsetMinutes: 540,
3205
+ qdashSeries: currentContext.qid ? [
3206
+ { parameter: "metric_a", qid: currentContext.qid },
3207
+ { parameter: "metric_b", qid: currentContext.qid },
3208
+ ] : [
3209
+ { parameter: "metric_a" },
3210
+ { parameter: "metric_b" },
3211
+ ],
3212
+ analysis: { resampleMinutes: 1, detrend: "none", normalize: "none" },
3213
+ };
3214
+ const source = args.trim() || await ctx.ui.editor("QDash investigation context (JSON)", JSON.stringify(template, null, 2));
3215
+ if (!source) return;
3216
+ try {
3217
+ const investigation = validateInvestigationContext(JSON.parse(source));
3218
+ currentContext = { ...currentContext, investigation };
3219
+ persistContext();
3220
+ refreshContextUi(ctx);
3221
+ ctx.ui.setWidget("qdash", ["QDash Investigation", investigationContextSummary(investigation)]);
3222
+ ctx.ui.notify(`Investigation context saved: ${investigationContextSummary(investigation)}`, "info");
3223
+ } catch (error) {
3224
+ ctx.ui.notify(`Invalid investigation context: ${error instanceof Error ? error.message : String(error)}`, "error");
3225
+ }
3226
+ },
3227
+ });
3228
+
3229
+ pi.registerCommand("qdash-clear-investigation", {
3230
+ description: "Clear the reusable timeseries investigation context without changing profile/chip context",
3231
+ handler: async (_args, ctx) => {
3232
+ const { investigation: _investigation, ...rest } = currentContext;
3233
+ currentContext = rest;
3234
+ persistContext();
3235
+ refreshContextUi(ctx);
3236
+ ctx.ui.notify("QDash investigation context cleared", "info");
3237
+ },
3238
+ });
3239
+
3070
3240
  pi.registerCommand("qdash-clear-context", {
3071
- description: "Clear the current QDash profile/chip/agent-session context",
3241
+ description: "Clear the current QDash profile/chip/agent-session/investigation context",
3072
3242
  handler: async (_args, ctx) => {
3073
3243
  currentContext = {};
3074
3244
  persistContext();
@@ -3180,6 +3350,7 @@ export default function qdashExtension(pi: ExtensionAPI) {
3180
3350
  `last execution ${currentContext.lastExecutionId ?? "none"}`,
3181
3351
  `last task ${currentContext.lastTaskId ?? "none"}`,
3182
3352
  `agent session ${currentContext.agentSessionId ?? "none"}`,
3353
+ `investigation ${currentContext.investigation ? investigationContextSummary(currentContext.investigation) : "none"}`,
3183
3354
  ]);
3184
3355
  ctx.ui.notify(contextSummary(), "info");
3185
3356
  },
@@ -0,0 +1,211 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { QDashClient } from "@oqtopus-team/qdash-client";
3
+ import { Type } from "typebox";
4
+
5
+ import {
6
+ compareTimeseries,
7
+ csvInspectionText,
8
+ inspectExternalCsv,
9
+ loadExternalCsvSeries,
10
+ timeseriesComparisonText,
11
+ type CsvTimeFormat,
12
+ type ExternalCsvSpec,
13
+ type NumericTimeseries,
14
+ } from "../lib/timeseries-analysis.js";
15
+ import type { TimeseriesInvestigationContext } from "../lib/investigation-context.js";
16
+ import { toTextToolResult } from "../lib/results.js";
17
+ import { timeseriesPoints } from "../lib/timeseries-plot.js";
18
+
19
+ type ComparisonToolParams = {
20
+ profile?: string;
21
+ configPath?: string;
22
+ useEnv?: boolean;
23
+ chipId?: string;
24
+ startAt?: string;
25
+ endAt?: string;
26
+ withinHours?: number;
27
+ qdashSeries?: Array<{ parameter: string; qid?: string; tag?: string; label?: string; unit?: string; scale?: number; offset?: number }>;
28
+ csvSeries?: Array<ExternalCsvSpec & { timeFormat?: CsvTimeFormat }>;
29
+ resampleMinutes?: number;
30
+ smoothingWindowMinutes?: number;
31
+ detrend?: string;
32
+ normalize?: string;
33
+ maxInterpolationGapMinutes?: number;
34
+ periodSearch?: { minMinutes: number; maxMinutes: number; stepMinutes?: number };
35
+ includeAlignedData?: boolean;
36
+ useInvestigationContext?: boolean;
37
+ };
38
+
39
+ export type TimeseriesToolDependencies = {
40
+ makeClient: (params: { profile?: string; configPath?: string; useEnv?: boolean }) => Promise<QDashClient>;
41
+ defaultChipId: (client: QDashClient, chipId?: string) => Promise<string>;
42
+ investigationContext?: () => TimeseriesInvestigationContext | undefined;
43
+ };
44
+
45
+ export function registerTimeseriesComparisonTool(pi: ExtensionAPI, dependencies: TimeseriesToolDependencies): void {
46
+ pi.registerTool({
47
+ name: "qdash_inspect_timeseries_csv",
48
+ label: "QDash Inspect Timeseries CSV",
49
+ description: "Inspect a local CSV schema and numeric/time characteristics before mapping it into a QDash timeseries comparison. Read-only; never uploads the file.",
50
+ promptSnippet: "Inspect local CSV columns, numeric ranges, timestamps, and cadence before comparison",
51
+ promptGuidelines: [
52
+ "Use qdash_inspect_timeseries_csv before qdash_compare_timeseries when a local CSV schema, timestamp format, timezone, or numeric columns are not already known.",
53
+ "Do not infer the timezone of naive timestamps from qdash_inspect_timeseries_csv samples; ask for or supply an explicit timezone offset.",
54
+ ],
55
+ parameters: Type.Object({
56
+ path: Type.String({ description: "Local CSV path, absolute or relative to the current working directory." }),
57
+ delimiter: Type.Optional(Type.String({ description: "Single-character delimiter. Defaults to comma." })),
58
+ skipRows: Type.Optional(Type.Number({ description: "Rows before the header. Defaults to 0." })),
59
+ timeColumn: Type.Optional(Type.String({ description: "Optional timestamp column to validate." })),
60
+ timeFormat: Type.Optional(Type.String({ description: "iso, yyyy/M/d H:mm, yyyy-MM-dd HH:mm:ss, unix-seconds, or unix-milliseconds." })),
61
+ timezoneOffsetMinutes: Type.Optional(Type.Number({ description: "Required to validate timestamps without an explicit zone; JST is 540." })),
62
+ scanRows: Type.Optional(Type.Number({ description: "Rows to profile, from 1 to 100000. Defaults to 10000." })),
63
+ }),
64
+ async execute(_toolCallId, params: {
65
+ path: string;
66
+ delimiter?: string;
67
+ skipRows?: number;
68
+ timeColumn?: string;
69
+ timeFormat?: CsvTimeFormat;
70
+ timezoneOffsetMinutes?: number;
71
+ scanRows?: number;
72
+ }, _signal, _onUpdate, ctx) {
73
+ const inspection = inspectExternalCsv(params, ctx.cwd);
74
+ return toTextToolResult(csvInspectionText(inspection), inspection, {
75
+ tool: "qdash_inspect_timeseries_csv",
76
+ path: inspection.path,
77
+ });
78
+ },
79
+ });
80
+
81
+ pi.registerTool({
82
+ name: "qdash_compare_timeseries",
83
+ label: "QDash Compare Timeseries",
84
+ description: "Compare two or more QDash and/or local CSV numeric timeseries on a common time grid. Read-only; reports alignment, correlations, optional shared-period fits, phases, and statistical caveats without inferring causality.",
85
+ promptSnippet: "Compare generic QDash metrics and external CSV sensor timeseries",
86
+ promptGuidelines: [
87
+ "Use qdash_compare_timeseries when comparing multiple QDash metrics, targets, or local CSV sensor logs over time.",
88
+ "For qdash_compare_timeseries local CSV inputs, explicitly specify timestamp format and timezone offset when timestamps have no zone.",
89
+ "Treat qdash_compare_timeseries correlations and shared periods as evidence of association, not proof of causality.",
90
+ ],
91
+ parameters: Type.Object({
92
+ profile: Type.Optional(Type.String({ description: "QDash profile name. Defaults to env when QDASH_BASE_URL is set, otherwise 'default'." })),
93
+ configPath: Type.Optional(Type.String({ description: "Optional path to qdash config.ini." })),
94
+ useEnv: Type.Optional(Type.Boolean({ description: "Force QDASH_* environment variables instead of a profile." })),
95
+ chipId: Type.Optional(Type.String({ description: "Chip ID. Defaults to the active/default chip when omitted." })),
96
+ startAt: Type.Optional(Type.String({ description: "Optional common start timestamp as an ISO timestamp with timezone." })),
97
+ endAt: Type.Optional(Type.String({ description: "Optional common end timestamp as an ISO timestamp with timezone." })),
98
+ withinHours: Type.Optional(Type.Number({ description: "QDash lookback when startAt is omitted. Defaults to 168 hours." })),
99
+ useInvestigationContext: Type.Optional(Type.Boolean({ description: "Use missing series/window/transform defaults from the active investigation context. Defaults to true." })),
100
+ qdashSeries: Type.Optional(Type.Array(Type.Object({
101
+ parameter: Type.String({ description: "QDash task-result parameter name." }),
102
+ qid: Type.Optional(Type.String()),
103
+ tag: Type.Optional(Type.String()),
104
+ label: Type.Optional(Type.String({ description: "Unique display label. Defaults to parameter plus qid/returned series." })),
105
+ unit: Type.Optional(Type.String({ description: "Optional output unit override." })),
106
+ scale: Type.Optional(Type.Number({ description: "Multiply QDash values by this factor. Defaults to 1." })),
107
+ offset: Type.Optional(Type.Number({ description: "Add this value after scaling. Defaults to 0." })),
108
+ }), { maxItems: 12 })),
109
+ csvSeries: Type.Optional(Type.Array(Type.Object({
110
+ path: Type.String({ description: "Local CSV path, absolute or relative to the current working directory." }),
111
+ timeColumn: Type.String(),
112
+ valueColumns: Type.Array(Type.Object({
113
+ column: Type.String(),
114
+ label: Type.Optional(Type.String()),
115
+ unit: Type.Optional(Type.String()),
116
+ scale: Type.Optional(Type.Number()),
117
+ offset: Type.Optional(Type.Number()),
118
+ }), { minItems: 1, maxItems: 12 }),
119
+ timeFormat: Type.Optional(Type.String({ description: "iso, yyyy/M/d H:mm, yyyy-MM-dd HH:mm:ss, unix-seconds, or unix-milliseconds. Defaults to iso." })),
120
+ timezoneOffsetMinutes: Type.Optional(Type.Number({ description: "Required for timestamps without an explicit zone; for JST use 540." })),
121
+ delimiter: Type.Optional(Type.String({ description: "Single-character delimiter. Defaults to comma." })),
122
+ skipRows: Type.Optional(Type.Number({ description: "Rows to skip before the header. Defaults to 0." })),
123
+ filters: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()), { description: "Optional exact-match row filters keyed by column name." })),
124
+ }), { maxItems: 8 })),
125
+ resampleMinutes: Type.Optional(Type.Number({ description: "Common grid interval. Defaults to 1 minute." })),
126
+ smoothingWindowMinutes: Type.Optional(Type.Number({ description: "Centered moving-average window. Zero disables smoothing." })),
127
+ detrend: Type.Optional(Type.String({ description: "none or linear. Defaults to none." })),
128
+ normalize: Type.Optional(Type.String({ description: "none or zscore. Defaults to none." })),
129
+ maxInterpolationGapMinutes: Type.Optional(Type.Number({ description: "Do not interpolate across larger source gaps." })),
130
+ periodSearch: Type.Optional(Type.Object({
131
+ minMinutes: Type.Number(),
132
+ maxMinutes: Type.Number(),
133
+ stepMinutes: Type.Optional(Type.Number()),
134
+ })),
135
+ includeAlignedData: Type.Optional(Type.Boolean({ description: "Include transformed aligned rows in structured details. Defaults to false." })),
136
+ }),
137
+ async execute(_toolCallId, params: ComparisonToolParams, _signal, _onUpdate, ctx) {
138
+ const investigation = params.useInvestigationContext === false ? undefined : dependencies.investigationContext?.();
139
+ const analysis = investigation?.analysis;
140
+ const detrend = params.detrend ?? analysis?.detrend ?? "none";
141
+ const normalize = params.normalize ?? analysis?.normalize ?? "none";
142
+ if (detrend !== "none" && detrend !== "linear") throw new Error("detrend must be 'none' or 'linear'");
143
+ if (normalize !== "none" && normalize !== "zscore") throw new Error("normalize must be 'none' or 'zscore'");
144
+ const csvSpecs = (params.csvSeries ?? investigation?.csvSeries ?? []).map((spec) => ({
145
+ ...spec,
146
+ timezoneOffsetMinutes: spec.timezoneOffsetMinutes ?? investigation?.timezoneOffsetMinutes,
147
+ }));
148
+ const qdashSpecs = (params.qdashSeries ?? investigation?.qdashSeries ?? []).map((spec) => ({
149
+ ...spec,
150
+ tag: spec.tag ?? investigation?.tag,
151
+ }));
152
+ const csv = csvSpecs.flatMap((spec) => loadExternalCsvSeries(spec, ctx.cwd));
153
+ const qdash: NumericTimeseries[] = [];
154
+ let resolvedStartAt = params.startAt ?? investigation?.startAt;
155
+ let resolvedEndAt = params.endAt ?? investigation?.endAt;
156
+ if (qdashSpecs.length > 0) {
157
+ resolvedEndAt = resolvedEndAt ?? new Date().toISOString();
158
+ resolvedStartAt = resolvedStartAt ?? new Date(Date.parse(resolvedEndAt) - (params.withinHours ?? 168) * 3_600_000).toISOString();
159
+ const client = await dependencies.makeClient(params);
160
+ const chipId = await dependencies.defaultChipId(client, params.chipId);
161
+ const fetched = await Promise.all(qdashSpecs.map(async (spec) => {
162
+ const data = await client.getTaskResultsTimeseries({
163
+ chipId,
164
+ parameter: spec.parameter,
165
+ qid: spec.qid,
166
+ tag: spec.tag,
167
+ startAt: resolvedStartAt!,
168
+ endAt: resolvedEndAt!,
169
+ });
170
+ return { spec, points: timeseriesPoints(data) };
171
+ }));
172
+ for (const { spec, points } of fetched) {
173
+ const groups = new Map<string, typeof points>();
174
+ for (const point of points) {
175
+ if (!point.at) continue;
176
+ const group = spec.qid ?? point.series;
177
+ groups.set(group, [...(groups.get(group) ?? []), point]);
178
+ }
179
+ for (const [group, values] of groups) {
180
+ const suffix = spec.qid ? ` q${spec.qid}` : groups.size > 1 ? ` ${group}` : "";
181
+ qdash.push({
182
+ label: spec.label ? groups.size > 1 ? `${spec.label} ${group}` : spec.label : `${spec.parameter}${suffix}`,
183
+ unit: spec.unit ?? values.find((point) => point.unit)?.unit,
184
+ source: "qdash",
185
+ points: values.map((point) => ({
186
+ at: point.at!,
187
+ value: point.value * (spec.scale ?? 1) + (spec.offset ?? 0),
188
+ })),
189
+ });
190
+ }
191
+ }
192
+ }
193
+ const comparison = compareTimeseries([...qdash, ...csv], {
194
+ startAt: resolvedStartAt,
195
+ endAt: resolvedEndAt,
196
+ resampleMinutes: params.resampleMinutes ?? analysis?.resampleMinutes,
197
+ smoothingWindowMinutes: params.smoothingWindowMinutes ?? analysis?.smoothingWindowMinutes,
198
+ detrend,
199
+ normalize,
200
+ maxInterpolationGapMinutes: params.maxInterpolationGapMinutes ?? analysis?.maxInterpolationGapMinutes,
201
+ periodSearch: params.periodSearch ?? analysis?.periodSearch,
202
+ includeAlignedData: params.includeAlignedData,
203
+ });
204
+ return toTextToolResult(timeseriesComparisonText(comparison), comparison, {
205
+ tool: "qdash_compare_timeseries",
206
+ seriesCount: comparison.series.length,
207
+ investigation: investigation?.name,
208
+ });
209
+ },
210
+ });
211
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oqtopus-team/pi-qdash",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Pi extension for querying QDash via @oqtopus-team/qdash-client",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -24,7 +24,7 @@
24
24
  "access": "public"
25
25
  },
26
26
  "dependencies": {
27
- "@oqtopus-team/qdash-client": "^0.1.3"
27
+ "@oqtopus-team/qdash-client": "^0.1.4"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@earendil-works/pi-coding-agent": "*",
@@ -39,7 +39,8 @@
39
39
  ]
40
40
  },
41
41
  "scripts": {
42
- "check": "tsc --noEmit"
42
+ "check": "tsc --noEmit",
43
+ "test": "rm -rf .test-dist && tsc --noEmit false --outDir .test-dist && node --test tests/*.test.mjs; status=$?; rm -rf .test-dist; exit $status"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@earendil-works/pi-coding-agent": "^0.74.0",
@@ -15,7 +15,7 @@ Use the pi-qdash tools instead of scraping the UI or hand-writing auth headers.
15
15
  - `qdash_get_chip_metrics`, `qdash_list_chip_qubits`, `qdash_list_chip_couplings`
16
16
  - `qdash_list_cryostats`, `qdash_list_cooldowns`
17
17
  - `qdash_get_cooldown_wiring`, `qdash_wiring_insights`, `qdash_list_cooldown_wiring_events`
18
- - `qdash_get_timeseries`, `qdash_plot_timeseries`
18
+ - `qdash_get_timeseries`, `qdash_plot_timeseries`, `qdash_inspect_timeseries_csv`, `qdash_compare_timeseries`
19
19
  - `qdash_list_task_results`, `qdash_get_task_result`
20
20
  - `qdash_list_issues`
21
21
  - `qdash_list_flows`, `qdash_get_flow`
@@ -24,6 +24,7 @@ Use the pi-qdash tools instead of scraping the UI or hand-writing auth headers.
24
24
  - `qdash_list_forum_posts`, `qdash_get_forum_post`, `qdash_list_forum_replies`
25
25
  - `qdash_get_figure`, `qdash_get_task_figures`, `qdash_recent_calibration_figure`, `qdash_analyze_figure_json`, `qdash_build_qcal_evidence`
26
26
  - `qdash_create_forum_evidence_reply` for confirmed evidence curation replies with visible task figures
27
+ - `qdash_preview_forum_image_reply`, then `qdash_create_forum_image_reply` for confirmed uploads of locally generated analysis images
27
28
  3. Use harness overview tools for status and triage:
28
29
  - `qdash_investigate` for natural-language requests to investigate or compare recent calibration, target history, figures, failures, issues, and Forum context
29
30
  - `qdash_compare_calibration` for read-only before/after comparison of repeated target calibration results
@@ -64,6 +65,8 @@ Use these commands to make pi behave like a QDash-specific harness with persiste
64
65
  /qdash-use-chip [chip_id]
65
66
  /qdash-use-target qid <qid> | coupling <coupling_id>
66
67
  /qdash-use-agent-session <session_id>
68
+ /qdash-investigation-setup [json]
69
+ /qdash-clear-investigation
67
70
  /qdash-context
68
71
  /qdash-dashboard [limit]
69
72
  /qdash-wiring-insights
@@ -73,6 +76,29 @@ Use these commands to make pi behave like a QDash-specific harness with persiste
73
76
 
74
77
  Prefer the current context when the user has already selected a profile/chip/session. Tools use that context when parameters are omitted.
75
78
 
79
+ ## Generic timeseries comparison
80
+
81
+ Use `qdash_compare_timeseries` when the investigation needs to align multiple
82
+ QDash metrics/targets, local CSV sensor logs, or both. For repeated calls, use
83
+ `/qdash-investigation-setup` to store the arbitrary series mappings, window,
84
+ timezone, and transform defaults; explicit tool arguments override the preset.
85
+ Do not assume a specific
86
+ CSV layout, metric name, target, unit, timestamp format, or timezone:
87
+
88
+ 1. Inspect unknown CSV headers, numeric coverage, and optional time cadence with
89
+ `qdash_inspect_timeseries_csv` first.
90
+ 2. Supply `timeColumn`, one or more `valueColumns`, and optional exact-match
91
+ `filters`; use `scale`/`offset` only for explicit unit conversion.
92
+ 3. For timestamps without a zone, require an explicit
93
+ `timezoneOffsetMinutes` (JST is `540`) rather than relying on the host clock.
94
+ 4. Start with raw/no-transform comparison, then state any smoothing,
95
+ interpolation, detrending, normalization, and period-search settings.
96
+ 5. Report overlap duration, source point counts, aligned point count, number of
97
+ observed cycles, correlations, phases, and explained fractions together.
98
+ 6. Treat correlation and shared periodicity as evidence of association. Do not
99
+ claim direct causality without intervention, longer observation, or evidence
100
+ that excludes a common driver.
101
+
76
102
  For cryostat wiring requests, start with `qdash_get_cooldown_wiring`. Pass `cooldownId` when the user names one; otherwise pass `cryoId`, or omit both to resolve the active/newest cooldown from the current/default chip. Prefer the compact `wiring.markdown`; request raw blocks only when structural or embedded BlockNote data is necessary, and request history when investigating wiring changes. Set `includeInsights` or call `qdash_wiring_insights` when the user asks what is characteristic, unusual, or worth checking in attenuation; summarize control/readout totals and affected qubits/MUXes instead of dumping the raw table.
77
103
 
78
104
  ## Configuration
@@ -94,10 +120,11 @@ When the user wants to preserve an investigated observation in QDash forum/notes
94
120
  2. Summarize the observation as evidence, not as an automatic calibration decision.
95
121
  3. Prefer replying to an existing target/coupling forum thread when one exists.
96
122
  4. Use `qdash_create_forum_evidence_reply` for task-result evidence so figures are embedded as QDash UI image blocks (`/api/executions/figure?path=...`) and visible in the forum.
97
- 5. Include task and execution QDash Web URLs in the reply.
98
- 6. When mentioning history or trends, include links to representative historical task results.
99
- 7. Mark agent-authored evidence with a footer such as `— 🤖 by pi-qdash`.
100
- 8. Ask for confirmation before creating/updating the forum post.
123
+ 5. For locally generated analysis images, call `qdash_preview_forum_image_reply` first, show the exact paths and reply text, then use `qdash_create_forum_image_reply` only after explicit confirmation. Supported images are PNG, JPEG, GIF, and WebP up to 5 MB each.
124
+ 6. Include task and execution QDash Web URLs in the reply when applicable.
125
+ 7. When mentioning history or trends, include links to representative historical task results.
126
+ 8. Mark agent-authored evidence with a footer such as `— 🤖 by pi-qdash`.
127
+ 9. Ask for confirmation before uploading images or creating/updating the forum post.
101
128
 
102
129
  ## Safety
103
130