@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.
- package/README.md +2 -1
- package/extensions/lib/forum-images.ts +82 -0
- package/extensions/lib/investigation-context.ts +169 -0
- package/extensions/lib/timeseries-analysis.ts +609 -0
- package/extensions/lib/write-gate.ts +1 -0
- package/extensions/qdash.ts +175 -4
- package/extensions/tools/timeseries.ts +211 -0
- package/package.json +4 -3
- package/skills/qdash/SKILL.md +32 -5
- package/skills/qdash-timeseries-diagnosis/SKILL.md +150 -0
package/extensions/qdash.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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",
|
package/skills/qdash/SKILL.md
CHANGED
|
@@ -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.
|
|
98
|
-
6.
|
|
99
|
-
7.
|
|
100
|
-
8.
|
|
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
|
|