@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.
@@ -4,19 +4,28 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
 
6
6
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
7
- import { QDashClient, defaultConfigPath, type DownloadedFile, type TaskResultFigureOptions } from "@oqtopus-team/qdash-client";
7
+ import {
8
+ QDashClient,
9
+ defaultConfigPath,
10
+ type CooldownResponse,
11
+ type DownloadedFile,
12
+ type TaskResultFigureOptions,
13
+ } from "@oqtopus-team/qdash-client";
8
14
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
9
15
  import { Type } from "typebox";
10
16
 
11
17
  import { analyzePlotlyFigure, figureAnalysisText } from "./lib/figure-analysis.js";
12
18
  import { fetchFigureDetails, figureComponent, figureResultText, mediaTypeForPath, type FigureDetails } from "./lib/figures.js";
19
+ import { forumImageReplyContent, forumImageReplyPreview, loadLocalForumImages, type LocalForumImage } from "./lib/forum-images.js";
13
20
  import { forumDetailComponent, forumDetailLines, forumListLines, forumPostTitle, forumPostsFromPayload } from "./lib/forum-render.js";
21
+ import { investigationContextSummary, validateInvestigationContext, type TimeseriesInvestigationContext } from "./lib/investigation-context.js";
14
22
  import { qdashObjectLinks, qdashWebBaseUrl, qdashWebUrl, safeConfig, withQDashLinks } from "./lib/links.js";
15
23
  import { arrayFromPayload, compactItems, firstNumber, firstString, formatItem, payloadTotal, statusIcon } from "./lib/payload.js";
16
24
  import { toTextToolResult, toToolResult } from "./lib/results.js";
17
25
  import { ansi, boxLinesToWidth, boxed, formatNumber, textComponent } from "./lib/render.js";
18
26
  import { plotSeriesLines, timeseriesPlotComponent, timeseriesPoints } from "./lib/timeseries-plot.js";
19
27
  import { installQDashWriteGate } from "./lib/write-gate.js";
28
+ import { registerTimeseriesComparisonTool } from "./tools/timeseries.js";
20
29
  import { analyzeWiringMarkdown, wiringInsightLines, type WiringInsights } from "./lib/wiring-analysis.js";
21
30
 
22
31
  type QDashQueryParams = {
@@ -131,6 +140,7 @@ type QDashContextState = {
131
140
  taskName?: string;
132
141
  lastExecutionId?: string;
133
142
  lastTaskId?: string;
143
+ investigation?: TimeseriesInvestigationContext;
134
144
  };
135
145
 
136
146
  const CONTEXT_ENTRY_TYPE = "qdash-context";
@@ -151,7 +161,8 @@ function contextSummary(): string {
151
161
  const chip = currentContext.chipId ?? "auto-chip";
152
162
  const session = currentContext.agentSessionId ? ` session:${shortId(currentContext.agentSessionId)}` : "";
153
163
  const target = currentContext.qid ? ` q${currentContext.qid}` : currentContext.couplingId ? ` c${currentContext.couplingId}` : "";
154
- return `qdash ${profile} ${chip}${target}${session}`;
164
+ const investigation = currentContext.investigation?.name ? ` investigation:${currentContext.investigation.name}` : "";
165
+ return `qdash ${profile} ${chip}${target}${session}${investigation}`;
155
166
  }
156
167
 
157
168
  function contextStatusLine(theme?: Theme): string {
@@ -179,8 +190,16 @@ function contextStatusLine(theme?: Theme): string {
179
190
  function isQDashContextState(value: unknown): value is QDashContextState {
180
191
  if (!value || typeof value !== "object") return false;
181
192
  const context = value as QDashContextState;
182
- return [context.profile, context.chipId, context.agentSessionId, context.qid, context.couplingId, context.taskName, context.lastExecutionId, context.lastTaskId]
193
+ const stringsValid = [context.profile, context.chipId, context.agentSessionId, context.qid, context.couplingId, context.taskName, context.lastExecutionId, context.lastTaskId]
183
194
  .every((item) => item === undefined || typeof item === "string");
195
+ if (!stringsValid) return false;
196
+ if (context.investigation === undefined) return true;
197
+ try {
198
+ validateInvestigationContext(context.investigation);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
184
203
  }
185
204
 
186
205
  function loadGlobalContext(): QDashContextState {
@@ -194,8 +213,9 @@ function loadGlobalContext(): QDashContextState {
194
213
  }
195
214
 
196
215
  function saveGlobalContext(context: QDashContextState): void {
216
+ const { investigation: _investigation, ...globalContext } = context;
197
217
  mkdirSync(dirname(GLOBAL_CONTEXT_PATH), { recursive: true });
198
- writeFileSync(GLOBAL_CONTEXT_PATH, `${JSON.stringify(context, null, 2)}\n`, "utf8");
218
+ writeFileSync(GLOBAL_CONTEXT_PATH, `${JSON.stringify(globalContext, null, 2)}\n`, "utf8");
199
219
  }
200
220
 
201
221
  function adoptContextFromToolInput(input: unknown): boolean {
@@ -383,6 +403,9 @@ async function defaultChipId(client: QDashClient, chipId?: string): Promise<stri
383
403
  return chipId ?? currentContext.chipId ?? client.getDefaultChipId();
384
404
  }
385
405
 
406
+ // Keep arbitrary GET access isolated for qdash_raw_get and legacy filters that are
407
+ // not represented in the generated OpenAPI surface. Normal operations should use
408
+ // QDashClient's high-level methods or client.api so upgrades remain type-checked.
386
409
  async function rawGet<T>(client: QDashClient, path: string, query?: Record<string, unknown>): Promise<T> {
387
410
  return (client as unknown as { get<T>(path: string, query?: Record<string, unknown>): Promise<T> }).get(path, cleanQuery(query));
388
411
  }
@@ -458,19 +481,6 @@ function requireValue(value: string | undefined, name: string): string {
458
481
  return value;
459
482
  }
460
483
 
461
- type CooldownRecord = {
462
- cooldown_id: string;
463
- cryo_id: string;
464
- description?: string;
465
- started_at: string;
466
- ended_at?: string | null;
467
- chip_ids?: string[];
468
- wiring_info?: string;
469
- wiring_blocks?: Record<string, unknown>[];
470
- };
471
-
472
- type CooldownListResponse = { cooldowns?: CooldownRecord[] };
473
-
474
484
  type CooldownWiringParams = {
475
485
  profile?: string;
476
486
  configPath?: string;
@@ -488,7 +498,7 @@ type WiringInsightParams = CooldownWiringParams & {
488
498
  color?: boolean;
489
499
  };
490
500
 
491
- function newestCooldown(cooldowns: CooldownRecord[]): CooldownRecord | undefined {
501
+ function newestCooldown(cooldowns: CooldownResponse[]): CooldownResponse | undefined {
492
502
  return [...cooldowns].sort((a, b) => {
493
503
  const activeDifference = Number(a.ended_at != null) - Number(b.ended_at != null);
494
504
  if (activeDifference !== 0) return activeDifference;
@@ -500,15 +510,15 @@ async function resolveCooldownWiring(params: CooldownWiringParams) {
500
510
  params = applyQDashContext(params);
501
511
  const client = await makeClient(params);
502
512
  let selection: string;
503
- let cooldown: CooldownRecord;
513
+ let cooldown: CooldownResponse;
504
514
  let resolvedChipId = params.chipId ?? currentContext.chipId;
505
515
 
506
516
  if (params.cooldownId) {
507
- cooldown = await rawGet<CooldownRecord>(client, `/cooldowns/${pathPart(params.cooldownId)}`);
517
+ cooldown = await client.api.getCooldown(params.cooldownId);
508
518
  selection = "explicit cooldownId";
509
519
  } else {
510
520
  resolvedChipId = params.chipId ?? (params.cryoId ? undefined : await defaultChipId(client));
511
- const response = await rawGet<CooldownListResponse>(client, "/cooldowns", {
521
+ const response = await client.api.listCooldowns({
512
522
  cryo_id: params.cryoId,
513
523
  chip_id: resolvedChipId,
514
524
  });
@@ -517,7 +527,7 @@ async function resolveCooldownWiring(params: CooldownWiringParams) {
517
527
  const scope = params.cryoId ? `cryo ${params.cryoId}` : `chip ${resolvedChipId}`;
518
528
  throw new Error(`No cooldown containing wiring information was found for ${scope}`);
519
529
  }
520
- cooldown = await rawGet<CooldownRecord>(client, `/cooldowns/${pathPart(selected.cooldown_id)}`);
530
+ cooldown = await client.api.getCooldown(selected.cooldown_id);
521
531
  selection = params.cryoId ? "active/newest cooldown for cryo" : "active/newest cooldown for chip";
522
532
  }
523
533
 
@@ -525,14 +535,14 @@ async function resolveCooldownWiring(params: CooldownWiringParams) {
525
535
  const wiringInfo = typeof cooldown.wiring_info === "string" ? cooldown.wiring_info.trim() : "";
526
536
  const includeBlocks = params.includeBlocks === true || !wiringInfo;
527
537
  const history = params.includeHistory
528
- ? await rawGet(client, `/cooldowns/${pathPart(cooldown.cooldown_id)}/wiring-events`, {
538
+ ? await client.api.listCooldownWiringEvents(cooldown.cooldown_id, {
529
539
  limit: params.historyLimit ?? 20,
530
540
  skip: 0,
531
541
  })
532
542
  : undefined;
533
543
  let cryostat: unknown;
534
544
  try {
535
- cryostat = await rawGet(client, `/cryostats/${pathPart(cooldown.cryo_id)}`);
545
+ cryostat = await client.api.getCryostat(cooldown.cryo_id);
536
546
  } catch {
537
547
  // Wiring remains useful on servers where cryostat detail is not accessible.
538
548
  }
@@ -580,9 +590,9 @@ async function buildDashboard(params: { profile?: string; configPath?: string; u
580
590
  const chipId = await defaultChipId(client, params.chipId);
581
591
  const [chips, openIssues, recentExecutions, failedTaskResults, provenanceStats] = await Promise.allSettled([
582
592
  client.listChips(),
583
- rawGet(client, "/issues", { is_closed: false, limit }),
584
- rawGet(client, "/executions", { chip_id: chipId, limit }),
585
- rawGet(client, "/task-results", { chip_id: chipId, status: "failed", limit }),
593
+ client.api.listIssues({ is_closed: false, limit }),
594
+ client.api.listExecutions({ chip_id: chipId, limit }),
595
+ client.api.listTaskResults({ chip_id: chipId, status: "failed", limit }),
586
596
  client.getProvenanceStats(),
587
597
  ]);
588
598
  const value = <T>(result: PromiseSettledResult<T>): T | { error: string } => result.status === "fulfilled" ? result.value : { error: result.reason instanceof Error ? result.reason.message : String(result.reason) };
@@ -724,7 +734,7 @@ async function buildRecentCalibrationSummary(params: { profile?: string; configP
724
734
  const chipId = await defaultChipId(client, params.chipId);
725
735
  const end = new Date();
726
736
  const start = new Date(end.getTime() - (params.withinHours ?? 24) * 3600_000);
727
- const payload = await rawGet(client, "/task-results", { chip_id: chipId, start_from: start.toISOString(), start_to: end.toISOString(), limit: params.limit ?? 30 });
737
+ const payload = await client.api.listTaskResults({ chip_id: chipId, start_from: start.toISOString(), start_to: end.toISOString(), limit: params.limit ?? 30 });
728
738
  const items = arrayFromPayload(payload).filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
729
739
  const byTarget = new Map<string, Record<string, unknown>[]>();
730
740
  for (const item of items) {
@@ -925,7 +935,7 @@ async function buildTargetOperationsReport(params: { profile?: string; configPat
925
935
  const [latestTarget, results, issues, forumPosts] = await Promise.all([
926
936
  kind === "qubit" ? client.getChipQubit(chipId, id) : client.getChipCoupling(chipId, id),
927
937
  client.listTaskResults({ chipId, qid, couplingId, startAt: start.toISOString(), endAt: end.toISOString(), limit }),
928
- rawGet(client, "/issues", { is_closed: false, limit: 100 }),
938
+ client.api.listIssues({ is_closed: false, limit: 100 }),
929
939
  client.listForumPosts({ chipId, status: "open", targetType: kind, targetId: id, limit }),
930
940
  ]);
931
941
  const recentResults = arrayFromPayload(results).filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
@@ -1109,7 +1119,7 @@ async function buildCalibrationValidation(params: { profile?: string; configPath
1109
1119
  const task = await client.getTaskResult(params.taskId) as unknown as Record<string, unknown>;
1110
1120
  const status = firstString(task, ["status"])?.toLowerCase() ?? "unknown";
1111
1121
  const figurePaths = taskFigurePaths(task, 20);
1112
- const issuesPayload = await rawGet(client, `/task-results/${pathPart(params.taskId)}/issues`).catch(() => []);
1122
+ const issuesPayload = await client.api.getTaskResultIssues(params.taskId).catch(() => []);
1113
1123
  const issues = arrayFromPayload(issuesPayload).filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
1114
1124
  const afterExecutionId = firstString(task, ["execution_id", "executionId"]);
1115
1125
  const comparison = params.beforeExecutionId && afterExecutionId
@@ -1242,10 +1252,10 @@ async function buildDashboardInsights(params: { profile?: string; configPath?: s
1242
1252
  const start = new Date(end.getTime() - (params.withinHours ?? 168) * 3600_000);
1243
1253
  const [forums, issues, failed, metrics, aiInsights] = await Promise.allSettled([
1244
1254
  client.listForumPosts({ chipId, status: "open", limit }),
1245
- rawGet(client, "/issues", { is_closed: false, limit }),
1246
- rawGet(client, "/task-results", { chip_id: chipId, status: "failed", start_from: start.toISOString(), start_to: end.toISOString(), limit }),
1255
+ client.api.listIssues({ is_closed: false, limit }),
1256
+ client.api.listTaskResults({ chip_id: chipId, status: "failed", start_from: start.toISOString(), start_to: end.toISOString(), limit }),
1247
1257
  client.getChipMetrics(chipId),
1248
- rawGet(client, `/chips/${pathPart(chipId)}/ai-insights`, { latest_only: true, start_at: start.toISOString(), end_at: end.toISOString() }),
1258
+ client.api.getDashboardAiInsights(chipId, { latest_only: true, start_at: start.toISOString(), end_at: end.toISOString() }),
1249
1259
  ]);
1250
1260
  const value = <T>(result: PromiseSettledResult<T>): T | undefined => result.status === "fulfilled" ? result.value : undefined;
1251
1261
  const insightMap = new Map<string, DashboardInsight>();
@@ -1340,6 +1350,56 @@ function taskFigurePaths(task: Record<string, unknown>, maxFigures: number): str
1340
1350
  return Array.isArray(paths) ? paths.filter((item): item is string => typeof item === "string").slice(0, maxFigures) : [];
1341
1351
  }
1342
1352
 
1353
+ type ForumImageReplyParams = {
1354
+ parentPostId: string;
1355
+ imagePaths: string[];
1356
+ interpretation: string;
1357
+ title?: string;
1358
+ };
1359
+
1360
+ type PreparedForumImageReply = {
1361
+ parent: Record<string, unknown>;
1362
+ images: LocalForumImage[];
1363
+ title: string;
1364
+ preview: string;
1365
+ };
1366
+
1367
+ async function prepareForumImageReply(client: QDashClient, params: ForumImageReplyParams): Promise<PreparedForumImageReply> {
1368
+ const parent = await client.getForumPost(params.parentPostId) as unknown as Record<string, unknown>;
1369
+ const images = loadLocalForumImages(params.imagePaths);
1370
+ const title = params.title ?? `${new Date().toISOString().slice(0, 10)} 追加分析画像`;
1371
+ const preview = forumImageReplyPreview(title, images, params.interpretation);
1372
+ return { parent, images, title, preview };
1373
+ }
1374
+
1375
+ async function createForumImageReply(client: QDashClient, params: ForumImageReplyParams, prepared: PreparedForumImageReply) {
1376
+ const uploaded = [];
1377
+ for (const image of prepared.images) {
1378
+ const result = await client.uploadForumImage(image.blob, image.filename);
1379
+ uploaded.push({ ...image, url: result.url });
1380
+ }
1381
+ const content = forumImageReplyContent(prepared.title, uploaded, params.interpretation);
1382
+ const parent = prepared.parent;
1383
+ const request = {
1384
+ category: firstString(parent, ["category"]) ?? "other",
1385
+ title: null,
1386
+ content,
1387
+ content_blocks: [],
1388
+ parent_id: params.parentPostId,
1389
+ chip_id: firstString(parent, ["chip_id", "chipId"]),
1390
+ target_type: firstString(parent, ["target_type", "targetType"]),
1391
+ target_id: firstString(parent, ["target_id", "targetId"]),
1392
+ cooldown_id: firstString(parent, ["cooldown_id", "cooldownId"]),
1393
+ status: firstString(parent, ["status"]) ?? "open",
1394
+ };
1395
+ const reply = await client.createForumPost(request as never);
1396
+ return {
1397
+ reply,
1398
+ content,
1399
+ images: uploaded.map(({ blob: _blob, ...image }) => image),
1400
+ };
1401
+ }
1402
+
1343
1403
  async function buildForumEvidenceReply(client: QDashClient, params: ForumEvidenceReplyParams) {
1344
1404
  const [parent, task] = await Promise.all([
1345
1405
  client.getForumPost(params.parentPostId) as unknown as Promise<Record<string, unknown>>,
@@ -1421,19 +1481,19 @@ async function executeQuery(params: QDashQueryParams) {
1421
1481
  case "chip_qubit": return client.getChipQubit(await defaultChipId(client, params.chipId), requireValue(params.qid, "qid"));
1422
1482
  case "chip_couplings": return client.listChipCouplings(await defaultChipId(client, params.chipId), { limit: params.limit, offset: params.offset });
1423
1483
  case "chip_coupling": return client.getChipCoupling(await defaultChipId(client, params.chipId), requireValue(params.couplingId, "couplingId"));
1424
- case "cryostats": return rawGet(client, "/cryostats");
1425
- case "cryostat": return rawGet(client, `/cryostats/${pathPart(requireValue(params.cryoId, "cryoId"))}`);
1426
- case "cooldowns": return rawGet(client, "/cooldowns", { cryo_id: params.cryoId, chip_id: params.chipId });
1427
- case "cooldown": return rawGet(client, `/cooldowns/${pathPart(requireValue(params.cooldownId, "cooldownId"))}`);
1428
- case "cooldown_wiring_events": return rawGet(client, `/cooldowns/${pathPart(requireValue(params.cooldownId, "cooldownId"))}/wiring-events`, { limit: params.limit, skip: params.skip });
1484
+ case "cryostats": return client.api.listCryostats();
1485
+ case "cryostat": return client.api.getCryostat(requireValue(params.cryoId, "cryoId"));
1486
+ case "cooldowns": return client.api.listCooldowns({ cryo_id: params.cryoId, chip_id: params.chipId });
1487
+ case "cooldown": return client.api.getCooldown(requireValue(params.cooldownId, "cooldownId"));
1488
+ case "cooldown_wiring_events": return client.api.listCooldownWiringEvents(requireValue(params.cooldownId, "cooldownId"), { limit: params.limit, skip: params.skip });
1429
1489
  case "timeseries": return client.getTaskResultsTimeseries({ chipId: await defaultChipId(client, params.chipId), parameter: requireValue(params.parameter, "parameter"), tag: params.tag, qid: params.qid, startAt: requireValue(params.startAt, "startAt"), endAt: requireValue(params.endAt, "endAt") });
1430
1490
  case "task_results": return rawGet(client, "/task-results", { chip_id: params.chipId, task_name: params.taskName, qid: params.qid, coupling_id: params.couplingId, execution_id: params.executionId, username: params.username, status: params.status, start_from: params.startFrom ?? params.startAt, start_to: params.startTo ?? params.endAt, message_contains: params.messageContains, limit: params.limit, skip: params.skip });
1431
1491
  case "task_result": return client.getTaskResult(requireValue(params.taskId, "taskId"));
1432
- case "task_note": return rawGet(client, `/task-results/${pathPart(requireValue(params.taskId, "taskId"))}/note`);
1433
- case "task_result_issues": return rawGet(client, `/task-results/${pathPart(requireValue(params.taskId, "taskId"))}/issues`);
1434
- case "qubit_latest": return rawGet(client, "/task-results/qubits/latest", { chip_id: await defaultChipId(client, params.chipId), task: requireValue(params.task ?? params.taskName, "task") });
1492
+ case "task_note": return client.api.getTaskNote(requireValue(params.taskId, "taskId"));
1493
+ case "task_result_issues": return client.api.getTaskResultIssues(requireValue(params.taskId, "taskId"));
1494
+ case "qubit_latest": return client.api.getLatestQubitTaskResults({ chip_id: await defaultChipId(client, params.chipId), task: requireValue(params.task ?? params.taskName, "task") });
1435
1495
  case "qubit_history": return rawGet(client, `/task-results/qubits/${pathPart(requireValue(params.qid, "qid"))}/history`, { chip_id: await defaultChipId(client, params.chipId), task: requireValue(params.task ?? params.taskName, "task"), date: requireValue(params.date, "date") });
1436
- case "coupling_latest": return rawGet(client, "/task-results/couplings/latest", { chip_id: await defaultChipId(client, params.chipId), task: requireValue(params.task ?? params.taskName, "task") });
1496
+ case "coupling_latest": return client.api.getLatestCouplingTaskResults({ chip_id: await defaultChipId(client, params.chipId), task: requireValue(params.task ?? params.taskName, "task") });
1437
1497
  case "coupling_history": return rawGet(client, `/task-results/couplings/${pathPart(requireValue(params.couplingId, "couplingId"))}/history`, { chip_id: await defaultChipId(client, params.chipId), task: requireValue(params.task ?? params.taskName, "task"), date: requireValue(params.date, "date") });
1438
1498
  case "tasks": return client.listTasks(params.backend);
1439
1499
  case "task_knowledge": return params.taskName ? client.getTaskKnowledge(params.taskName) : client.listTaskKnowledge();
@@ -1443,23 +1503,26 @@ async function executeQuery(params: QDashQueryParams) {
1443
1503
  case "files_tree": return client.getFilesTree();
1444
1504
  case "file_content": return client.getFileContent(requireValue(params.path, "path"));
1445
1505
  case "git_status": return client.getGitStatus();
1446
- case "issues": return rawGet(client, "/issues", { task_id: params.taskId, is_closed: params.isClosed, limit: params.limit, skip: params.skip });
1447
- case "issue_knowledge": return rawGet(client, "/issue-knowledge", { status: params.status, task_name: params.taskName, limit: params.limit, skip: params.skip });
1506
+ case "issues": return client.api.listIssues({ task_id: params.taskId, is_closed: params.isClosed, limit: params.limit, skip: params.skip });
1507
+ case "issue_knowledge": return client.api.listIssueKnowledge({ status: params.status, task_name: params.taskName, limit: params.limit, skip: params.skip });
1448
1508
  case "flows": return client.listFlows();
1449
1509
  case "flow": return client.getFlow(requireValue(params.flowName, "flowName"));
1450
1510
  case "flow_templates": return client.listFlowTemplates();
1451
1511
  case "flow_template": return client.getFlowTemplate(requireValue(params.templateId, "templateId"));
1452
- case "flow_helper_files": return rawGet(client, "/flows/helpers");
1453
- case "flow_helper_file": return rawGet(client, `/flows/helpers/${pathPart(requireValue(params.filename, "filename"))}`);
1512
+ case "flow_helper_files": return client.api.listFlowHelperFiles();
1513
+ case "flow_helper_file": return client.api.getFlowHelperFile(requireValue(params.filename, "filename"));
1454
1514
  case "executions": return rawGet(client, "/executions", { chip_id: params.chipId, flow_name: params.flowName, status: params.status, skip: params.skip, limit: params.limit });
1455
1515
  case "execution": return client.getExecution(requireValue(params.executionId, "executionId"));
1456
- case "ai_reviews": return rawGet(client, "/task-results/ai-review", { chip_id: params.chipId, task_name: params.taskName, status: params.status, decision: params.decision, latest_only: params.latestOnly, skip: params.skip, limit: params.limit });
1457
- case "ai_review_runs": return rawGet(client, "/task-results/ai-review/runs", { chip_id: params.chipId, task_name: params.taskName, skip: params.skip, limit: params.limit });
1458
- case "ai_review_run": return rawGet(client, `/task-results/ai-review/runs/${pathPart(requireValue(params.reviewRunId, "reviewRunId"))}`);
1516
+ case "ai_reviews": return client.api.listTaskResultAiReviews({ chip_id: params.chipId, task_name: params.taskName, status: params.status, decision: params.decision, latest_only: params.latestOnly, skip: params.skip, limit: params.limit });
1517
+ case "ai_review_runs": return client.api.listTaskResultAiReviewRuns({ chip_id: params.chipId, task_name: params.taskName, skip: params.skip, limit: params.limit });
1518
+ case "ai_review_run": return client.api.getTaskResultAiReviewRun(requireValue(params.reviewRunId, "reviewRunId"));
1459
1519
  case "forum_posts": return client.listForumPosts({ status: params.status, chipId: params.chipId, limit: params.limit, skip: params.skip });
1460
1520
  case "provenance_stats": return client.getProvenanceStats();
1461
- case "provenance_history": return rawGet(client, "/provenance/history", { parameter_name: params.parameterName ?? params.parameter, qid: params.qid, limit: params.limit });
1462
- case "provenance_changes": return rawGet(client, "/provenance/changes", { parameter_names: params.parameterName ?? params.parameter, within_hours: params.withinHours, limit: params.limit });
1521
+ case "provenance_history": return client.api.getParameterHistory({ parameter_name: requireValue(params.parameterName ?? params.parameter, "parameterName"), qid: requireValue(params.qid, "qid"), limit: params.limit });
1522
+ case "provenance_changes": {
1523
+ const parameterName = params.parameterName ?? params.parameter;
1524
+ return client.getRecentChanges({ parameterNames: parameterName ? [parameterName] : undefined, withinHours: params.withinHours, limit: params.limit });
1525
+ }
1463
1526
  case "provenance_lineage": return client.getProvenanceLineage(requireValue(params.entityId, "entityId"));
1464
1527
  case "provenance_impact": return client.getProvenanceImpact(requireValue(params.entityId, "entityId"));
1465
1528
  }
@@ -1728,6 +1791,12 @@ export default function qdashExtension(pi: ExtensionAPI) {
1728
1791
  },
1729
1792
  });
1730
1793
 
1794
+ registerTimeseriesComparisonTool(pi, {
1795
+ makeClient,
1796
+ defaultChipId,
1797
+ investigationContext: () => currentContext.investigation,
1798
+ });
1799
+
1731
1800
  registerQueryTool({
1732
1801
  name: "qdash_list_task_results",
1733
1802
  label: "QDash List Task Results",
@@ -2239,6 +2308,65 @@ export default function qdashExtension(pi: ExtensionAPI) {
2239
2308
  },
2240
2309
  });
2241
2310
 
2311
+ pi.registerTool({
2312
+ name: "qdash_preview_forum_image_reply",
2313
+ label: "QDash Preview Forum Image Reply",
2314
+ description: "Preview a QDash forum reply that will upload and embed locally generated analysis images. Read-only; no image or post is created.",
2315
+ promptSnippet: "Preview local analysis images and forum reply content before uploading",
2316
+ promptGuidelines: [
2317
+ "Use qdash_preview_forum_image_reply before uploading locally generated analysis images to a forum thread.",
2318
+ "Show the image paths and reply content, then require explicit confirmation before publishing.",
2319
+ ],
2320
+ parameters: Type.Object({
2321
+ ...connectionParams,
2322
+ parentPostId: Type.String(),
2323
+ imagePaths: Type.Array(Type.String(), { minItems: 1, maxItems: 8 }),
2324
+ interpretation: Type.String({ description: "Human-readable interpretation or context shown after the images." }),
2325
+ title: Type.Optional(Type.String()),
2326
+ }),
2327
+ async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; parentPostId: string; imagePaths: string[]; interpretation: string; title?: string }) {
2328
+ const client = await makeClient(params);
2329
+ const prepared = await prepareForumImageReply(client, params);
2330
+ return toTextToolResult(prepared.preview, {
2331
+ preview: prepared.preview,
2332
+ parent: prepared.parent,
2333
+ images: prepared.images.map(({ blob: _blob, ...image }) => image),
2334
+ }, { tool: "qdash_preview_forum_image_reply" });
2335
+ },
2336
+ });
2337
+
2338
+ pi.registerTool({
2339
+ name: "qdash_create_forum_image_reply",
2340
+ label: "QDash Create Forum Image Reply",
2341
+ description: "Upload locally generated analysis images and create a QDash forum reply embedding them. This is a write operation and requires confirmation.",
2342
+ promptSnippet: "Upload local analysis images and create a forum reply after explicit confirmation",
2343
+ promptGuidelines: [
2344
+ "Use qdash_create_forum_image_reply only after previewing the exact image paths and reply content with qdash_preview_forum_image_reply.",
2345
+ "Require explicit confirmation before uploading images or creating the reply.",
2346
+ ],
2347
+ parameters: Type.Object({
2348
+ ...connectionParams,
2349
+ parentPostId: Type.String(),
2350
+ imagePaths: Type.Array(Type.String(), { minItems: 1, maxItems: 8 }),
2351
+ interpretation: Type.String({ description: "Human-readable interpretation or context shown after the images." }),
2352
+ title: Type.Optional(Type.String()),
2353
+ confirmWrite: Type.Optional(Type.Boolean()),
2354
+ }),
2355
+ async execute(_toolCallId, params: ConfirmableParams & { profile?: string; configPath?: string; useEnv?: boolean; parentPostId: string; imagePaths: string[]; interpretation: string; title?: string }, _signal, _onUpdate, ctx) {
2356
+ const client = await makeClient(params);
2357
+ const prepared = await prepareForumImageReply(client, params);
2358
+ if (!params.confirmWrite) {
2359
+ const confirmed = ctx.hasUI && await ctx.ui.confirm("Upload images and create QDash forum reply?", prepared.preview.slice(0, 1800));
2360
+ if (!confirmed) throw new Error("qdash_create_forum_image_reply requires explicit confirmation");
2361
+ }
2362
+ const created = await createForumImageReply(client, params, prepared);
2363
+ const postId = firstString(created.reply as unknown as Record<string, unknown>, ["id"]);
2364
+ const parentUrl = qdashWebUrl(client, `/forum/posts/${encodeURIComponent(params.parentPostId)}`);
2365
+ const text = [`created forum image reply${postId ? ` ${postId}` : ""}`, `parent ${parentUrl}`, "", created.content].join("\n");
2366
+ return toTextToolResult(text, created, { tool: "qdash_create_forum_image_reply", url: parentUrl });
2367
+ },
2368
+ });
2369
+
2242
2370
  pi.registerTool({
2243
2371
  name: "qdash_create_forum_evidence_reply",
2244
2372
  label: "QDash Create Forum Evidence Reply",
@@ -3067,8 +3195,49 @@ export default function qdashExtension(pi: ExtensionAPI) {
3067
3195
  },
3068
3196
  });
3069
3197
 
3198
+ pi.registerCommand("qdash-investigation-setup", {
3199
+ description: "Create or edit a reusable generic timeseries investigation context; accepts optional JSON",
3200
+ handler: async (args, ctx) => {
3201
+ const template: TimeseriesInvestigationContext = currentContext.investigation ?? {
3202
+ name: "timeseries-investigation",
3203
+ timezoneOffsetMinutes: 540,
3204
+ qdashSeries: currentContext.qid ? [
3205
+ { parameter: "metric_a", qid: currentContext.qid },
3206
+ { parameter: "metric_b", qid: currentContext.qid },
3207
+ ] : [
3208
+ { parameter: "metric_a" },
3209
+ { parameter: "metric_b" },
3210
+ ],
3211
+ analysis: { resampleMinutes: 1, detrend: "none", normalize: "none" },
3212
+ };
3213
+ const source = args.trim() || await ctx.ui.editor("QDash investigation context (JSON)", JSON.stringify(template, null, 2));
3214
+ if (!source) return;
3215
+ try {
3216
+ const investigation = validateInvestigationContext(JSON.parse(source));
3217
+ currentContext = { ...currentContext, investigation };
3218
+ persistContext();
3219
+ refreshContextUi(ctx);
3220
+ ctx.ui.setWidget("qdash", ["QDash Investigation", investigationContextSummary(investigation)]);
3221
+ ctx.ui.notify(`Investigation context saved: ${investigationContextSummary(investigation)}`, "info");
3222
+ } catch (error) {
3223
+ ctx.ui.notify(`Invalid investigation context: ${error instanceof Error ? error.message : String(error)}`, "error");
3224
+ }
3225
+ },
3226
+ });
3227
+
3228
+ pi.registerCommand("qdash-clear-investigation", {
3229
+ description: "Clear the reusable timeseries investigation context without changing profile/chip context",
3230
+ handler: async (_args, ctx) => {
3231
+ const { investigation: _investigation, ...rest } = currentContext;
3232
+ currentContext = rest;
3233
+ persistContext();
3234
+ refreshContextUi(ctx);
3235
+ ctx.ui.notify("QDash investigation context cleared", "info");
3236
+ },
3237
+ });
3238
+
3070
3239
  pi.registerCommand("qdash-clear-context", {
3071
- description: "Clear the current QDash profile/chip/agent-session context",
3240
+ description: "Clear the current QDash profile/chip/agent-session/investigation context",
3072
3241
  handler: async (_args, ctx) => {
3073
3242
  currentContext = {};
3074
3243
  persistContext();
@@ -3180,6 +3349,7 @@ export default function qdashExtension(pi: ExtensionAPI) {
3180
3349
  `last execution ${currentContext.lastExecutionId ?? "none"}`,
3181
3350
  `last task ${currentContext.lastTaskId ?? "none"}`,
3182
3351
  `agent session ${currentContext.agentSessionId ?? "none"}`,
3352
+ `investigation ${currentContext.investigation ? investigationContextSummary(currentContext.investigation) : "none"}`,
3183
3353
  ]);
3184
3354
  ctx.ui.notify(contextSummary(), "info");
3185
3355
  },