@oqtopus-team/pi-qdash 0.1.3 → 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 {
@@ -393,6 +407,58 @@ type TaskResultFigureFile = DownloadedFile & {
393
407
  jsonFigurePaths: string[];
394
408
  };
395
409
 
410
+ type CalibrationEvidence = {
411
+ id?: string;
412
+ source: string;
413
+ target?: Record<string, unknown>;
414
+ task?: Record<string, unknown>;
415
+ metrics?: Record<string, number | string | boolean | null>;
416
+ parameters?: Record<string, number | string | boolean | null>;
417
+ resultJson?: unknown;
418
+ figures?: Array<Record<string, unknown>>;
419
+ logs?: string[];
420
+ notes?: string;
421
+ history?: Array<Record<string, unknown>>;
422
+ };
423
+
424
+ function parameterValueRecord(value: unknown): Record<string, number | string | boolean | null> {
425
+ const output: Record<string, number | string | boolean | null> = {};
426
+ const input = value && typeof value === "object" ? value as Record<string, unknown> : {};
427
+ for (const [key, raw] of Object.entries(input)) {
428
+ const object = raw && typeof raw === "object" ? raw as Record<string, unknown> : undefined;
429
+ const candidate = object && "value" in object ? object.value : raw;
430
+ if (typeof candidate === "number" || typeof candidate === "string" || typeof candidate === "boolean" || candidate === null) output[key] = candidate;
431
+ }
432
+ return output;
433
+ }
434
+
435
+ function firstStringField(object: Record<string, unknown>, keys: string[]): string | undefined {
436
+ for (const key of keys) {
437
+ const value = object[key];
438
+ if (typeof value === "string" && value) return value;
439
+ }
440
+ return undefined;
441
+ }
442
+
443
+ function qcalEvidenceText(evidence: CalibrationEvidence): string {
444
+ const metrics = Object.entries(evidence.metrics ?? {}).slice(0, 8).map(([key, value]) => ` - ${key}: ${String(value)}`);
445
+ const figures = evidence.figures ?? [];
446
+ return boxed("QDash QCal Evidence", [
447
+ `id ${evidence.id ?? "n/a"}`,
448
+ `source ${evidence.source}`,
449
+ `target ${evidence.target?.id ?? evidence.target?.couplingId ?? evidence.target?.qids ?? "n/a"}`,
450
+ `task ${evidence.task?.name ?? "n/a"} (${evidence.task?.status ?? "unknown"})`,
451
+ "",
452
+ "Metrics",
453
+ ...(metrics.length ? metrics : [" - none"]),
454
+ "",
455
+ `Figures ${figures.length}`,
456
+ ...figures.map((figure, index) => ` - #${index + 1} ${String(figure.role ?? "plot")} ${String(figure.path ?? figure.url ?? "")}`),
457
+ "",
458
+ "Use qcal_evaluate_bundle with details.evidence to evaluate this evidence with pi-qcal.",
459
+ ]).join("\n");
460
+ }
461
+
396
462
  function cleanQuery(values: Record<string, unknown> = {}): Record<string, unknown> {
397
463
  return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null));
398
464
  }
@@ -1288,6 +1354,56 @@ function taskFigurePaths(task: Record<string, unknown>, maxFigures: number): str
1288
1354
  return Array.isArray(paths) ? paths.filter((item): item is string => typeof item === "string").slice(0, maxFigures) : [];
1289
1355
  }
1290
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
+
1291
1407
  async function buildForumEvidenceReply(client: QDashClient, params: ForumEvidenceReplyParams) {
1292
1408
  const [parent, task] = await Promise.all([
1293
1409
  client.getForumPost(params.parentPostId) as unknown as Promise<Record<string, unknown>>,
@@ -1676,6 +1792,12 @@ export default function qdashExtension(pi: ExtensionAPI) {
1676
1792
  },
1677
1793
  });
1678
1794
 
1795
+ registerTimeseriesComparisonTool(pi, {
1796
+ makeClient,
1797
+ defaultChipId,
1798
+ investigationContext: () => currentContext.investigation,
1799
+ });
1800
+
1679
1801
  registerQueryTool({
1680
1802
  name: "qdash_list_task_results",
1681
1803
  label: "QDash List Task Results",
@@ -2187,6 +2309,65 @@ export default function qdashExtension(pi: ExtensionAPI) {
2187
2309
  },
2188
2310
  });
2189
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
+
2190
2371
  pi.registerTool({
2191
2372
  name: "qdash_create_forum_evidence_reply",
2192
2373
  label: "QDash Create Forum Evidence Reply",
@@ -2462,6 +2643,105 @@ export default function qdashExtension(pi: ExtensionAPI) {
2462
2643
  },
2463
2644
  });
2464
2645
 
2646
+ pi.registerTool({
2647
+ name: "qdash_build_qcal_evidence",
2648
+ label: "QDash Build QCal Evidence",
2649
+ description: "Build a provider-neutral CalibrationEvidence bundle from a QDash task result for evaluation by pi-qcal.",
2650
+ promptSnippet: "Convert QDash calibration task results into pi-qcal CalibrationEvidence",
2651
+ promptGuidelines: [
2652
+ "Use qdash_build_qcal_evidence before qcal_evaluate_bundle when the user asks to evaluate QDash calibration data with pi-qcal.",
2653
+ "qdash_build_qcal_evidence is read-only and only converts QDash data into a generic evidence bundle; it does not evaluate, execute, commit, or apply calibration parameters.",
2654
+ "After qdash_build_qcal_evidence, pass details.evidence to qcal_evaluate_bundle and treat the result as advisory evidence.",
2655
+ ],
2656
+ parameters: Type.Object({
2657
+ ...connectionParams,
2658
+ taskId: Type.String(),
2659
+ includeFigures: Type.Optional(Type.Boolean({ description: "Embed task image figures as data URLs. Defaults to true." })),
2660
+ includeFigureAnalysis: Type.Optional(Type.Boolean({ description: "Analyze Plotly JSON figures and include summaries/warnings in notes. Defaults to true." })),
2661
+ maxFigures: Type.Optional(Type.Number({ description: "Maximum image figures to embed. Defaults to 1." })),
2662
+ includeRawTaskResult: Type.Optional(Type.Boolean({ description: "Include the full raw task result in resultJson. Defaults to false." })),
2663
+ }),
2664
+ async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; taskId: string; includeFigures?: boolean; includeFigureAnalysis?: boolean; maxFigures?: number; includeRawTaskResult?: boolean }) {
2665
+ const client = await makeClient(params);
2666
+ const task = await client.getTaskResult(params.taskId) as unknown as Record<string, unknown>;
2667
+ const figures: Array<Record<string, unknown>> = [];
2668
+ const notes: string[] = [];
2669
+ const figurePaths = Array.isArray(task.figure_path) ? task.figure_path.filter((item): item is string => typeof item === "string") : [];
2670
+ const jsonFigurePaths = Array.isArray(task.json_figure_path) ? task.json_figure_path.filter((item): item is string => typeof item === "string") : [];
2671
+
2672
+ if (params.includeFigureAnalysis !== false) {
2673
+ const limit = Math.min(jsonFigurePaths.length, params.maxFigures ?? 1);
2674
+ for (let index = 0; index < limit; index += 1) {
2675
+ try {
2676
+ const file: TaskResultFigureFile = await client.getTaskResultFigure(params.taskId, { index, preferJson: true });
2677
+ const figure = JSON.parse(Buffer.from(file.data).toString("utf8")) as unknown;
2678
+ const analysis = analyzePlotlyFigure(file.path ?? jsonFigurePaths[index] ?? "", figure, params.taskId);
2679
+ notes.push(`Figure JSON analysis #${index + 1}:`, ...analysis.summaries.map((line) => `- ${line}`));
2680
+ if (analysis.warnings.length) notes.push("Warnings:", ...analysis.warnings.map((line) => `- ${line}`));
2681
+ figures.push({ path: file.path ?? jsonFigurePaths[index], role: "plotly", data: figure });
2682
+ } catch (error) {
2683
+ notes.push(`Figure JSON analysis #${index + 1} failed: ${error instanceof Error ? error.message : String(error)}`);
2684
+ }
2685
+ }
2686
+ }
2687
+
2688
+ if (params.includeFigures !== false) {
2689
+ const limit = Math.min(figurePaths.length, params.maxFigures ?? 1);
2690
+ for (let index = 0; index < limit; index += 1) {
2691
+ try {
2692
+ const file: TaskResultFigureFile = await client.getTaskResultFigure(params.taskId, { index, preferJson: false });
2693
+ const bytes = Buffer.from(file.data);
2694
+ const mediaType = file.mediaType || mediaTypeForPath(file.path ?? figurePaths[index] ?? "");
2695
+ if (mediaType.startsWith("image/")) {
2696
+ figures.push({ path: file.path ?? figurePaths[index], url: `data:${mediaType};base64,${bytes.toString("base64")}`, mimeType: mediaType, role: "plot" });
2697
+ }
2698
+ } catch (error) {
2699
+ notes.push(`Figure image #${index + 1} embedding failed: ${error instanceof Error ? error.message : String(error)}`);
2700
+ }
2701
+ }
2702
+ }
2703
+
2704
+ const couplingId = firstStringField(task, ["coupling_id", "couplingId", "qid"]);
2705
+ const qid = firstStringField(task, ["qid"]);
2706
+ const evidence: CalibrationEvidence = {
2707
+ id: params.taskId,
2708
+ source: "external",
2709
+ target: {
2710
+ kind: couplingId && couplingId.includes("-") ? "coupling" : qid ? "qubit" : "system",
2711
+ id: couplingId ?? qid,
2712
+ couplingId: couplingId && couplingId.includes("-") ? couplingId : undefined,
2713
+ qids: qid && !qid.includes("-") ? [qid] : undefined,
2714
+ chipId: firstStringField(task, ["chip_id", "chipId"]),
2715
+ },
2716
+ task: {
2717
+ name: firstStringField(task, ["task_name", "taskName", "name"]),
2718
+ objective: `Evaluate QDash calibration task ${firstStringField(task, ["task_name", "taskName", "name"]) ?? params.taskId}`,
2719
+ backend: firstStringField(task, ["backend", "chip_id", "chipId"]),
2720
+ startedAt: firstStringField(task, ["start_at", "startAt"]),
2721
+ completedAt: firstStringField(task, ["end_at", "endAt"]),
2722
+ status: firstStringField(task, ["status"]),
2723
+ },
2724
+ metrics: parameterValueRecord(task.output_parameters),
2725
+ parameters: { ...parameterValueRecord(task.input_parameters), ...parameterValueRecord(task.run_parameters) },
2726
+ figures: figures.length ? figures : undefined,
2727
+ notes: [
2728
+ typeof task.message === "string" ? `Task message: ${task.message}` : undefined,
2729
+ notes.length ? notes.join("\n") : undefined,
2730
+ ].filter(Boolean).join("\n\n") || undefined,
2731
+ resultJson: params.includeRawTaskResult ? task : {
2732
+ task_id: task.task_id,
2733
+ execution_id: task.execution_id,
2734
+ flow_name: task.flow_name,
2735
+ figure_path: figurePaths,
2736
+ json_figure_path: jsonFigurePaths,
2737
+ links: task._links,
2738
+ },
2739
+ };
2740
+
2741
+ return toTextToolResult(qcalEvidenceText(evidence), { evidence }, { tool: "qdash_build_qcal_evidence", taskId: params.taskId, evidence });
2742
+ },
2743
+ });
2744
+
2465
2745
  registerQueryTool({
2466
2746
  name: "qdash_get_provenance_stats",
2467
2747
  label: "QDash Provenance Stats",
@@ -2916,8 +3196,49 @@ export default function qdashExtension(pi: ExtensionAPI) {
2916
3196
  },
2917
3197
  });
2918
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
+
2919
3240
  pi.registerCommand("qdash-clear-context", {
2920
- description: "Clear the current QDash profile/chip/agent-session context",
3241
+ description: "Clear the current QDash profile/chip/agent-session/investigation context",
2921
3242
  handler: async (_args, ctx) => {
2922
3243
  currentContext = {};
2923
3244
  persistContext();
@@ -3029,6 +3350,7 @@ export default function qdashExtension(pi: ExtensionAPI) {
3029
3350
  `last execution ${currentContext.lastExecutionId ?? "none"}`,
3030
3351
  `last task ${currentContext.lastTaskId ?? "none"}`,
3031
3352
  `agent session ${currentContext.agentSessionId ?? "none"}`,
3353
+ `investigation ${currentContext.investigation ? investigationContextSummary(currentContext.investigation) : "none"}`,
3032
3354
  ]);
3033
3355
  ctx.ui.notify(contextSummary(), "info");
3034
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.3",
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",