pi-workflow-engine 0.12.0 → 1.0.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 +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +90 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import type { LoadedWorkflow, WorkflowApi } from "./types.ts";
|
|
3
|
+
import type { LoadedWorkflow, WorkflowApi, WorkflowMeta } from "./types.ts";
|
|
4
4
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
5
5
|
import { parseWorkflowMeta } from "./workflow-module.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* v1 inline workflow source contract:
|
|
9
9
|
* - The script starts with `export const meta = { ... };` where the object is a pure literal.
|
|
10
|
-
* - The script contains exactly one `export default async ...` workflow function
|
|
10
|
+
* - The script contains exactly one `export default async ...` workflow function with a block body,
|
|
11
|
+
* followed by nothing but an optional `;` and comments.
|
|
11
12
|
* - Inline scripts must not use `import` or any exports other than `meta` and the default function.
|
|
12
13
|
* - `Type` is injected lexically by the host; scripts must use that `Type`, not import typebox.
|
|
13
14
|
*/
|
|
@@ -19,25 +20,19 @@ export class InlineWorkflowCompileError extends Error {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export interface InlineMetaLiteral {
|
|
23
|
-
readonly literal: string;
|
|
24
|
-
readonly value: unknown;
|
|
25
|
-
readonly endOffset: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
type InlineWorkflowExecutor = (api: WorkflowApi, typebox: typeof Type) => Promise<unknown>;
|
|
29
24
|
type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecutor;
|
|
30
25
|
|
|
31
26
|
const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
|
|
32
27
|
|
|
33
28
|
export function compileInlineWorkflow(source: string): LoadedWorkflow {
|
|
34
|
-
|
|
29
|
+
const code = maskNonCode(source);
|
|
30
|
+
rejectImportSyntax(code);
|
|
35
31
|
const metaLiteral = extractMetaLiteral(source);
|
|
36
32
|
const parsedMeta = parseWorkflowMeta(metaLiteral.value);
|
|
37
33
|
if ("reason" in parsedMeta) throw new InlineWorkflowCompileError(parsedMeta.reason);
|
|
38
34
|
|
|
39
|
-
const
|
|
40
|
-
const executor = compileExecutor(defaultExpression);
|
|
35
|
+
const executor = compileExecutor(extractDefaultWorkflowExpression(source, code, metaLiteral.endOffset));
|
|
41
36
|
return {
|
|
42
37
|
meta: parsedMeta.meta,
|
|
43
38
|
default: (api) => executor(api, Type),
|
|
@@ -45,7 +40,17 @@ export function compileInlineWorkflow(source: string): LoadedWorkflow {
|
|
|
45
40
|
};
|
|
46
41
|
}
|
|
47
42
|
|
|
48
|
-
|
|
43
|
+
/** The script's pure-literal meta, read without compiling or running anything; undefined when it does not parse. */
|
|
44
|
+
export function readInlineWorkflowMeta(source: string): WorkflowMeta | undefined {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = parseWorkflowMeta(extractMetaLiteral(source).value);
|
|
47
|
+
return "meta" in parsed ? parsed.meta : undefined;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractMetaLiteral(source: string): { readonly value: unknown; readonly endOffset: number } {
|
|
49
54
|
const start = skipWhitespace(source, 0);
|
|
50
55
|
const prefix = /export\s+const\s+meta\s*=/y;
|
|
51
56
|
prefix.lastIndex = start;
|
|
@@ -61,83 +66,84 @@ export function extractMetaLiteral(source: string): InlineMetaLiteral {
|
|
|
61
66
|
|
|
62
67
|
const parser = new LiteralParser(source, objectStart);
|
|
63
68
|
const value = parser.parseValue();
|
|
64
|
-
|
|
65
|
-
let endOffset = skipWhitespace(source, literalEnd);
|
|
69
|
+
let endOffset = skipWhitespace(source, parser.offset);
|
|
66
70
|
if (source[endOffset] === ";") endOffset++;
|
|
67
71
|
|
|
68
|
-
return {
|
|
72
|
+
return { value, endOffset };
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Enforce the no-`import` rule on code only, so prompts may mention imports. This is a lint
|
|
77
|
+
* against honest mistakes, not a security boundary: inline code can still reach eval.
|
|
78
|
+
*/
|
|
79
|
+
function rejectImportSyntax(code: string): void {
|
|
80
|
+
// `import` as a keyword: not part of a longer identifier, a `.import` member, or an `import:` key.
|
|
81
|
+
const match = /(?<![\w$]|[^.]\.)import(?![\w$])(?!\s*:)(\s*\()?/.exec(code);
|
|
82
|
+
if (!match) return;
|
|
83
|
+
throw new InlineWorkflowCompileError(
|
|
84
|
+
match[1] === undefined
|
|
85
|
+
? "inline workflows must not contain import statements; use injected Type instead"
|
|
86
|
+
: "inline workflows must not use dynamic import()",
|
|
87
|
+
);
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
function extractDefaultWorkflowExpression(source: string, startOffset: number): string {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
90
|
+
function extractDefaultWorkflowExpression(source: string, code: string, startOffset: number): string {
|
|
91
|
+
const start = skipWhitespace(code, startOffset);
|
|
92
|
+
if (start === code.length) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
|
|
93
|
+
const prefix = /export\s+default\b\s*/y;
|
|
94
|
+
prefix.lastIndex = start;
|
|
95
|
+
if (!prefix.test(code)) {
|
|
84
96
|
throw new InlineWorkflowCompileError("inline workflow default export must directly follow the meta declaration");
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
const expressionSource = moduleSource.slice(match[0].length).trimStart();
|
|
92
|
-
const bodyOpen = findDefaultBodyOpen(expressionSource);
|
|
93
|
-
const bodyClose = findMatchingDelimiter(expressionSource, bodyOpen, "{", "}");
|
|
94
|
-
const expression = expressionSource.slice(0, bodyClose + 1);
|
|
95
|
-
const remainder = expressionSource.slice(bodyClose + 1).trim();
|
|
96
|
-
if (remainder !== "" && remainder !== ";") {
|
|
99
|
+
const expressionStart = prefix.lastIndex;
|
|
100
|
+
const expressionEnd = defaultFunctionEnd(code, expressionStart);
|
|
101
|
+
if (!/^\s*;?\s*$/.test(code.slice(expressionEnd))) {
|
|
97
102
|
throw new InlineWorkflowCompileError("inline workflow must not contain code after the default export");
|
|
98
103
|
}
|
|
99
|
-
return
|
|
104
|
+
return source.slice(expressionStart, expressionEnd);
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return bodyOpen;
|
|
107
|
+
/**
|
|
108
|
+
* End of the default export in the code-only view: the brace closing the function's block body.
|
|
109
|
+
* The parser cannot find it, because `fn, 5` and `fn\n(api)` (an immediate call) both parse as
|
|
110
|
+
* one expression; cutting here rejects them before any workflow code runs. Like the import rule,
|
|
111
|
+
* this is a lint over the masked view, not a security boundary.
|
|
112
|
+
*/
|
|
113
|
+
function defaultFunctionEnd(code: string, start: number): number {
|
|
114
|
+
const head = /async\s+(?<keyword>function\b)\s*(?:[A-Za-z_$][\w$]*\s*)?(?=\()|async\s*(?=\()|async\s+[A-Za-z_$][\w$]*\s*(?==>)/y;
|
|
115
|
+
head.lastIndex = start;
|
|
116
|
+
const match = head.exec(code);
|
|
117
|
+
if (!match) {
|
|
118
|
+
throw new InlineWorkflowCompileError("inline workflow default export must be an async function or async arrow function");
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
|
|
124
|
-
let cursor = skipWhitespace(expressionSource, paramsClose + 1);
|
|
125
|
-
if (!expressionSource.startsWith("=>", cursor)) {
|
|
126
|
-
throw new InlineWorkflowCompileError("default async arrow workflow must use =>");
|
|
127
|
-
}
|
|
128
|
-
cursor = skipWhitespace(expressionSource, cursor + 2);
|
|
129
|
-
if (expressionSource[cursor] !== "{") {
|
|
130
|
-
throw new InlineWorkflowCompileError("default async arrow workflow must use a block body");
|
|
131
|
-
}
|
|
132
|
-
return cursor;
|
|
121
|
+
let cursor = head.lastIndex;
|
|
122
|
+
if (code[cursor] === "(") cursor = skipWhitespace(code, closingBracket(code, cursor) + 1);
|
|
123
|
+
if (match.groups?.keyword === undefined) {
|
|
124
|
+
if (!code.startsWith("=>", cursor)) throw new InlineWorkflowCompileError("default async arrow workflow must use =>");
|
|
125
|
+
cursor = skipWhitespace(code, cursor + 2);
|
|
133
126
|
}
|
|
127
|
+
if (code[cursor] !== "{") throw new InlineWorkflowCompileError("default async workflow must use a block body");
|
|
128
|
+
return closingBracket(code, cursor) + 1;
|
|
129
|
+
}
|
|
134
130
|
|
|
135
|
-
|
|
131
|
+
/** Index of the bracket closing the one at `open`, counting every bracket kind in the code-only view. */
|
|
132
|
+
function closingBracket(code: string, open: number): number {
|
|
133
|
+
let depth = 0;
|
|
134
|
+
for (let index = open; index < code.length; index++) {
|
|
135
|
+
const char = code[index];
|
|
136
|
+
if (char === "(" || char === "[" || char === "{") depth++;
|
|
137
|
+
else if ((char === ")" || char === "]" || char === "}") && --depth === 0) return index;
|
|
138
|
+
}
|
|
139
|
+
throw new InlineWorkflowCompileError("inline workflow default export has an unclosed bracket");
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
|
|
139
143
|
try {
|
|
140
|
-
|
|
144
|
+
// The expression already ends at the function body's closing brace; the parentheses also make
|
|
145
|
+
// the parser reject anything that is not a single expression.
|
|
146
|
+
return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = (\n${defaultExpression}\n);\nreturn await workflow(api);`);
|
|
141
147
|
} catch (error) {
|
|
142
148
|
throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${unknownErrorMessage(error)}`);
|
|
143
149
|
}
|
|
@@ -330,56 +336,115 @@ class LiteralParser {
|
|
|
330
336
|
}
|
|
331
337
|
}
|
|
332
338
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
339
|
+
const REGEX_PREFIX_KEYWORDS = new Set([
|
|
340
|
+
"await", "case", "delete", "do", "else", "in", "instanceof", "new", "of", "return", "throw", "typeof", "void", "yield",
|
|
341
|
+
]);
|
|
342
|
+
const CONTROL_HEAD_KEYWORDS = new Set(["for", "if", "while"]);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Blank the contents of strings, template text, comments and regex literals, keeping offsets
|
|
346
|
+
* and delimiters, so keyword and bracket checks see only code. A `/` after `]`, `}` or any `)`
|
|
347
|
+
* but an `if`/`for`/`while` head's is read as division, so a regex literal opening a statement
|
|
348
|
+
* after a block's `}` stays unmasked. The parser still decides whether a script is valid.
|
|
349
|
+
*/
|
|
350
|
+
function maskNonCode(source: string): string {
|
|
351
|
+
const masked = source.split("");
|
|
352
|
+
const blank = (start: number, end: number): number => {
|
|
353
|
+
masked.fill(" ", start, end);
|
|
354
|
+
return end;
|
|
355
|
+
};
|
|
356
|
+
// One entry per open brace; true when it is a template `${` whose `}` resumes template text.
|
|
357
|
+
const braces: boolean[] = [];
|
|
358
|
+
const templateText = (start: number): number => {
|
|
359
|
+
for (let index = start; index < source.length; index++) {
|
|
360
|
+
if (source[index] === "\\") index++;
|
|
361
|
+
else if (source[index] === "`") return blank(start, index) + 1;
|
|
362
|
+
else if (source.startsWith("${", index)) {
|
|
363
|
+
braces.push(true);
|
|
364
|
+
return blank(start, index) + 2;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return blank(start, source.length);
|
|
368
|
+
};
|
|
369
|
+
// One entry per open paren; true when it opens an `if`/`for`/`while` head, after which `/` starts a regex.
|
|
370
|
+
const parens: boolean[] = [];
|
|
371
|
+
const controlHeadEnds = new Set<number>();
|
|
372
|
+
|
|
373
|
+
let index = 0;
|
|
336
374
|
while (index < source.length) {
|
|
337
375
|
const char = source[index];
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
376
|
+
if (source.startsWith("//", index)) {
|
|
377
|
+
const newline = source.indexOf("\n", index);
|
|
378
|
+
index = blank(index, newline === -1 ? source.length : newline);
|
|
379
|
+
} else if (source.startsWith("/*", index)) {
|
|
380
|
+
const close = source.indexOf("*/", index + 2);
|
|
381
|
+
index = blank(index, close === -1 ? source.length : close + 2);
|
|
382
|
+
} else if (char === '"' || char === "'") {
|
|
383
|
+
index = blank(index + 1, quotedTextEnd(source, index + 1, char)) + 1;
|
|
384
|
+
} else if (char === "`") {
|
|
385
|
+
index = templateText(index + 1);
|
|
386
|
+
} else if (char === "{") {
|
|
387
|
+
braces.push(false);
|
|
388
|
+
index++;
|
|
389
|
+
} else if (char === "}") {
|
|
390
|
+
index = braces.pop() === true ? templateText(index + 1) : index + 1;
|
|
391
|
+
} else if (char === "(") {
|
|
392
|
+
parens.push(CONTROL_HEAD_KEYWORDS.has(wordBefore(masked, index)));
|
|
393
|
+
index++;
|
|
394
|
+
} else if (char === ")") {
|
|
395
|
+
if (parens.pop() === true) controlHeadEnds.add(index);
|
|
396
|
+
index++;
|
|
397
|
+
} else if (char === "/" && regexCanStart(masked, index, controlHeadEnds)) {
|
|
398
|
+
const close = regexLiteralEnd(source, index + 1);
|
|
399
|
+
index = close === undefined ? index + 1 : blank(index + 1, close) + 1;
|
|
400
|
+
} else {
|
|
401
|
+
index++;
|
|
341
402
|
}
|
|
342
|
-
if (char === "/" && source[index + 1] === "/") {
|
|
343
|
-
index = skipLineComment(source, index);
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
if (char === "/" && source[index + 1] === "*") {
|
|
347
|
-
index = skipBlockComment(source, index);
|
|
348
|
-
continue;
|
|
349
|
-
}
|
|
350
|
-
if (char === open) depth++;
|
|
351
|
-
if (char === close) {
|
|
352
|
-
depth--;
|
|
353
|
-
if (depth === 0) return index;
|
|
354
|
-
}
|
|
355
|
-
index++;
|
|
356
403
|
}
|
|
357
|
-
|
|
404
|
+
return masked.join("");
|
|
358
405
|
}
|
|
359
406
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
407
|
+
/** Index of the closing quote, or of the line break that leaves a broken string confined to one line. */
|
|
408
|
+
function quotedTextEnd(source: string, start: number, quote: string): number {
|
|
409
|
+
for (let index = start; index < source.length; index++) {
|
|
363
410
|
const char = source[index];
|
|
364
|
-
if (char === "\\")
|
|
365
|
-
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
if (char === quote) return index + 1;
|
|
369
|
-
index++;
|
|
411
|
+
if (char === "\\") index++;
|
|
412
|
+
else if (char === quote || char === "\n") return index;
|
|
370
413
|
}
|
|
371
|
-
|
|
414
|
+
return source.length;
|
|
372
415
|
}
|
|
373
416
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
417
|
+
/** Whether a `/` here starts a regex literal, judged by the code token before it. */
|
|
418
|
+
function regexCanStart(masked: readonly string[], slash: number, controlHeadEnds: ReadonlySet<number>): boolean {
|
|
419
|
+
let end = slash - 1;
|
|
420
|
+
while (end >= 0 && /\s/.test(masked[end])) end--;
|
|
421
|
+
if (end < 0) return true;
|
|
422
|
+
if (masked[end] === ")") return controlHeadEnds.has(end);
|
|
423
|
+
if (/[\w$]/.test(masked[end])) return REGEX_PREFIX_KEYWORDS.has(wordBefore(masked, slash));
|
|
424
|
+
return !/[\]}"'`]/.test(masked[end]);
|
|
377
425
|
}
|
|
378
426
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
427
|
+
/** The identifier or keyword ending just before `index`, skipping whitespace; empty when there is none. */
|
|
428
|
+
function wordBefore(masked: readonly string[], index: number): string {
|
|
429
|
+
let end = index;
|
|
430
|
+
while (end > 0 && /\s/.test(masked[end - 1])) end--;
|
|
431
|
+
let start = end;
|
|
432
|
+
while (start > 0 && /[\w$]/.test(masked[start - 1])) start--;
|
|
433
|
+
return masked.slice(start, end).join("");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Index of the closing `/` of a regex literal body starting at `start`, if it closes on this line. */
|
|
437
|
+
function regexLiteralEnd(source: string, start: number): number | undefined {
|
|
438
|
+
let inClass = false;
|
|
439
|
+
for (let index = start; index < source.length; index++) {
|
|
440
|
+
const char = source[index];
|
|
441
|
+
if (char === "\\") index++;
|
|
442
|
+
else if (char === "\n") return undefined;
|
|
443
|
+
else if (char === "[") inClass = true;
|
|
444
|
+
else if (char === "]") inClass = false;
|
|
445
|
+
else if (char === "/" && !inClass) return index;
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
383
448
|
}
|
|
384
449
|
|
|
385
450
|
function skipWhitespace(source: string, start: number): number {
|
|
@@ -64,18 +64,6 @@ export function workflowJournalPath(cwd: string, runId: string): string {
|
|
|
64
64
|
return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}.jsonl`);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
|
|
68
|
-
const capture = captureAgentJournalKey(prompt, opts, worktreeBaseline);
|
|
69
|
-
return capture.kind === "verified" ? capture.key : `agent:unverifiable:${randomUUID()}`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
|
|
73
|
-
const capture = captureAgentCallHash(prompt, opts, worktreeBaseline);
|
|
74
|
-
return capture.kind === "verified"
|
|
75
|
-
? capture.hash
|
|
76
|
-
: createHash("sha256").update(`unverifiable:${randomUUID()}`).digest("hex");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
67
|
/** Capture a replay key without allowing hostile or oversized schemas to escape as exceptions. */
|
|
80
68
|
export function captureAgentJournalKey(
|
|
81
69
|
prompt: string,
|
|
@@ -115,6 +103,7 @@ function captureAgentCallHash(
|
|
|
115
103
|
skills: opts.skills,
|
|
116
104
|
schema: opts.schema,
|
|
117
105
|
isolation: opts.isolation,
|
|
106
|
+
candidatePatch: opts.candidatePatch ? { baselineOid: opts.candidatePatch.baselineOid, hash: createHash("sha256").update(opts.candidatePatch.patch).digest("hex") } : undefined,
|
|
118
107
|
worktreeBaseline: worktreeBaseline
|
|
119
108
|
? {
|
|
120
109
|
ref: worktreeBaseline.ref,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { relative, sep } from "node:path";
|
|
1
3
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
5
|
import {
|
|
@@ -78,7 +80,7 @@ export function registerWorkflowModelProfileCommand(pi: ExtensionAPI): void {
|
|
|
78
80
|
const prefix = command.kind === "status"
|
|
79
81
|
? "Workflow model profiles"
|
|
80
82
|
: `${command.profile} ${command.kind === "set" ? "updated" : "cleared"} in ${command.scope} config`;
|
|
81
|
-
ctx.ui.notify(`${prefix}\n${formatWorkflowModelProfiles(profiles, paths)}`, "info");
|
|
83
|
+
ctx.ui.notify(`${prefix}\n${formatWorkflowModelProfiles(profiles, paths, ctx.cwd)}`, "info");
|
|
82
84
|
} catch (error) {
|
|
83
85
|
ctx.ui.notify(unknownErrorMessage(error), "error");
|
|
84
86
|
}
|
|
@@ -152,16 +154,43 @@ export function parseWorkflowModelsCommand(args: string): WorkflowModelsCommand
|
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
|
|
157
|
+
/**
|
|
158
|
+
* An aligned plain-text table (notifications cannot style or measure the terminal):
|
|
159
|
+
* one row per profile, then the two config files with home and project paths shortened.
|
|
160
|
+
*/
|
|
161
|
+
export function formatWorkflowModelProfiles(
|
|
156
162
|
profiles: ResolvedWorkflowModelProfiles,
|
|
157
163
|
paths: WorkflowModelProfilePaths,
|
|
164
|
+
cwd: string,
|
|
165
|
+
home: string = homedir(),
|
|
158
166
|
): string {
|
|
159
|
-
const
|
|
167
|
+
const rows = WORKFLOW_MODEL_PROFILE_NAMES.map((name) => {
|
|
160
168
|
const profile = profiles[name];
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
169
|
+
return [
|
|
170
|
+
name,
|
|
171
|
+
profile.model ? `${profile.model.provider}/${profile.model.id}` : "no host model",
|
|
172
|
+
profile.thinkingLevel ?? "host",
|
|
173
|
+
profile.source === "host" ? "host fallback" : `${profile.source} config`,
|
|
174
|
+
];
|
|
165
175
|
});
|
|
166
|
-
|
|
176
|
+
const configs = [
|
|
177
|
+
["user", displayPath(paths.user, cwd, home)],
|
|
178
|
+
["project", `${displayPath(paths.project, cwd, home)} (overrides user)`],
|
|
179
|
+
];
|
|
180
|
+
return [...alignColumns([["profile", "model", "thinking", "source"], ...rows]), "", ...alignColumns(configs)].join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function alignColumns(rows: readonly (readonly string[])[]): string[] {
|
|
184
|
+
const widths = rows.reduce<number[]>(
|
|
185
|
+
(max, row) => row.map((cell, index) => Math.max(max[index] ?? 0, cell.length)),
|
|
186
|
+
[],
|
|
187
|
+
);
|
|
188
|
+
return rows.map((row) => ` ${row.map((cell, index) => (index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0))).join(" ")}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Project paths relative to the cwd, others with the home directory as `~`. */
|
|
192
|
+
function displayPath(path: string, cwd: string, home: string): string {
|
|
193
|
+
if (path.startsWith(cwd + sep)) return relative(cwd, path);
|
|
194
|
+
if (home && path.startsWith(home + sep)) return `~${path.slice(home.length)}`;
|
|
195
|
+
return path;
|
|
167
196
|
}
|
|
@@ -5,6 +5,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
6
|
import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { isMissingPathError } from "./filesystem-error.ts";
|
|
8
|
+
import { isRecord } from "./guards.ts";
|
|
8
9
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
9
10
|
|
|
10
11
|
export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
|
|
@@ -43,19 +44,6 @@ export interface WorkflowModelRouteRequest {
|
|
|
43
44
|
readonly profile?: WorkflowModelProfileName;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export class WorkflowModelProfileConfigError extends Error {
|
|
47
|
-
override readonly name = "WorkflowModelProfileConfigError";
|
|
48
|
-
readonly code = "WORKFLOW_MODEL_PROFILE_CONFIG";
|
|
49
|
-
|
|
50
|
-
constructor(
|
|
51
|
-
message: string,
|
|
52
|
-
readonly configPath: string,
|
|
53
|
-
readonly profile?: WorkflowModelProfileName,
|
|
54
|
-
) {
|
|
55
|
-
super(message);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
47
|
export const WORKFLOW_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
|
|
60
48
|
const THINKING_LEVELS = new Set<ThinkingLevel>(WORKFLOW_THINKING_LEVELS);
|
|
61
49
|
const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> = {
|
|
@@ -140,19 +128,15 @@ export function readWorkflowModelProfileFile(configPath: string): WorkflowModelP
|
|
|
140
128
|
contents = readFileSync(configPath, "utf8");
|
|
141
129
|
} catch (error) {
|
|
142
130
|
if (isMissingPathError(error)) return { profiles: {} };
|
|
143
|
-
throw new
|
|
131
|
+
throw new Error(
|
|
144
132
|
`Could not read workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`,
|
|
145
|
-
configPath,
|
|
146
133
|
);
|
|
147
134
|
}
|
|
148
135
|
let parsed: unknown;
|
|
149
136
|
try {
|
|
150
137
|
parsed = JSON.parse(contents);
|
|
151
138
|
} catch (error) {
|
|
152
|
-
throw new
|
|
153
|
-
`Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`,
|
|
154
|
-
configPath,
|
|
155
|
-
);
|
|
139
|
+
throw new Error(`Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
|
|
156
140
|
}
|
|
157
141
|
return parseWorkflowModelProfileFile(parsed, configPath);
|
|
158
142
|
}
|
|
@@ -198,10 +182,10 @@ function parseWorkflowModelProfileFile(value: unknown, configPath: string): Work
|
|
|
198
182
|
if (!isWorkflowModelProfileName(name)) {
|
|
199
183
|
throw invalidConfig(configPath, `unknown profile "${name}"; expected small, medium, or big`);
|
|
200
184
|
}
|
|
201
|
-
if (!isRecord(profile)) throw invalidConfig(configPath, `profile "${name}" must be an object
|
|
202
|
-
assertOnlyKeys(profile, ["model", "thinkingLevel"], configPath
|
|
185
|
+
if (!isRecord(profile)) throw invalidConfig(configPath, `profile "${name}" must be an object`);
|
|
186
|
+
assertOnlyKeys(profile, ["model", "thinkingLevel"], configPath);
|
|
203
187
|
if (typeof profile.model !== "string") {
|
|
204
|
-
throw invalidConfig(configPath, `profile "${name}" requires a provider-qualified "model" string
|
|
188
|
+
throw invalidConfig(configPath, `profile "${name}" requires a provider-qualified "model" string`);
|
|
205
189
|
}
|
|
206
190
|
assertThinkingLevel(profile.thinkingLevel, configPath, name);
|
|
207
191
|
profiles[name] = {
|
|
@@ -218,29 +202,18 @@ function resolveConfiguredModel(
|
|
|
218
202
|
modelRegistry: Pick<ModelRegistry, "find">,
|
|
219
203
|
configPath: string,
|
|
220
204
|
): Model<Api> {
|
|
205
|
+
// Exactly "provider/model": no whitespace, a non-empty provider, and a model id that neither starts nor ends with "/".
|
|
221
206
|
const slash = modelRef.indexOf("/");
|
|
222
|
-
if (modelRef
|
|
223
|
-
throw invalidConfig(
|
|
224
|
-
configPath,
|
|
225
|
-
`profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`,
|
|
226
|
-
name,
|
|
227
|
-
);
|
|
207
|
+
if (/\s/.test(modelRef) || slash <= 0 || modelRef.endsWith("/") || modelRef[slash + 1] === "/") {
|
|
208
|
+
throw invalidConfig(configPath, `profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`);
|
|
228
209
|
}
|
|
229
210
|
const provider = modelRef.slice(0, slash);
|
|
230
211
|
const modelId = modelRef.slice(slash + 1);
|
|
231
|
-
if (/\s/.test(provider) || modelId.startsWith("/") || modelId.endsWith("/") || /\s/.test(modelId)) {
|
|
232
|
-
throw invalidConfig(
|
|
233
|
-
configPath,
|
|
234
|
-
`profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`,
|
|
235
|
-
name,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
212
|
const model = modelRegistry.find(provider, modelId);
|
|
239
213
|
if (!model) {
|
|
240
214
|
throw invalidConfig(
|
|
241
215
|
configPath,
|
|
242
216
|
`profile "${name}" references unavailable model "${modelRef}"; choose an exact model shown by /model and update it with /workflow:models set ${name} provider/model`,
|
|
243
|
-
name,
|
|
244
217
|
);
|
|
245
218
|
}
|
|
246
219
|
return model;
|
|
@@ -257,21 +230,12 @@ function assertThinkingLevel(
|
|
|
257
230
|
profile: WorkflowModelProfileName,
|
|
258
231
|
): asserts value is ThinkingLevel | undefined {
|
|
259
232
|
if (value === undefined || (typeof value === "string" && isWorkflowThinkingLevel(value))) return;
|
|
260
|
-
throw invalidConfig(
|
|
261
|
-
configPath,
|
|
262
|
-
`profile "${profile}" thinkingLevel must be off, minimal, low, medium, high, xhigh, or max`,
|
|
263
|
-
profile,
|
|
264
|
-
);
|
|
233
|
+
throw invalidConfig(configPath, `profile "${profile}" thinkingLevel must be off, minimal, low, medium, high, xhigh, or max`);
|
|
265
234
|
}
|
|
266
235
|
|
|
267
|
-
function assertOnlyKeys(
|
|
268
|
-
value: Record<string, unknown>,
|
|
269
|
-
allowed: readonly string[],
|
|
270
|
-
configPath: string,
|
|
271
|
-
profile?: WorkflowModelProfileName,
|
|
272
|
-
): void {
|
|
236
|
+
function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string[], configPath: string): void {
|
|
273
237
|
const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
|
|
274
|
-
if (unexpected) throw invalidConfig(configPath, `unexpected key "${unexpected}"
|
|
238
|
+
if (unexpected) throw invalidConfig(configPath, `unexpected key "${unexpected}"`);
|
|
275
239
|
}
|
|
276
240
|
|
|
277
241
|
function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModelProfileFile): void {
|
|
@@ -286,25 +250,10 @@ function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModel
|
|
|
286
250
|
} catch {
|
|
287
251
|
// Best effort: preserve the original write error.
|
|
288
252
|
}
|
|
289
|
-
throw new
|
|
290
|
-
`Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`,
|
|
291
|
-
configPath,
|
|
292
|
-
);
|
|
253
|
+
throw new Error(`Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`);
|
|
293
254
|
}
|
|
294
255
|
}
|
|
295
256
|
|
|
296
|
-
function invalidConfig(
|
|
297
|
-
configPath:
|
|
298
|
-
reason: string,
|
|
299
|
-
profile?: WorkflowModelProfileName,
|
|
300
|
-
): WorkflowModelProfileConfigError {
|
|
301
|
-
return new WorkflowModelProfileConfigError(
|
|
302
|
-
`Invalid workflow model profile config at ${configPath}: ${reason}.`,
|
|
303
|
-
configPath,
|
|
304
|
-
profile,
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
309
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
257
|
+
function invalidConfig(configPath: string, reason: string): Error {
|
|
258
|
+
return new Error(`Invalid workflow model profile config at ${configPath}: ${reason}.`);
|
|
310
259
|
}
|