pi-workflow-engine 0.5.0 → 0.7.0
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/.pi/extensions/pi-workflow-engine/index.ts +148 -10
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +205 -23
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +151 -21
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +190 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +114 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +132 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +56 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +240 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +22 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +11 -52
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +13 -3
- package/README.md +29 -7
- package/USAGE.md +47 -9
- package/package.json +8 -6
- package/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
-
import type {
|
|
5
|
+
import type { WorkflowProgressSnapshot } from "./src/progress.ts";
|
|
6
|
+
import type { WorkflowModule, WorkflowProgressSource, WorkflowRef, WorkflowRunOptions } from "./src/types.ts";
|
|
7
|
+
import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
|
|
6
8
|
import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
|
|
7
9
|
import { registerDynamax } from "./src/dynamax.ts";
|
|
10
|
+
import { handleReviewViewerAction } from "./src/review/review-actions.ts";
|
|
11
|
+
import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
|
|
8
12
|
import { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
|
|
9
13
|
|
|
10
14
|
/** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
|
|
@@ -60,8 +64,76 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
|
|
|
60
64
|
return createPerfRecorder(true);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Resolve an `api.workflow()` reference to a registered workflow module. Throws on an unknown name.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<WorkflowModule> {
|
|
71
|
+
const { discoverWorkflows } = await loadDiscovery();
|
|
72
|
+
const workflows = await discoverWorkflows(EXTENSION_DIR, { perf });
|
|
73
|
+
const mod = workflows.get(ref);
|
|
74
|
+
if (!mod) {
|
|
75
|
+
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
76
|
+
throw new Error(`Unknown workflow "${ref}". Available: ${available}`);
|
|
77
|
+
}
|
|
78
|
+
return mod;
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
|
|
64
82
|
|
|
83
|
+
export interface LastWorkflowInspection {
|
|
84
|
+
readonly name: string;
|
|
85
|
+
readonly args: string;
|
|
86
|
+
readonly completedAt: number;
|
|
87
|
+
readonly snapshot: WorkflowProgressSnapshot;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ActiveWorkflowInspection {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly args: string;
|
|
93
|
+
readonly startedAt: number;
|
|
94
|
+
readonly snapshot: () => WorkflowProgressSnapshot;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let lastWorkflowInspection: LastWorkflowInspection | undefined;
|
|
98
|
+
let activeWorkflowInspection: ActiveWorkflowInspection | undefined;
|
|
99
|
+
|
|
100
|
+
export function getLastWorkflowInspection(): LastWorkflowInspection | undefined {
|
|
101
|
+
return lastWorkflowInspection;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getActiveWorkflowInspection(): ActiveWorkflowInspection | undefined {
|
|
105
|
+
return activeWorkflowInspection;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function openWorkflowInspector(ctx: ExtensionContext, inspection: LastWorkflowInspection | ActiveWorkflowInspection): Promise<void> {
|
|
109
|
+
await ctx.ui.custom<void>(
|
|
110
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(snapshotGetter(inspection), tui, theme, () => done(undefined)),
|
|
111
|
+
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function openAvailableWorkflowInspector(ctx: ExtensionContext): Promise<void> {
|
|
116
|
+
if (!ctx.hasUI) {
|
|
117
|
+
ctx.ui.notify("Workflow inspector requires the TUI", "warning");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const inspection = activeWorkflowInspection ?? lastWorkflowInspection;
|
|
121
|
+
if (!inspection) {
|
|
122
|
+
ctx.ui.notify("No workflow inspector is available yet", "warning");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await openWorkflowInspector(ctx, inspection);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function snapshotGetter(inspection: LastWorkflowInspection | ActiveWorkflowInspection): () => WorkflowProgressSnapshot {
|
|
129
|
+
const snapshot = inspection.snapshot;
|
|
130
|
+
return typeof snapshot === "function" ? snapshot : () => snapshot;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
|
|
134
|
+
return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot() };
|
|
135
|
+
}
|
|
136
|
+
|
|
65
137
|
export interface WorkflowInvocation {
|
|
66
138
|
name: string;
|
|
67
139
|
args: string;
|
|
@@ -98,6 +170,14 @@ function parseWorkflowOptions(input: string): { args: string; options: WorkflowR
|
|
|
98
170
|
options.perf = true;
|
|
99
171
|
continue;
|
|
100
172
|
}
|
|
173
|
+
if (token === "--result-viewer" || token === "--review-viewer") {
|
|
174
|
+
options.resultViewer = "open";
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (token === "--no-result-viewer" || token === "--no-review-viewer") {
|
|
178
|
+
options.resultViewer = "skip";
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
101
181
|
if (token.startsWith("--concurrency=")) {
|
|
102
182
|
options.concurrency = parseNumericOption(token.slice("--concurrency=".length));
|
|
103
183
|
continue;
|
|
@@ -182,7 +262,7 @@ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResu
|
|
|
182
262
|
return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
|
|
183
263
|
}
|
|
184
264
|
|
|
185
|
-
async function pickWorkflow(
|
|
265
|
+
export async function pickWorkflow(
|
|
186
266
|
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
187
267
|
ctx: ExtensionCommandContext,
|
|
188
268
|
): Promise<WorkflowInvocation | undefined> {
|
|
@@ -203,11 +283,10 @@ async function pickWorkflow(
|
|
|
203
283
|
const separator = choice.indexOf(" — ");
|
|
204
284
|
const name = separator === -1 ? choice : choice.slice(0, separator);
|
|
205
285
|
const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
|
|
206
|
-
|
|
207
|
-
return { name, args, options: { inspect } };
|
|
286
|
+
return { name, args, options: {} };
|
|
208
287
|
}
|
|
209
288
|
|
|
210
|
-
async function sendWorkflowResult(
|
|
289
|
+
export async function sendWorkflowResult(
|
|
211
290
|
pi: ExtensionAPI,
|
|
212
291
|
ctx: ExtensionCommandContext,
|
|
213
292
|
name: string,
|
|
@@ -218,16 +297,44 @@ async function sendWorkflowResult(
|
|
|
218
297
|
): Promise<void> {
|
|
219
298
|
const { runWorkflow } = await loadEngine();
|
|
220
299
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
300
|
+
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
221
301
|
const result = await runWorkflow(ctx, mod, args, {
|
|
222
302
|
...options,
|
|
223
303
|
perf: options.perf ?? perfRecorder !== undefined,
|
|
224
304
|
perfRecorder,
|
|
305
|
+
resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
|
|
306
|
+
onProgressSource(source) {
|
|
307
|
+
if (source) {
|
|
308
|
+
liveInspection = bindActiveWorkflowInspection(name, args, source);
|
|
309
|
+
activeWorkflowInspection = liveInspection;
|
|
310
|
+
} else if (activeWorkflowInspection === liveInspection) {
|
|
311
|
+
activeWorkflowInspection = undefined;
|
|
312
|
+
liveInspection = undefined;
|
|
313
|
+
}
|
|
314
|
+
options.onProgressSource?.(source);
|
|
315
|
+
},
|
|
225
316
|
onPerfSnapshot(snapshot) {
|
|
226
317
|
perfSnapshot = snapshot;
|
|
227
318
|
options.onPerfSnapshot?.(snapshot);
|
|
228
319
|
},
|
|
320
|
+
onProgressSnapshot(snapshot) {
|
|
321
|
+
lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
322
|
+
options.onProgressSnapshot?.(snapshot);
|
|
323
|
+
},
|
|
229
324
|
});
|
|
230
325
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
326
|
+
const reviewDecision = decideReviewResultsPresentation({
|
|
327
|
+
workflowName: name,
|
|
328
|
+
result,
|
|
329
|
+
mode: extensionContextMode(ctx),
|
|
330
|
+
hasUI: ctx.hasUI,
|
|
331
|
+
resultViewer: options.resultViewer,
|
|
332
|
+
invocationKind: "command",
|
|
333
|
+
});
|
|
334
|
+
const reviewAction = await maybeShowReviewResultsViewer(ctx, reviewDecision);
|
|
335
|
+
if (reviewDecision.kind !== "send") {
|
|
336
|
+
await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
|
|
337
|
+
}
|
|
231
338
|
pi.sendMessage(
|
|
232
339
|
{ customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
|
|
233
340
|
{ triggerTurn: false },
|
|
@@ -235,7 +342,7 @@ async function sendWorkflowResult(
|
|
|
235
342
|
}
|
|
236
343
|
|
|
237
344
|
export default function workflowEngine(pi: ExtensionAPI): void {
|
|
238
|
-
registerDynamax(pi);
|
|
345
|
+
registerDynamax(pi, undefined, { openInspector: openAvailableWorkflowInspector });
|
|
239
346
|
|
|
240
347
|
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
241
348
|
const details = message.details;
|
|
@@ -243,6 +350,18 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
243
350
|
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
244
351
|
});
|
|
245
352
|
|
|
353
|
+
pi.registerCommand("workflow:inspector", {
|
|
354
|
+
description: "Open the current or last workflow inspector",
|
|
355
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
356
|
+
const trimmed = args.trim();
|
|
357
|
+
if (trimmed && trimmed !== "last") {
|
|
358
|
+
ctx.ui.notify("Usage: /workflow:inspector [last]", "warning");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
await openAvailableWorkflowInspector(ctx);
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
246
365
|
// /workflow <name> [args] — user-invoked.
|
|
247
366
|
pi.registerCommand("workflow", {
|
|
248
367
|
description: "Run a multi-agent workflow: /workflow <name> [args]",
|
|
@@ -279,13 +398,14 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
279
398
|
name: "workflow",
|
|
280
399
|
label: "Workflow",
|
|
281
400
|
description:
|
|
282
|
-
"ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
|
|
401
|
+
"ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
|
|
283
402
|
promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
|
|
284
403
|
promptGuidelines: [
|
|
285
|
-
"Use workflow only when the user opted into workflow orchestration via `dynamax`, `/dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
|
|
404
|
+
"Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
|
|
286
405
|
"Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
|
|
287
406
|
"Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
|
|
288
407
|
"Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
|
|
408
|
+
"Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
|
|
289
409
|
"Every workflow tool call must provide exactly one of `name` or `script`, never both.",
|
|
290
410
|
],
|
|
291
411
|
parameters: Type.Object({
|
|
@@ -318,6 +438,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
318
438
|
if (request.kind === "error") return invalidWorkflowInvocationResult();
|
|
319
439
|
|
|
320
440
|
const runOptions: WorkflowRunOptions = {
|
|
441
|
+
inspect: ctx.hasUI,
|
|
321
442
|
concurrency: params.concurrency,
|
|
322
443
|
parallelSubmissionLimit: params.parallelSubmissionLimit,
|
|
323
444
|
perf: params.perf,
|
|
@@ -353,13 +474,30 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
353
474
|
|
|
354
475
|
const { runWorkflow } = await loadEngine();
|
|
355
476
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
356
|
-
|
|
477
|
+
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
478
|
+
const resultArgs = params.args ?? "";
|
|
479
|
+
const result = await runWorkflow(ctx, mod, resultArgs, {
|
|
357
480
|
...runOptions,
|
|
358
481
|
perf: runOptions.perf ?? perfRecorder !== undefined,
|
|
359
482
|
perfRecorder,
|
|
483
|
+
resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
|
|
484
|
+
onProgressSource(source) {
|
|
485
|
+
if (source) {
|
|
486
|
+
liveInspection = bindActiveWorkflowInspection(resultName, resultArgs, source);
|
|
487
|
+
activeWorkflowInspection = liveInspection;
|
|
488
|
+
} else if (activeWorkflowInspection === liveInspection) {
|
|
489
|
+
activeWorkflowInspection = undefined;
|
|
490
|
+
liveInspection = undefined;
|
|
491
|
+
}
|
|
492
|
+
},
|
|
360
493
|
onPerfSnapshot: (snapshot) => {
|
|
361
494
|
perfSnapshot = snapshot;
|
|
362
495
|
},
|
|
496
|
+
onProgressSnapshot: (snapshot) => {
|
|
497
|
+
// Record the run so /workflow:inspector can reopen it — tool-invoked (dynamax) runs were
|
|
498
|
+
// previously uninspectable, unlike the /workflow command path.
|
|
499
|
+
lastWorkflowInspection = { name: resultName, args: resultArgs, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
500
|
+
},
|
|
363
501
|
});
|
|
364
502
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
365
503
|
const perfLine = formatPerfLine(perf);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT = "ctrl+shift+m" satisfies KeyId;
|
|
7
|
+
|
|
8
|
+
const CONFIG_FILE_NAME = "pi-workflow-engine.json";
|
|
9
|
+
|
|
10
|
+
export interface DynamaxShortcuts {
|
|
11
|
+
inspector: KeyId | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DynamaxShortcutConfig {
|
|
15
|
+
inspector?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function dynamaxShortcutsConfigPath(agentDir: string = getAgentDir()): string {
|
|
19
|
+
return join(agentDir, "extensions", CONFIG_FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveDynamaxShortcuts(configPath: string = dynamaxShortcutsConfigPath()): DynamaxShortcuts {
|
|
23
|
+
if (!existsSync(configPath)) return defaultDynamaxShortcuts();
|
|
24
|
+
|
|
25
|
+
const config = readShortcutConfig(configPath);
|
|
26
|
+
if (!config) return defaultDynamaxShortcuts();
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
inspector: shortcutFromConfig(config.inspector, DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readShortcutConfig(configPath: string): DynamaxShortcutConfig | null {
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
warnUsingDefaults("Could not read", configPath);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const shortcuts = isRecord(parsed) ? parsed.shortcuts : undefined;
|
|
43
|
+
if (shortcuts === undefined) return {};
|
|
44
|
+
if (!isRecord(shortcuts) || !hasValidShortcutValues(shortcuts)) {
|
|
45
|
+
warnUsingDefaults("Invalid", configPath);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return shortcuts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasValidShortcutValues(shortcuts: Record<string, unknown>): boolean {
|
|
53
|
+
return isValidShortcutConfigValue(shortcuts.inspector);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isValidShortcutConfigValue(value: unknown): value is string | null | undefined {
|
|
57
|
+
return value === undefined || value === null || (typeof value === "string" && value.trim().length > 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function shortcutFromConfig(configured: unknown, fallback: KeyId): KeyId | null {
|
|
61
|
+
if (configured === null) return null;
|
|
62
|
+
return typeof configured === "string" ? (configured.trim() as KeyId) : fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultDynamaxShortcuts(): DynamaxShortcuts {
|
|
66
|
+
return { inspector: DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function warnUsingDefaults(reason: "Could not read" | "Invalid", configPath: string): void {
|
|
74
|
+
console.warn(`${reason} pi-workflow-engine config at ${configPath}; using default Dynamax shortcuts.`);
|
|
75
|
+
}
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
4
|
+
import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
|
|
3
5
|
|
|
4
6
|
export interface DynamaxState {
|
|
5
7
|
sticky: boolean;
|
|
6
8
|
oneShotPending: boolean;
|
|
9
|
+
turnActive: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DynamaxRuntime {
|
|
13
|
+
state: DynamaxState;
|
|
14
|
+
runningWorkflow?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
|
|
18
|
+
export interface DynamaxRegistrationOptions {
|
|
19
|
+
openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
|
|
7
20
|
}
|
|
8
21
|
|
|
9
22
|
export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
|
|
23
|
+
export const DYNAMAX_STATUS_KEY = "dynamax";
|
|
24
|
+
export const DYNAMAX_WIDGET_KEY = "workflow-dynamax";
|
|
10
25
|
|
|
11
26
|
export const DYNAMAX_REMINDER = `
|
|
12
27
|
## dynamax workflow opt-in
|
|
@@ -23,7 +38,24 @@ Inline workflow rules:
|
|
|
23
38
|
const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
|
|
24
39
|
|
|
25
40
|
export function createDynamaxState(): DynamaxState {
|
|
26
|
-
return { sticky: false, oneShotPending: false };
|
|
41
|
+
return { sticky: false, oneShotPending: false, turnActive: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createDynamaxRuntime(): DynamaxRuntime {
|
|
45
|
+
return { state: createDynamaxState() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function dynamaxSessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
|
|
49
|
+
return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getDynamaxRuntime(store: DynamaxRuntimeStore, ctx: Pick<ExtensionContext, "sessionManager">): DynamaxRuntime {
|
|
53
|
+
const key = dynamaxSessionKey(ctx);
|
|
54
|
+
const existing = store.get(key);
|
|
55
|
+
if (existing) return existing;
|
|
56
|
+
const runtime = createDynamaxRuntime();
|
|
57
|
+
store.set(key, runtime);
|
|
58
|
+
return runtime;
|
|
27
59
|
}
|
|
28
60
|
|
|
29
61
|
export function hasDynamaxToken(text: string): boolean {
|
|
@@ -36,12 +68,16 @@ export function markDynamaxOneShot(state: DynamaxState): void {
|
|
|
36
68
|
|
|
37
69
|
export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
|
|
38
70
|
state.sticky = sticky;
|
|
39
|
-
if (!sticky)
|
|
71
|
+
if (!sticky) {
|
|
72
|
+
state.oneShotPending = false;
|
|
73
|
+
state.turnActive = false;
|
|
74
|
+
}
|
|
40
75
|
}
|
|
41
76
|
|
|
42
77
|
export function clearDynamax(state: DynamaxState): void {
|
|
43
78
|
state.sticky = false;
|
|
44
79
|
state.oneShotPending = false;
|
|
80
|
+
state.turnActive = false;
|
|
45
81
|
}
|
|
46
82
|
|
|
47
83
|
export function consumeDynamaxOneShot(state: DynamaxState): boolean {
|
|
@@ -51,12 +87,32 @@ export function consumeDynamaxOneShot(state: DynamaxState): boolean {
|
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
export function isDynamaxActive(state: DynamaxState): boolean {
|
|
54
|
-
return state.sticky || state.oneShotPending;
|
|
90
|
+
return state.sticky || state.oneShotPending || state.turnActive;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function describeDynamaxState(state: DynamaxState): string {
|
|
94
|
+
const sticky = state.sticky ? "on" : "off";
|
|
95
|
+
const oneShot = state.oneShotPending ? "pending" : "clear";
|
|
96
|
+
const turn = state.turnActive ? "; active for current turn" : "";
|
|
97
|
+
return `sticky ${sticky}; one-shot ${oneShot}${turn}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function dynamaxWidgetLine(state: DynamaxState, shortcut: KeyId | null, runningWorkflow?: string): string {
|
|
101
|
+
const modes = [
|
|
102
|
+
runningWorkflow ? `running ${runningWorkflow}` : undefined,
|
|
103
|
+
state.sticky ? "sticky on" : undefined,
|
|
104
|
+
!state.sticky && state.turnActive ? "active this turn" : undefined,
|
|
105
|
+
state.oneShotPending ? "one-shot pending" : undefined,
|
|
106
|
+
].filter((value): value is string => value !== undefined);
|
|
107
|
+
const mode = modes.join(" + ") || "off";
|
|
108
|
+
const inspectorHint = shortcut ? `${shortcut} inspector` : "/workflow:inspector";
|
|
109
|
+
return `dynamax: ${mode} | /workflow:dynamax on|off | ${inspectorHint}`;
|
|
55
110
|
}
|
|
56
111
|
|
|
57
112
|
export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
|
|
58
|
-
if (!
|
|
113
|
+
if (!state.sticky && !state.oneShotPending) return systemPrompt;
|
|
59
114
|
state.oneShotPending = false;
|
|
115
|
+
state.turnActive = true;
|
|
60
116
|
return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
|
|
61
117
|
}
|
|
62
118
|
|
|
@@ -65,52 +121,174 @@ export function appendDynamaxContextReminder(messages: AgentMessage[], state: Dy
|
|
|
65
121
|
return [...messages, createDynamaxContextMessage()];
|
|
66
122
|
}
|
|
67
123
|
|
|
68
|
-
export function registerDynamax(
|
|
69
|
-
|
|
124
|
+
export function registerDynamax(
|
|
125
|
+
pi: ExtensionAPI,
|
|
126
|
+
shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts(),
|
|
127
|
+
options: DynamaxRegistrationOptions = {},
|
|
128
|
+
): void {
|
|
129
|
+
const runtimes: DynamaxRuntimeStore = new Map();
|
|
130
|
+
const openInspector =
|
|
131
|
+
options.openInspector ??
|
|
132
|
+
((ctx: ExtensionContext): void => {
|
|
133
|
+
ctx.ui.notify("No workflow inspector is available yet", "warning");
|
|
134
|
+
});
|
|
135
|
+
const refreshIfVisible = (ctx: ExtensionContext): void => {
|
|
136
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
137
|
+
if (isDynamaxActive(runtime.state) || runtime.runningWorkflow) {
|
|
138
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
pi.on("session_start", (_event, ctx) => {
|
|
143
|
+
updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
147
|
+
clearDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx));
|
|
148
|
+
});
|
|
70
149
|
|
|
71
|
-
pi.on("input", (event) => {
|
|
150
|
+
pi.on("input", (event, ctx) => {
|
|
72
151
|
if (event.source === "extension") return { action: "continue" };
|
|
73
|
-
|
|
152
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
153
|
+
if (hasDynamaxToken(event.text)) {
|
|
154
|
+
markDynamaxOneShot(runtime.state);
|
|
155
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
156
|
+
}
|
|
74
157
|
return { action: "continue" };
|
|
75
158
|
});
|
|
76
159
|
|
|
77
|
-
pi.on("before_agent_start", (event) => {
|
|
78
|
-
const
|
|
160
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
161
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
162
|
+
const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
|
|
79
163
|
if (systemPrompt === event.systemPrompt) return undefined;
|
|
164
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
80
165
|
return { systemPrompt };
|
|
81
166
|
});
|
|
82
167
|
|
|
83
|
-
pi.on("
|
|
84
|
-
|
|
168
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
169
|
+
refreshIfVisible(ctx);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
pi.on("message_start", (_event, ctx) => {
|
|
173
|
+
refreshIfVisible(ctx);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
pi.on("message_update", (_event, ctx) => {
|
|
177
|
+
refreshIfVisible(ctx);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("message_end", (_event, ctx) => {
|
|
181
|
+
refreshIfVisible(ctx);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
185
|
+
refreshIfVisible(ctx);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
189
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
190
|
+
runtime.runningWorkflow = undefined;
|
|
191
|
+
if (!runtime.state.sticky) runtime.state.turnActive = false;
|
|
192
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
196
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
197
|
+
if (event.toolName === "workflow") {
|
|
198
|
+
runtime.runningWorkflow = workflowLabel(event.args);
|
|
199
|
+
}
|
|
200
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
201
|
+
return undefined;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
pi.on("tool_execution_update", (_event, ctx) => {
|
|
205
|
+
refreshIfVisible(ctx);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
209
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
210
|
+
if (event.toolName === "workflow") {
|
|
211
|
+
runtime.runningWorkflow = undefined;
|
|
212
|
+
}
|
|
213
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
214
|
+
return undefined;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.on("context", (event, ctx) => {
|
|
218
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
219
|
+
const messages = appendDynamaxContextReminder(event.messages, runtime.state);
|
|
85
220
|
if (messages === event.messages) return undefined;
|
|
86
221
|
return { messages };
|
|
87
222
|
});
|
|
88
223
|
|
|
89
|
-
|
|
90
|
-
|
|
224
|
+
if (shortcuts.inspector) {
|
|
225
|
+
pi.registerShortcut(shortcuts.inspector, {
|
|
226
|
+
description: "Open workflow inspector",
|
|
227
|
+
handler: async (ctx) => {
|
|
228
|
+
await openInspector(ctx);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pi.registerCommand("workflow:dynamax", {
|
|
234
|
+
description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
|
|
91
235
|
handler: async (args, ctx) => {
|
|
236
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
92
237
|
const action = args.trim().toLowerCase();
|
|
238
|
+
if (action === "") {
|
|
239
|
+
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
93
242
|
if (action === "on") {
|
|
94
|
-
setDynamaxSticky(state, true);
|
|
95
|
-
ctx
|
|
243
|
+
setDynamaxSticky(runtime.state, true);
|
|
244
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
245
|
+
ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
|
|
96
246
|
return;
|
|
97
247
|
}
|
|
98
248
|
if (action === "off") {
|
|
99
|
-
clearDynamax(state);
|
|
100
|
-
ctx
|
|
249
|
+
clearDynamax(runtime.state);
|
|
250
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
251
|
+
ctx.ui.notify("Dynamax workflow orchestration is off", "info");
|
|
101
252
|
return;
|
|
102
253
|
}
|
|
103
254
|
if (action === "status") {
|
|
104
|
-
|
|
105
|
-
const oneShot = state.oneShotPending ? "; one-shot opt-in pending" : "";
|
|
106
|
-
ctx.ui.notify(`dynamax sticky mode is ${sticky}${oneShot}`, "info");
|
|
255
|
+
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
|
|
107
256
|
return;
|
|
108
257
|
}
|
|
109
|
-
ctx.ui.notify("Usage: /dynamax on|off|status", "warning");
|
|
258
|
+
ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
|
|
110
259
|
},
|
|
111
260
|
});
|
|
112
261
|
}
|
|
113
262
|
|
|
263
|
+
export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
|
|
264
|
+
if (!ctx.hasUI) return;
|
|
265
|
+
ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
|
|
266
|
+
ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
|
|
267
|
+
if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
|
|
268
|
+
clearDynamaxSurfaces(ctx, runtime);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const status = dynamaxWidgetLine(runtime.state, shortcuts.inspector, runtime.runningWorkflow);
|
|
273
|
+
ctx.ui.setStatus(DYNAMAX_STATUS_KEY, status);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
|
|
277
|
+
if (!ctx.hasUI) return;
|
|
278
|
+
ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
|
|
279
|
+
ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
|
|
280
|
+
ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function workflowLabel(args: unknown): string {
|
|
284
|
+
if (!isRecord(args)) return "workflow";
|
|
285
|
+
const name = args.name;
|
|
286
|
+
if (typeof name === "string" && name.trim()) return name.trim();
|
|
287
|
+
const script = args.script;
|
|
288
|
+
if (typeof script === "string" && script.trim()) return "inline workflow";
|
|
289
|
+
return "workflow";
|
|
290
|
+
}
|
|
291
|
+
|
|
114
292
|
function createDynamaxContextMessage(): AgentMessage {
|
|
115
293
|
return {
|
|
116
294
|
role: "custom",
|
|
@@ -121,3 +299,7 @@ function createDynamaxContextMessage(): AgentMessage {
|
|
|
121
299
|
timestamp: Date.now(),
|
|
122
300
|
};
|
|
123
301
|
}
|
|
302
|
+
|
|
303
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
304
|
+
return typeof value === "object" && value !== null;
|
|
305
|
+
}
|