pi-workflow-engine 0.11.0 → 0.12.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 +255 -97
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +72 -141
- package/USAGE.md +211 -17
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { sessionKey } from "../session-identity.ts";
|
|
3
3
|
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "../options.ts";
|
|
4
4
|
import type { LoadedWorkflow } from "../types.ts";
|
|
5
5
|
import type { WorkflowExecution } from "../workflow-execution.ts";
|
|
@@ -42,7 +42,7 @@ export class ReviewSessionCoordinator {
|
|
|
42
42
|
): void {
|
|
43
43
|
const name = execution.envelope.name;
|
|
44
44
|
if (name !== "code-review") return;
|
|
45
|
-
const key =
|
|
45
|
+
const key = sessionKey(ctx);
|
|
46
46
|
const report = codeReviewReport(name, execution.envelope.result);
|
|
47
47
|
if (!report) {
|
|
48
48
|
this.sessions.delete(key);
|
|
@@ -72,7 +72,7 @@ export class ReviewSessionCoordinator {
|
|
|
72
72
|
});
|
|
73
73
|
if (decision.kind !== "open") return;
|
|
74
74
|
|
|
75
|
-
const retained = this.sessions.get(
|
|
75
|
+
const retained = this.sessions.get(sessionKey(ctx));
|
|
76
76
|
if (!retained) return;
|
|
77
77
|
await this.openAndHandle(ctx, retained, decision.issues);
|
|
78
78
|
}
|
|
@@ -82,7 +82,7 @@ export class ReviewSessionCoordinator {
|
|
|
82
82
|
ctx.ui.notify("Code-review results viewer requires the TUI", "warning");
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
|
-
const retained = this.sessions.get(
|
|
85
|
+
const retained = this.sessions.get(sessionKey(ctx));
|
|
86
86
|
if (!retained) {
|
|
87
87
|
ctx.ui.notify("No code-review result is available yet. Run /workflow code-review first.", "warning");
|
|
88
88
|
return;
|
|
@@ -96,7 +96,7 @@ export class ReviewSessionCoordinator {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
dispose(ctx: ExtensionContext): void {
|
|
99
|
-
this.sessions.delete(
|
|
99
|
+
this.sessions.delete(sessionKey(ctx));
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
private async openAndHandle(
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { throwIfAborted } from "../cancellation.ts";
|
|
3
|
+
import { captureDiffTarget, type DiffCaptureFailure } from "../diff-capture.ts";
|
|
3
4
|
import {
|
|
4
|
-
|
|
5
|
-
type DiffCaptureFailure,
|
|
5
|
+
reviewGitDiffBaseline,
|
|
6
6
|
type GitReviewDiffTarget,
|
|
7
7
|
type PullRequestReviewDiffTarget,
|
|
8
8
|
type ReviewDiffTarget,
|
|
9
|
-
|
|
10
|
-
} from "../diff-capture.ts";
|
|
9
|
+
} from "../review-diff-target.ts";
|
|
11
10
|
import { runBoundedProcess } from "../process-runner.ts";
|
|
12
11
|
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
13
12
|
import type { WorktreeBaseline } from "../worktree.ts";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
|
|
4
|
+
export type GitDiffBaselineTarget =
|
|
5
|
+
| { readonly kind: "working-tree" }
|
|
6
|
+
| { readonly kind: "index" }
|
|
7
|
+
| { readonly kind: "range"; readonly ref: string };
|
|
8
|
+
|
|
9
|
+
export const GitReviewDiffTargetSchema = Type.Object({
|
|
10
|
+
kind: Type.Literal("git"),
|
|
11
|
+
args: Type.Array(Type.String()),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const PullRequestReviewDiffTargetSchema = Type.Object({
|
|
15
|
+
kind: Type.Literal("pull-request"),
|
|
16
|
+
number: Type.Integer({ minimum: 1 }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
|
|
20
|
+
|
|
21
|
+
export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
|
|
22
|
+
export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
|
|
23
|
+
|
|
24
|
+
/** Serializable semantic identity of the diff being reviewed. */
|
|
25
|
+
export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
|
|
26
|
+
|
|
27
|
+
const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
|
|
28
|
+
const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
|
|
29
|
+
const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
|
|
30
|
+
|
|
31
|
+
export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
|
|
32
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
33
|
+
if (tokens.length < 2) return { error: "empty or incomplete diff command" };
|
|
34
|
+
if (tokens[0] === "git" && tokens[1] === "diff") {
|
|
35
|
+
const parsed = parseGitDiff(tokens);
|
|
36
|
+
return "error" in parsed ? parsed : parsed.target;
|
|
37
|
+
}
|
|
38
|
+
if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
|
|
39
|
+
const number = Number(tokens[3]);
|
|
40
|
+
if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
|
|
41
|
+
const flags = tokens.slice(4);
|
|
42
|
+
if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
|
|
43
|
+
const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
|
|
44
|
+
if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
|
|
45
|
+
return { kind: "pull-request", number };
|
|
46
|
+
}
|
|
47
|
+
return { error: "diff command is not in the git/gh allowlist" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseGitDiff(
|
|
51
|
+
tokens: readonly string[],
|
|
52
|
+
): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
|
|
53
|
+
const args = tokens.slice(2);
|
|
54
|
+
const safeArgs = ["diff", "--no-ext-diff"];
|
|
55
|
+
const operands: string[] = [];
|
|
56
|
+
let staged = false;
|
|
57
|
+
let pathMode = false;
|
|
58
|
+
|
|
59
|
+
for (const token of args) {
|
|
60
|
+
if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
|
|
61
|
+
if (token === "--") {
|
|
62
|
+
pathMode = true;
|
|
63
|
+
safeArgs.push(token);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!pathMode && token.startsWith("-")) {
|
|
67
|
+
if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
|
|
68
|
+
staged ||= token === "--cached" || token === "--staged";
|
|
69
|
+
if (token !== "--no-ext-diff") safeArgs.push(token);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!pathMode) operands.push(token);
|
|
73
|
+
safeArgs.push(token);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseline = classifyGitDiffBaseline(staged, operands);
|
|
77
|
+
return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
|
|
81
|
+
if (operands.length > 1) {
|
|
82
|
+
return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
|
|
83
|
+
}
|
|
84
|
+
if (staged) return { kind: "index" };
|
|
85
|
+
|
|
86
|
+
const range = operands.find((operand) => operand.includes(".."));
|
|
87
|
+
if (range) {
|
|
88
|
+
const separator = range.includes("...") ? "..." : "..";
|
|
89
|
+
return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { kind: "working-tree" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isAllowedGitDiffFlag(token: string): boolean {
|
|
96
|
+
return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
|
|
100
|
+
return target.kind === "pull-request"
|
|
101
|
+
? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
|
|
102
|
+
: { file: "git", args: target.args };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
|
|
106
|
+
export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
|
|
107
|
+
const command = reviewDiffCommand(target);
|
|
108
|
+
return [command.file, ...command.args].join(" ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
|
|
112
|
+
export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
|
|
113
|
+
const parsed = parseGitDiff(["git", ...target.args]);
|
|
114
|
+
if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
|
|
115
|
+
return parsed.baseline;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Validate persisted targets by round-tripping through the single command allowlist parser. */
|
|
119
|
+
export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
|
|
120
|
+
if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
|
|
121
|
+
const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
|
|
122
|
+
return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
|
|
126
|
+
if (left.kind !== right.kind) return false;
|
|
127
|
+
if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
|
|
128
|
+
if (right.kind !== "git" || left.args.length !== right.args.length) return false;
|
|
129
|
+
return left.args.every((arg, index) => arg === right.args[index]);
|
|
130
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Stable identity for state retained for the lifetime of a pi session. */
|
|
4
|
+
export function sessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
|
|
5
|
+
return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
|
|
6
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const MAX_SCHEMA_REPAIR_ATTEMPTS = 2;
|
|
2
|
+
export const STRUCTURED_OUTPUT_ERROR_CODE = "WORKFLOW_STRUCTURED_OUTPUT_NONCOMPLIANCE";
|
|
3
|
+
|
|
4
|
+
export interface StructuredOutputErrorDetails {
|
|
5
|
+
readonly agentLabel: string;
|
|
6
|
+
readonly promptAttempts: number;
|
|
7
|
+
readonly repairAttempts: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Recoverable failure raised when a schema agent never calls `final_answer`. */
|
|
11
|
+
export class WorkflowStructuredOutputError extends Error {
|
|
12
|
+
readonly name = "WorkflowStructuredOutputError";
|
|
13
|
+
readonly code = STRUCTURED_OUTPUT_ERROR_CODE;
|
|
14
|
+
readonly details: StructuredOutputErrorDetails;
|
|
15
|
+
|
|
16
|
+
constructor(agentLabel: string, repairAttempts: number) {
|
|
17
|
+
const promptAttempts = repairAttempts + 1;
|
|
18
|
+
super(
|
|
19
|
+
`Schema agent "${agentLabel}" did not call final_answer after ${promptAttempts} prompt attempts (${repairAttempts} repairs).`,
|
|
20
|
+
);
|
|
21
|
+
this.details = { agentLabel, promptAttempts, repairAttempts };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toJSON(): {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly details: StructuredOutputErrorDetails;
|
|
29
|
+
} {
|
|
30
|
+
return { name: this.name, message: this.message, code: this.code, details: this.details };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentToolHint } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Raised before prompting when required semantic tool capabilities are unavailable. */
|
|
5
|
+
export class WorkflowToolHintUnavailableError extends Error {
|
|
6
|
+
readonly code = "WORKFLOW_TOOL_HINT_UNAVAILABLE";
|
|
7
|
+
readonly hints: readonly AgentToolHint[];
|
|
8
|
+
|
|
9
|
+
constructor(hints: readonly AgentToolHint[]) {
|
|
10
|
+
super(`Required workflow tool capability unavailable: ${hints.join(", ")}`);
|
|
11
|
+
this.name = "WorkflowToolHintUnavailableError";
|
|
12
|
+
this.hints = [...hints];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function matchesAgentToolHint(tool: ToolInfo, hint: AgentToolHint): boolean {
|
|
17
|
+
return hint === "search" ? isSearchLikeTool(tool) : isExternalSearchLikeTool(tool);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isExternalSearchLikeTool(tool: ToolInfo): boolean {
|
|
21
|
+
if (isMutationLikeToolName(tool.name) || tool.sourceInfo.source === "builtin") return false;
|
|
22
|
+
const name = tool.name.toLowerCase();
|
|
23
|
+
const description = [tool.description, ...(tool.promptGuidelines ?? [])].join(" ").toLowerCase();
|
|
24
|
+
const text = `${name} ${description}`;
|
|
25
|
+
const hasResearchAction = /\b(?:browse|browser|search|fetch|extract|open|read|get)\b/.test(text)
|
|
26
|
+
|| /(?:^|[-_])(?:browse|browser|search|fetch|extract)(?:$|[-_])/.test(name)
|
|
27
|
+
|| /(?:^|[^a-z0-9])web(?:$|[^a-z0-9])/.test(name);
|
|
28
|
+
const hasExternalMedium = /(?:^|[-_])(?:web|browser|url|http|https|tavily|exa|firecrawl|brave|serper|parallel|perplexity)(?:$|[-_])/.test(name)
|
|
29
|
+
|| /\b(?:search|browse)\s+(?:the\s+)?(?:web|internet)\b/.test(description)
|
|
30
|
+
|| /\b(?:internet|web) search(?: engine)?\b/.test(description)
|
|
31
|
+
|| /\b(?:fetch|extract|open|read|get)\s+(?:(?:the|a|an)\s+)?(?:url|web\s?page|website|https? page)\b/.test(description);
|
|
32
|
+
return hasResearchAction && hasExternalMedium;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isSearchLikeTool(tool: ToolInfo): boolean {
|
|
36
|
+
if (isMutationLikeToolName(tool.name)) return false;
|
|
37
|
+
const name = tool.name.toLowerCase();
|
|
38
|
+
if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
|
|
39
|
+
return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isMutationLikeToolName(name: string): boolean {
|
|
43
|
+
return /(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)/.test(name.toLowerCase());
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
3
4
|
import { hashIdentity } from "./identity-fingerprint.ts";
|
|
4
5
|
import { logicalWorkspacePath, portableRelativePath } from "./replay-path-identity.ts";
|
|
5
6
|
import { captureTreeFingerprint, isPathWithin, validateTreeFile, type FingerprintCapture } from "./tree-fingerprint.ts";
|
|
@@ -147,7 +148,3 @@ function nonEmptyString(value: string, label: string): string {
|
|
|
147
148
|
if (value.length === 0) throw new Error(`${label} is empty`);
|
|
148
149
|
return value;
|
|
149
150
|
}
|
|
150
|
-
|
|
151
|
-
function isMissingPathError(error: unknown): boolean {
|
|
152
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
153
|
-
}
|
|
@@ -3,6 +3,7 @@ import { constants, type BigIntStats } from "node:fs";
|
|
|
3
3
|
import { lstat, open, readdir, type FileHandle } from "node:fs/promises";
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { throwIfAborted } from "./cancellation.ts";
|
|
6
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
6
7
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
8
|
|
|
8
9
|
export type FingerprintCapture =
|
|
@@ -398,7 +399,3 @@ function isExcludedAncestor(path: string, excluded: ReadonlySet<string> | undefi
|
|
|
398
399
|
}
|
|
399
400
|
return false;
|
|
400
401
|
}
|
|
401
|
-
|
|
402
|
-
function isMissingPathError(error: unknown): boolean {
|
|
403
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
404
|
-
}
|
|
@@ -3,9 +3,12 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
|
3
3
|
import type { WorkflowBudget } from "./budget.ts";
|
|
4
4
|
import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
|
|
5
5
|
import type { PerfSink, PerfSnapshot } from "./perf.ts";
|
|
6
|
-
import type { WorkflowProgressSnapshot } from "./progress.ts";
|
|
6
|
+
import type { WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
7
7
|
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
8
8
|
import type { WorktreeBaseline } from "./worktree.ts";
|
|
9
|
+
import type { WorkflowModelProfileName } from "./model-profiles.ts";
|
|
10
|
+
|
|
11
|
+
export type { WorkflowLaneItemStatus } from "./progress-types.ts";
|
|
9
12
|
|
|
10
13
|
/** A reference to a registered workflow by name. */
|
|
11
14
|
export type WorkflowRef = string;
|
|
@@ -29,6 +32,13 @@ export interface WorkflowRunMetadata {
|
|
|
29
32
|
readonly runId: string;
|
|
30
33
|
readonly resumedFromRunId?: string;
|
|
31
34
|
readonly journalPath: string;
|
|
35
|
+
readonly recordPath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorkflowBackgroundOrigin {
|
|
39
|
+
/** Stable pi session id used to route and deduplicate completion delivery. */
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
readonly requestedAt: number;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface WorkflowRunOptions {
|
|
@@ -36,12 +46,30 @@ export interface WorkflowRunOptions {
|
|
|
36
46
|
perf?: boolean;
|
|
37
47
|
concurrency?: number;
|
|
38
48
|
parallelSubmissionLimit?: number;
|
|
39
|
-
/**
|
|
49
|
+
/** Maximum number of agent calls that may reach a live model session during this run. */
|
|
50
|
+
maxAgents?: number;
|
|
51
|
+
/** Maximum live duration of one agent call in milliseconds. */
|
|
52
|
+
agentTimeoutMs?: number;
|
|
53
|
+
/** Retry count for transient provider/session failures. Defaults to zero. */
|
|
54
|
+
agentRetries?: number;
|
|
55
|
+
/** Explicitly schedule resumable background runs after recognized provider usage windows. */
|
|
56
|
+
autoResumeOnUsageLimit?: boolean;
|
|
57
|
+
/** Maximum recognized usage-limit pauses before automatic scheduling stops. */
|
|
58
|
+
usageLimitMaxAttempts?: number;
|
|
59
|
+
/** Maximum delay accepted from a provider reset hint. */
|
|
60
|
+
usageLimitMaxDelayMs?: number;
|
|
61
|
+
/** Internal count carried into a scheduler-created successor run. */
|
|
62
|
+
usageLimitAttempt?: number;
|
|
63
|
+
/** Output-token ceiling across recorded subagent attempts. Omit for no limit (budget.total === null). */
|
|
40
64
|
budget?: number;
|
|
41
65
|
/** Internal/test override for the generated run id. Omit to generate a new id. */
|
|
42
66
|
runId?: string;
|
|
67
|
+
/** Internal origin metadata for an explicitly backgrounded tool invocation. */
|
|
68
|
+
background?: WorkflowBackgroundOrigin;
|
|
43
69
|
/** Replay completed agent results from this prior run id when call and execution context still match. */
|
|
44
70
|
resumeFromRunId?: string;
|
|
71
|
+
/** Explicitly allow resume to ignore only a workflow-source fingerprint mismatch. */
|
|
72
|
+
resumeEditedWorkflow?: boolean;
|
|
45
73
|
resultViewer?: "open" | "skip";
|
|
46
74
|
/** Additional abort signal to compose with the host context signal. */
|
|
47
75
|
signal?: AbortSignal;
|
|
@@ -66,9 +94,7 @@ export interface WorkflowProgressSource {
|
|
|
66
94
|
}
|
|
67
95
|
|
|
68
96
|
/** Options for a single `agent()` call. */
|
|
69
|
-
export type
|
|
70
|
-
|
|
71
|
-
export type AgentToolHint = "search";
|
|
97
|
+
export type AgentToolHint = "search" | "external-search";
|
|
72
98
|
export type AgentResumePolicy = "read-only" | "off";
|
|
73
99
|
|
|
74
100
|
export interface IsolatedAgentResult<T> {
|
|
@@ -96,12 +122,15 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
96
122
|
/** Phase to group this agent under in the progress tree. */
|
|
97
123
|
phase?: string;
|
|
98
124
|
/**
|
|
99
|
-
* Optional model id.
|
|
100
|
-
*
|
|
125
|
+
* Optional model id. Overrides profile routing; when both model and profile are
|
|
126
|
+
* omitted, inherit the host model. Explicit refs are strict: bare ids resolve as
|
|
127
|
+
* Anthropic shorthand; use "provider/id" for other providers.
|
|
101
128
|
*/
|
|
102
129
|
model?: string;
|
|
103
|
-
/** Reasoning effort for this agent. */
|
|
130
|
+
/** Reasoning effort for this agent. Overrides the profile's configured effort. */
|
|
104
131
|
thinkingLevel?: ThinkingLevel;
|
|
132
|
+
/** Exact configured model route to use when model/thinkingLevel do not override it. */
|
|
133
|
+
profile?: WorkflowModelProfileName;
|
|
105
134
|
/**
|
|
106
135
|
* Stable identity hint for resume replay. Use this for repeated logical calls
|
|
107
136
|
* with identical prompts/options, e.g. `${stage}:${item.id}`.
|
|
@@ -126,11 +155,13 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
126
155
|
/** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
|
|
127
156
|
tools?: string[];
|
|
128
157
|
/**
|
|
129
|
-
* Dynamically include installed tools matching semantic categories.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
158
|
+
* Dynamically include installed tools matching semantic categories. "search"
|
|
159
|
+
* matches local grep/find/code-search tools; "external-search" matches installed
|
|
160
|
+
* web search, browsing, and URL extraction tools.
|
|
132
161
|
*/
|
|
133
162
|
toolHints?: readonly AgentToolHint[];
|
|
163
|
+
/** Fail before prompting unless every semantic tool hint matched an installed tool. */
|
|
164
|
+
requireToolHints?: boolean;
|
|
134
165
|
/**
|
|
135
166
|
* Skill names to expose to this subagent. Subagents receive no skills by default.
|
|
136
167
|
* When omitted, clear prompt text such as `/skill:name`, `include skill name`, or
|
|
@@ -138,7 +169,10 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
138
169
|
* prompt inference for prompts that mention skills only as subject matter.
|
|
139
170
|
*/
|
|
140
171
|
skills?: readonly string[];
|
|
141
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* typebox schema. The agent must call the validated `final_answer` tool; bounded
|
|
174
|
+
* repair exhaustion throws `WorkflowStructuredOutputError` instead of returning null.
|
|
175
|
+
*/
|
|
142
176
|
schema?: S;
|
|
143
177
|
}
|
|
144
178
|
|
|
@@ -151,11 +185,11 @@ export interface WorkflowApi {
|
|
|
151
185
|
agent<S extends TSchema>(
|
|
152
186
|
prompt: string,
|
|
153
187
|
opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
|
|
154
|
-
): Promise<IsolatedAgentResult<Static<S
|
|
188
|
+
): Promise<IsolatedAgentResult<Static<S>>>;
|
|
155
189
|
/** Run a text subagent in a disposable worktree and return its final text plus patch. */
|
|
156
190
|
agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
|
|
157
|
-
/** Run a subagent and return
|
|
158
|
-
agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S
|
|
191
|
+
/** Run a subagent and return validated structured output; rejects with a recoverable typed error on repair exhaustion. */
|
|
192
|
+
agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
|
|
159
193
|
/** Run a subagent and return its final assistant text. */
|
|
160
194
|
agent(prompt: string, opts?: AgentOptions): Promise<string>;
|
|
161
195
|
/**
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { sliceByColumn, visibleWidth, type EditorComponent } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export type DynamaxEffect = "shine" | "static" | "off";
|
|
4
|
+
|
|
5
|
+
export interface DynamaxAnimationScheduler {
|
|
6
|
+
now(): number;
|
|
7
|
+
schedule(callback: () => void, delayMs: number): () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DynamaxEditorDecoration {
|
|
11
|
+
readonly editor: EditorComponent;
|
|
12
|
+
dispose(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface DynamaxEditorDecorationOptions {
|
|
16
|
+
effect: Exclude<DynamaxEffect, "off">;
|
|
17
|
+
scheduler?: DynamaxAnimationScheduler;
|
|
18
|
+
isActive?: () => boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DYNAMAX_RENDER_PATTERN = /(^|[^A-Za-z0-9_])(dynamax)(?=[^A-Za-z0-9_]|$)/gi;
|
|
22
|
+
const DYNAMAX_COLORS = [
|
|
23
|
+
[233, 137, 115],
|
|
24
|
+
[228, 186, 103],
|
|
25
|
+
[141, 192, 122],
|
|
26
|
+
[102, 194, 179],
|
|
27
|
+
[121, 157, 207],
|
|
28
|
+
[157, 134, 195],
|
|
29
|
+
[206, 130, 172],
|
|
30
|
+
] as const;
|
|
31
|
+
const RESET_FOREGROUND = "\x1b[39m";
|
|
32
|
+
const DYNAMAX_EFFECT_ENV = "PI_DYNAMAX_EFFECT";
|
|
33
|
+
export const DYNAMAX_ANIMATION_FRAME_MS = 135;
|
|
34
|
+
const DYNAMAX_SHINE_FRAME_COUNT = DYNAMAX_COLORS.length;
|
|
35
|
+
const DEFAULT_DYNAMAX_ANIMATION_SCHEDULER: DynamaxAnimationScheduler = {
|
|
36
|
+
now: () => performance.now(),
|
|
37
|
+
schedule(callback, delayMs) {
|
|
38
|
+
const timer = setTimeout(callback, delayMs);
|
|
39
|
+
return () => clearTimeout(timer);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function resolveDynamaxEffect(env: NodeJS.ProcessEnv = process.env): DynamaxEffect {
|
|
44
|
+
const configured = env[DYNAMAX_EFFECT_ENV]?.trim().toLowerCase();
|
|
45
|
+
if (configured === "shine" || configured === "static" || configured === "off") return configured;
|
|
46
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return "off";
|
|
47
|
+
return "shine";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function highlightDynamaxTokens(line: string, shinePosition?: number): string {
|
|
51
|
+
const { visibleText, rawPositions } = mapVisibleCharacters(line);
|
|
52
|
+
const insertions = new Map<number, string>();
|
|
53
|
+
for (const match of visibleText.matchAll(DYNAMAX_RENDER_PATTERN)) {
|
|
54
|
+
const token = match[2]!;
|
|
55
|
+
const visibleStart = match.index! + match[1]!.length;
|
|
56
|
+
for (let index = 0; index < token.length; index++) {
|
|
57
|
+
const rawPosition = rawPositions[visibleStart + index]!;
|
|
58
|
+
const color = DYNAMAX_COLORS[index % DYNAMAX_COLORS.length]!;
|
|
59
|
+
const shine = shinePosition === undefined ? (index === 0 ? 0.45 : 0) : shineFactor(index, shinePosition);
|
|
60
|
+
insertions.set(rawPosition, foregroundColor(color, shine));
|
|
61
|
+
}
|
|
62
|
+
const restorePosition = rawPositions[visibleStart + token.length] ?? line.length;
|
|
63
|
+
insertions.set(restorePosition, foregroundRestoreAt(line, restorePosition));
|
|
64
|
+
}
|
|
65
|
+
if (insertions.size === 0) return line;
|
|
66
|
+
|
|
67
|
+
let highlighted = "";
|
|
68
|
+
for (let rawPosition = 0; rawPosition <= line.length; rawPosition++) {
|
|
69
|
+
highlighted += insertions.get(rawPosition) ?? "";
|
|
70
|
+
if (rawPosition < line.length) highlighted += line[rawPosition];
|
|
71
|
+
}
|
|
72
|
+
return highlighted;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function decorateDynamaxEditor(
|
|
76
|
+
editor: EditorComponent,
|
|
77
|
+
requestRender: () => void,
|
|
78
|
+
options: DynamaxEditorDecorationOptions,
|
|
79
|
+
): DynamaxEditorDecoration {
|
|
80
|
+
const { effect } = options;
|
|
81
|
+
const originalRender = editor.render;
|
|
82
|
+
const scheduler = options.scheduler ?? DEFAULT_DYNAMAX_ANIMATION_SCHEDULER;
|
|
83
|
+
const isActive = options.isActive ?? (() => true);
|
|
84
|
+
let disposed = false;
|
|
85
|
+
let cancelScheduled: (() => void) | undefined;
|
|
86
|
+
let lastSignature: string | undefined;
|
|
87
|
+
let animationStartedAt: number | undefined;
|
|
88
|
+
|
|
89
|
+
const cancelAnimation = (): void => {
|
|
90
|
+
cancelScheduled?.();
|
|
91
|
+
cancelScheduled = undefined;
|
|
92
|
+
animationStartedAt = undefined;
|
|
93
|
+
};
|
|
94
|
+
const scheduleRender = (delayMs: number): void => {
|
|
95
|
+
if (cancelScheduled || disposed || !isActive()) return;
|
|
96
|
+
cancelScheduled = scheduler.schedule(() => {
|
|
97
|
+
cancelScheduled = undefined;
|
|
98
|
+
if (disposed || !isActive()) return;
|
|
99
|
+
requestRender();
|
|
100
|
+
}, Math.max(0, delayMs));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const decoratedRender = (width: number): string[] => {
|
|
104
|
+
const lines = originalRender.call(editor, width);
|
|
105
|
+
const signature = visibleDynamaxSignature(lines);
|
|
106
|
+
if (signature !== lastSignature) {
|
|
107
|
+
cancelAnimation();
|
|
108
|
+
lastSignature = signature;
|
|
109
|
+
if (signature && effect === "shine" && isActive()) animationStartedAt = scheduler.now();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let shinePosition: number | undefined;
|
|
113
|
+
if (animationStartedAt !== undefined && signature && effect === "shine" && isActive()) {
|
|
114
|
+
const now = scheduler.now();
|
|
115
|
+
const elapsed = Math.max(0, now - animationStartedAt);
|
|
116
|
+
const frame = Math.floor(elapsed / DYNAMAX_ANIMATION_FRAME_MS);
|
|
117
|
+
if (frame < DYNAMAX_SHINE_FRAME_COUNT) {
|
|
118
|
+
shinePosition = frame;
|
|
119
|
+
const nextFrameAt = animationStartedAt + (frame + 1) * DYNAMAX_ANIMATION_FRAME_MS;
|
|
120
|
+
scheduleRender(nextFrameAt - now);
|
|
121
|
+
} else {
|
|
122
|
+
cancelAnimation();
|
|
123
|
+
}
|
|
124
|
+
} else if (!signature || effect !== "shine" || !isActive()) {
|
|
125
|
+
cancelAnimation();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return lines.map((line) => highlightDynamaxTokens(line, shinePosition));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
editor.render = decoratedRender;
|
|
132
|
+
return {
|
|
133
|
+
editor,
|
|
134
|
+
dispose() {
|
|
135
|
+
if (disposed) return;
|
|
136
|
+
disposed = true;
|
|
137
|
+
cancelAnimation();
|
|
138
|
+
if (editor.render === decoratedRender) editor.render = originalRender;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function shineFactor(index: number, shinePosition: number): number {
|
|
144
|
+
const distance = Math.abs(index - shinePosition);
|
|
145
|
+
if (distance === 0) return 0.7;
|
|
146
|
+
if (distance === 1) return 0.3;
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function foregroundColor(color: readonly [number, number, number], shine: number): string {
|
|
151
|
+
const red = Math.round(color[0] + (255 - color[0]) * shine);
|
|
152
|
+
const green = Math.round(color[1] + (255 - color[1]) * shine);
|
|
153
|
+
const blue = Math.round(color[2] + (255 - color[2]) * shine);
|
|
154
|
+
return `\x1b[38;2;${red};${green};${blue}m`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mapVisibleCharacters(line: string): { visibleText: string; rawPositions: number[] } {
|
|
158
|
+
let visibleText = "";
|
|
159
|
+
const rawPositions: number[] = [];
|
|
160
|
+
let rawPosition = 0;
|
|
161
|
+
while (rawPosition < line.length) {
|
|
162
|
+
const controlLength = terminalControlSequenceLength(line, rawPosition);
|
|
163
|
+
if (controlLength > 0) {
|
|
164
|
+
rawPosition += controlLength;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
visibleText += line[rawPosition];
|
|
168
|
+
rawPositions.push(rawPosition);
|
|
169
|
+
rawPosition += 1;
|
|
170
|
+
}
|
|
171
|
+
return { visibleText, rawPositions };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function terminalControlSequenceLength(line: string, position: number): number {
|
|
175
|
+
if (line[position] !== "\x1b") return 0;
|
|
176
|
+
const introducer = line[position + 1];
|
|
177
|
+
if (introducer === "[") {
|
|
178
|
+
for (let index = position + 2; index < line.length; index++) {
|
|
179
|
+
const code = line.charCodeAt(index);
|
|
180
|
+
if (code >= 0x40 && code <= 0x7e) return index - position + 1;
|
|
181
|
+
}
|
|
182
|
+
return line.length - position;
|
|
183
|
+
}
|
|
184
|
+
if (introducer === "]" || introducer === "_") {
|
|
185
|
+
for (let index = position + 2; index < line.length; index++) {
|
|
186
|
+
if (line[index] === "\x07") return index - position + 1;
|
|
187
|
+
if (line[index] === "\x1b" && line[index + 1] === "\\") return index - position + 2;
|
|
188
|
+
}
|
|
189
|
+
return line.length - position;
|
|
190
|
+
}
|
|
191
|
+
return Math.min(2, line.length - position);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function foregroundRestoreAt(line: string, rawPosition: number): string {
|
|
195
|
+
const column = visibleWidth(line.slice(0, rawPosition));
|
|
196
|
+
// The extra cell makes Pi emit the active SGR prefix even when the token ends the line.
|
|
197
|
+
const sliced = sliceByColumn(`${line} `, column, 1);
|
|
198
|
+
let controls = "";
|
|
199
|
+
let position = 0;
|
|
200
|
+
while (position < sliced.length) {
|
|
201
|
+
const controlLength = terminalControlSequenceLength(sliced, position);
|
|
202
|
+
if (controlLength === 0) break;
|
|
203
|
+
const sequence = sliced.slice(position, position + controlLength);
|
|
204
|
+
if (sequence.startsWith("\x1b[") && sequence.endsWith("m")) controls += sequence;
|
|
205
|
+
position += controlLength;
|
|
206
|
+
}
|
|
207
|
+
return `${RESET_FOREGROUND}${controls}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function visibleDynamaxSignature(lines: string[]): string {
|
|
211
|
+
const matches: string[] = [];
|
|
212
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
213
|
+
const { visibleText } = mapVisibleCharacters(lines[lineIndex]!);
|
|
214
|
+
for (const match of visibleText.matchAll(DYNAMAX_RENDER_PATTERN)) {
|
|
215
|
+
const visibleStart = match.index! + match[1]!.length;
|
|
216
|
+
matches.push(`${lineIndex}:${visibleStart}:${match[2]!}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return matches.join("|");
|
|
220
|
+
}
|