pi-long-task 0.1.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/src/git.ts ADDED
@@ -0,0 +1,262 @@
1
+ import { execFile } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+ import path from "node:path";
5
+
6
+ import { taskLabel, type SessionOutcome } from "./worker_session.ts";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export interface GitRunResult {
11
+ stdout: string;
12
+ stderr: string;
13
+ code: number;
14
+ }
15
+
16
+ export interface CommitAfterSessionOptions {
17
+ cwd: string;
18
+ resultPath: string;
19
+ todoPath?: string;
20
+ runDir?: string;
21
+ outcome: Pick<SessionOutcome, "task" | "reportedStatus" | "done" | "error" | "timedOut" | "aborted">;
22
+ preExistingDirtyPaths?: ReadonlySet<string> | readonly string[];
23
+ }
24
+
25
+ export interface CommitAfterSessionResult {
26
+ hash?: string;
27
+ error?: string;
28
+ skipped?: string;
29
+ }
30
+
31
+ export async function gitRoot(cwd: string): Promise<string | undefined> {
32
+ const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
33
+ if (result.code !== 0) {
34
+ return undefined;
35
+ }
36
+ const root = result.stdout.trim();
37
+ return root ? path.resolve(root) : undefined;
38
+ }
39
+
40
+ export async function gitDirtyPaths(cwd: string, ...excludePaths: string[]): Promise<Set<string>> {
41
+ const root = await gitRoot(cwd);
42
+ if (!root) {
43
+ return new Set();
44
+ }
45
+
46
+ const excluded = excludePaths.map((item) => relToRoot(item, root)).filter(Boolean);
47
+ const result = await runGit(root, ["status", "--porcelain", "-z", "--untracked-files=all"]);
48
+ if (result.code !== 0) {
49
+ return new Set();
50
+ }
51
+
52
+ const dirty = new Set<string>();
53
+ const entries = result.stdout.split("\0");
54
+ for (let index = 0; index < entries.length; index += 1) {
55
+ const entry = entries[index];
56
+ if (!entry) {
57
+ continue;
58
+ }
59
+
60
+ const status = entry.slice(0, 2);
61
+ const firstPath = entry.slice(3);
62
+ const isRenameOrCopy = status.includes("R") || status.includes("C");
63
+ if (firstPath && !isExcluded(firstPath, excluded)) {
64
+ dirty.add(firstPath);
65
+ }
66
+ if (isRenameOrCopy && index + 1 < entries.length) {
67
+ const secondPath = entries[index + 1];
68
+ index += 1;
69
+ if (secondPath && !isExcluded(secondPath, excluded)) {
70
+ dirty.add(secondPath);
71
+ }
72
+ }
73
+ }
74
+ return dirty;
75
+ }
76
+
77
+ export async function unstagePaths(root: string, paths: Iterable<string>): Promise<void> {
78
+ const pathList = [...new Set([...paths].filter(Boolean))].sort();
79
+ if (pathList.length === 0) {
80
+ return;
81
+ }
82
+
83
+ await runGit(root, ["restore", "--staged", "--", ...pathList]);
84
+ await runGit(root, ["reset", "--", ...pathList]);
85
+ }
86
+
87
+ export function shouldCommitOutcome(
88
+ outcome: Pick<SessionOutcome, "reportedStatus" | "error" | "timedOut" | "aborted">,
89
+ ): boolean {
90
+ if (outcome.error || outcome.timedOut || outcome.aborted) {
91
+ return false;
92
+ }
93
+ const status = outcome.reportedStatus.toLowerCase();
94
+ return ["done", "complete", "completed", "success", "succeeded", "partial", "blocked"].includes(status);
95
+ }
96
+
97
+ export async function commitAfterSession(options: CommitAfterSessionOptions): Promise<CommitAfterSessionResult> {
98
+ if (!shouldCommitOutcome(options.outcome)) {
99
+ return { skipped: "outcome is not eligible for commit" };
100
+ }
101
+
102
+ const root = await gitRoot(options.cwd);
103
+ if (!root) {
104
+ return { error: "not inside a git repository" };
105
+ }
106
+
107
+ const messagePrefix = options.outcome.done ? "Complete" : "Progress";
108
+ const commitMessage = `${messagePrefix} ${taskLabel(options.outcome.task)}`;
109
+
110
+ try {
111
+ const add = await runGit(root, ["add", "-A"]);
112
+ if (add.code !== 0) {
113
+ return { error: gitError(add) };
114
+ }
115
+
116
+ const excludedPaths = new Set<string>();
117
+ addPathIfPresent(excludedPaths, relToRoot(options.resultPath, root));
118
+ if (options.todoPath) {
119
+ addPathIfPresent(excludedPaths, relToRoot(options.todoPath, root));
120
+ }
121
+ if (options.runDir) {
122
+ addPathIfPresent(excludedPaths, relToRoot(options.runDir, root));
123
+ }
124
+ for (const item of options.preExistingDirtyPaths ?? []) {
125
+ addPathIfPresent(excludedPaths, item);
126
+ }
127
+
128
+ for (const item of await stagedArtifactPaths(root, options.runDir)) {
129
+ excludedPaths.add(item);
130
+ }
131
+
132
+ await unstagePaths(root, excludedPaths);
133
+
134
+ const diff = await runGit(root, ["diff", "--cached", "--quiet", "--exit-code"]);
135
+ if (diff.code === 0) {
136
+ return { skipped: "no staged diff" };
137
+ }
138
+ if (diff.code !== 1) {
139
+ return { error: gitError(diff) };
140
+ }
141
+
142
+ const commit = await runGit(root, ["commit", "-m", commitMessage]);
143
+ if (commit.code !== 0) {
144
+ return { error: gitError(commit) };
145
+ }
146
+
147
+ const rev = await runGit(root, ["rev-parse", "--short", "HEAD"]);
148
+ if (rev.code !== 0) {
149
+ return { error: gitError(rev) };
150
+ }
151
+ return { hash: rev.stdout.trim() };
152
+ } catch (error) {
153
+ return { error: error instanceof Error ? error.message : String(error) };
154
+ }
155
+ }
156
+
157
+ async function stagedArtifactPaths(root: string, runDir?: string): Promise<Set<string>> {
158
+ const artifacts = new Set<string>();
159
+ const runDirRel = runDir ? relToRoot(runDir, root) : "";
160
+ const result = await runGit(root, ["diff", "--cached", "--name-only", "-z"]);
161
+ if (result.code !== 0) {
162
+ return artifacts;
163
+ }
164
+
165
+ for (const item of result.stdout.split("\0")) {
166
+ if (!item) {
167
+ continue;
168
+ }
169
+ if (path.posix.basename(item) === "TASK_RESULT.md") {
170
+ artifacts.add(item);
171
+ continue;
172
+ }
173
+ if (runDirRel && isPathAtOrUnder(item, runDirRel)) {
174
+ artifacts.add(item);
175
+ }
176
+ }
177
+ return artifacts;
178
+ }
179
+
180
+ function relToRoot(pathname: string, root: string): string {
181
+ const absolute = resolveExistingPath(pathname);
182
+ const resolvedRoot = resolveExistingPath(root);
183
+ const relative = path.relative(resolvedRoot, absolute);
184
+ if (!relative || relative === ".") {
185
+ return "";
186
+ }
187
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
188
+ return absolute;
189
+ }
190
+ return toGitPath(relative);
191
+ }
192
+
193
+ function resolveExistingPath(pathname: string): string {
194
+ const absolute = path.resolve(pathname);
195
+ try {
196
+ return realpathSync.native(absolute);
197
+ } catch {
198
+ return absolute;
199
+ }
200
+ }
201
+
202
+ function addPathIfPresent(paths: Set<string>, pathname: string): void {
203
+ if (pathname) {
204
+ paths.add(pathname);
205
+ }
206
+ }
207
+
208
+ function isExcluded(pathname: string, excluded: readonly string[]): boolean {
209
+ return excluded.some((item) => isPathAtOrUnder(pathname, item));
210
+ }
211
+
212
+ function isPathAtOrUnder(pathname: string, parent: string): boolean {
213
+ const normalizedPath = trimTrailingSlash(toGitPath(pathname));
214
+ const normalizedParent = trimTrailingSlash(toGitPath(parent));
215
+ return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`);
216
+ }
217
+
218
+ function trimTrailingSlash(value: string): string {
219
+ return value.replace(/\/+$/g, "");
220
+ }
221
+
222
+ function toGitPath(value: string): string {
223
+ return value.split(path.sep).join("/");
224
+ }
225
+
226
+ function gitError(result: GitRunResult): string {
227
+ return (result.stderr || result.stdout || `git exited with status ${result.code}`).trim();
228
+ }
229
+
230
+ async function runGit(cwd: string, args: readonly string[]): Promise<GitRunResult> {
231
+ try {
232
+ const result = await execFileAsync("git", [...args], {
233
+ cwd,
234
+ encoding: "utf8",
235
+ maxBuffer: 10 * 1024 * 1024,
236
+ });
237
+ return {
238
+ stdout: result.stdout,
239
+ stderr: result.stderr,
240
+ code: 0,
241
+ };
242
+ } catch (error) {
243
+ if (isExecError(error)) {
244
+ return {
245
+ stdout: typeof error.stdout === "string" ? error.stdout : "",
246
+ stderr: typeof error.stderr === "string" ? error.stderr : "",
247
+ code: typeof error.code === "number" ? error.code : 1,
248
+ };
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+
254
+ function isExecError(error: unknown): error is { stdout?: unknown; stderr?: unknown; code?: unknown } {
255
+ return typeof error === "object" && error !== null;
256
+ }
257
+
258
+ export const git_root = gitRoot;
259
+ export const git_dirty_paths = gitDirtyPaths;
260
+ export const unstage_paths = unstagePaths;
261
+ export const should_commit_outcome = shouldCommitOutcome;
262
+ export const commit_after_session = commitAfterSession;
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
4
+ import { renderLongTaskToolCall, renderLongTaskToolResult } from "./render.ts";
5
+ import { PiLongTaskParams } from "./types.ts";
6
+
7
+ function toolDetails(result: CoordinatorResult) {
8
+ return {
9
+ runId: result.runId,
10
+ todoPath: result.todoPath,
11
+ resultPath: result.resultPath,
12
+ outcomes: result.outcomes,
13
+ commits: result.commits,
14
+ status: result.status,
15
+ totalTasks: result.totalTasks,
16
+ completedTasks: result.completedTasks,
17
+ failedTasks: result.failedTasks,
18
+ blockedTasks: result.blockedTasks,
19
+ remainingTasks: result.remainingTasks,
20
+ summary: result.summary,
21
+ };
22
+ }
23
+
24
+ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
25
+ pi.registerTool({
26
+ name: "pi_long_task",
27
+ label: "Pi Long Task",
28
+ description: "Break down and run long coding tasks from a request or TODO plan.",
29
+ parameters: PiLongTaskParams,
30
+ renderCall: renderLongTaskToolCall,
31
+ renderResult: renderLongTaskToolResult,
32
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
33
+ const publishProgress = (update: CoordinatorProgressUpdate) => {
34
+ onUpdate?.({
35
+ content: [
36
+ {
37
+ type: "text" as const,
38
+ text: update.message,
39
+ },
40
+ ],
41
+ details: update,
42
+ });
43
+ };
44
+
45
+ const result = await runCoordinator({
46
+ ...params,
47
+ cwd: ctx?.cwd,
48
+ abortSignal: signal,
49
+ onProgress: publishProgress,
50
+ });
51
+
52
+ return {
53
+ content: [
54
+ {
55
+ type: "text" as const,
56
+ text: result.message,
57
+ },
58
+ ],
59
+ details: toolDetails(result),
60
+ };
61
+ },
62
+ });
63
+ }
package/src/render.ts ADDED
@@ -0,0 +1,270 @@
1
+ import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
5
+
6
+ export interface CoordinatorResultForRendering {
7
+ status: CoordinatorStatus;
8
+ summary: string;
9
+ totalTasks: number;
10
+ completedTasks: number;
11
+ failedTasks: number;
12
+ blockedTasks: number;
13
+ todoPath: string;
14
+ resultPath?: string;
15
+ taskResultPath?: string;
16
+ commits?: CoordinatorCommitSummary[];
17
+ remainingTasks?: CoordinatorRemainingTask[];
18
+ error?: string;
19
+ }
20
+
21
+ export interface CoordinatorToolRenderDetails extends CoordinatorResultForRendering {
22
+ runId?: string;
23
+ }
24
+
25
+ export function formatCoordinatorResultMessage(result: CoordinatorResultForRendering): string {
26
+ const resultPath = result.resultPath ?? result.taskResultPath ?? "unknown";
27
+ const remaining = result.remainingTasks ?? [];
28
+ const commits = result.commits ?? [];
29
+ const remainingCount = Math.max(0, result.totalTasks - result.completedTasks);
30
+ const lines = [
31
+ `Pi Long Task: ${result.status}`,
32
+ `Tasks: ${result.completedTasks} completed, ${result.failedTasks} failed, ${result.blockedTasks} blocked, ${remainingCount} remaining (${result.totalTasks} total).`,
33
+ `Result file: ${resultPath}`,
34
+ `TODO file: ${result.todoPath}`,
35
+ ];
36
+
37
+ const commitLines = commits
38
+ .filter((commit) => commit.hash || commit.error)
39
+ .map((commit) => {
40
+ if (commit.hash) {
41
+ return `- TODO ${commit.taskId}: ${commit.hash}`;
42
+ }
43
+ return `- TODO ${commit.taskId}: commit error: ${commit.error ?? "unknown"}`;
44
+ });
45
+ if (commitLines.length > 0) {
46
+ lines.push("Commits:", ...commitLines);
47
+ }
48
+
49
+ if (remaining.length > 0) {
50
+ lines.push(
51
+ "Remaining tasks:",
52
+ ...remaining.map((task) => `- TODO ${task.taskId} — ${task.title} (${task.status})`),
53
+ );
54
+ }
55
+
56
+ if (result.error) {
57
+ lines.push(`Error: ${result.error}`);
58
+ }
59
+
60
+ return lines.join("\n");
61
+ }
62
+
63
+ export function renderLongTaskToolCall(args: { inputText?: string; commit?: boolean }, theme: Theme): Text {
64
+ const commit = args.commit ? theme.fg("warning", "commit:on") : theme.fg("dim", "commit:off");
65
+ const input = oneLine(args.inputText ?? "");
66
+ const preview = input ? ` ${theme.fg("muted", quote(truncatePlain(input, 96)))}` : "";
67
+ return new Text(`${theme.fg("toolTitle", theme.bold("pi_long_task"))} ${commit}${preview}`, 0, 0);
68
+ }
69
+
70
+ export function renderLongTaskToolResult(
71
+ result: AgentToolResult<unknown>,
72
+ options: ToolRenderResultOptions,
73
+ theme: Theme,
74
+ ): Text {
75
+ const details = recordOrUndefined(result.details);
76
+ if (options.isPartial) {
77
+ return new Text(renderLongTaskProgress(details, contentText(result), theme), 0, 0);
78
+ }
79
+
80
+ const finalDetails = longTaskDetails(details);
81
+ if (!finalDetails) {
82
+ return new Text(contentText(result), 0, 0);
83
+ }
84
+
85
+ return new Text(renderLongTaskSummary(finalDetails, options.expanded, theme), 0, 0);
86
+ }
87
+
88
+ function renderLongTaskProgress(details: Record<string, unknown> | undefined, fallback: string, theme: Theme): string {
89
+ const message = stringValue(details?.message) || firstLine(fallback) || "Pi Long Task is running...";
90
+ const phase = stringValue(details?.phase);
91
+ const toolName = stringValue(details?.toolName);
92
+ const prefix = phase === "worker_tool" && toolName ? `worker ${toolName}` : phase || "progress";
93
+ return `${theme.fg("accent", "●")} ${theme.fg("muted", prefix)} ${message}`;
94
+ }
95
+
96
+ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded: boolean, theme: Theme): string {
97
+ const remainingCount = Math.max(0, details.totalTasks - details.completedTasks);
98
+ const statusStyle = statusColor(details.status);
99
+ const icon = details.status === "done" ? "✓" : details.status === "failed" ? "✗" : "!";
100
+ const commitCount = (details.commits ?? []).filter((commit) => commit.hash).length;
101
+ const summary = [
102
+ `${theme.fg(statusStyle, icon)} ${theme.fg("toolTitle", theme.bold("Pi Long Task"))} ${theme.fg(statusStyle, details.status)}`,
103
+ theme.fg("muted", `${details.completedTasks}/${details.totalTasks} tasks`),
104
+ details.failedTasks ? theme.fg("error", `${details.failedTasks} failed`) : undefined,
105
+ details.blockedTasks ? theme.fg("warning", `${details.blockedTasks} blocked`) : undefined,
106
+ remainingCount ? theme.fg("muted", `${remainingCount} remaining`) : undefined,
107
+ commitCount ? theme.fg("success", `${commitCount} commit${commitCount === 1 ? "" : "s"}`) : undefined,
108
+ ].filter(Boolean);
109
+
110
+ if (!expanded) {
111
+ return summary.join(" — ");
112
+ }
113
+
114
+ const lines = [summary.join(" — "), theme.fg("muted", details.summary)];
115
+ lines.push(theme.fg("dim", `Result: ${details.resultPath ?? details.taskResultPath ?? "unknown"}`));
116
+ lines.push(theme.fg("dim", `TODO: ${details.todoPath}`));
117
+
118
+ const commits = details.commits ?? [];
119
+ if (commits.length > 0) {
120
+ lines.push(theme.fg("muted", "Commits:"));
121
+ for (const commit of commits) {
122
+ const text = commit.hash
123
+ ? `- TODO ${commit.taskId}: ${commit.hash}`
124
+ : `- TODO ${commit.taskId}: commit error: ${commit.error ?? "unknown"}`;
125
+ lines.push(theme.fg(commit.hash ? "success" : "error", text));
126
+ }
127
+ }
128
+
129
+ const remaining = details.remainingTasks ?? [];
130
+ if (remaining.length > 0) {
131
+ lines.push(theme.fg("muted", "Remaining:"));
132
+ for (const task of remaining) {
133
+ lines.push(theme.fg("dim", `- TODO ${task.taskId} — ${task.title} (${task.status})`));
134
+ }
135
+ }
136
+
137
+ if (details.error) {
138
+ lines.push(theme.fg("error", `Error: ${details.error}`));
139
+ }
140
+
141
+ return lines.join("\n");
142
+ }
143
+
144
+ function longTaskDetails(details: Record<string, unknown> | undefined): CoordinatorToolRenderDetails | undefined {
145
+ if (!details) {
146
+ return undefined;
147
+ }
148
+
149
+ const status = stringValue(details.status);
150
+ const totalTasks = numberValue(details.totalTasks);
151
+ const completedTasks = numberValue(details.completedTasks);
152
+ const failedTasks = numberValue(details.failedTasks);
153
+ const blockedTasks = numberValue(details.blockedTasks);
154
+ const todoPath = stringValue(details.todoPath);
155
+ const summary = stringValue(details.summary);
156
+ if (
157
+ !isCoordinatorStatus(status) ||
158
+ totalTasks === undefined ||
159
+ completedTasks === undefined ||
160
+ !todoPath ||
161
+ !summary
162
+ ) {
163
+ return undefined;
164
+ }
165
+
166
+ return {
167
+ status,
168
+ summary,
169
+ totalTasks,
170
+ completedTasks,
171
+ failedTasks: failedTasks ?? 0,
172
+ blockedTasks: blockedTasks ?? 0,
173
+ todoPath,
174
+ resultPath: stringValue(details.resultPath),
175
+ taskResultPath: stringValue(details.taskResultPath),
176
+ runId: stringValue(details.runId),
177
+ commits: commitSummaries(details.commits),
178
+ remainingTasks: remainingTaskSummaries(details.remainingTasks),
179
+ error: stringValue(details.error),
180
+ };
181
+ }
182
+
183
+ function commitSummaries(value: unknown): CoordinatorCommitSummary[] {
184
+ if (!Array.isArray(value)) {
185
+ return [];
186
+ }
187
+ return value.flatMap((item) => {
188
+ const record = recordOrUndefined(item);
189
+ const taskId = stringValue(record?.taskId);
190
+ if (!taskId) {
191
+ return [];
192
+ }
193
+ return [
194
+ {
195
+ taskId,
196
+ hash: stringValue(record?.hash),
197
+ error: stringValue(record?.error),
198
+ },
199
+ ];
200
+ });
201
+ }
202
+
203
+ function remainingTaskSummaries(value: unknown): CoordinatorRemainingTask[] {
204
+ if (!Array.isArray(value)) {
205
+ return [];
206
+ }
207
+ return value.flatMap((item) => {
208
+ const record = recordOrUndefined(item);
209
+ const taskId = stringValue(record?.taskId);
210
+ const title = stringValue(record?.title);
211
+ const status = stringValue(record?.status);
212
+ if (!taskId || !title || !status) {
213
+ return [];
214
+ }
215
+ return [{ taskId, title, status }];
216
+ });
217
+ }
218
+
219
+ function statusColor(status: CoordinatorStatus): "success" | "warning" | "error" {
220
+ if (status === "done") {
221
+ return "success";
222
+ }
223
+ if (status === "failed") {
224
+ return "error";
225
+ }
226
+ return "warning";
227
+ }
228
+
229
+ function isCoordinatorStatus(value: string): value is CoordinatorStatus {
230
+ return value === "done" || value === "partial" || value === "blocked" || value === "failed";
231
+ }
232
+
233
+ function contentText(result: AgentToolResult<unknown>): string {
234
+ const content = Array.isArray(result.content) ? result.content : [];
235
+ return content
236
+ .map((item) => {
237
+ const record = recordOrUndefined(item);
238
+ return record?.type === "text" ? stringValue(record.text) : "";
239
+ })
240
+ .filter(Boolean)
241
+ .join("\n");
242
+ }
243
+
244
+ function firstLine(value: string): string {
245
+ return value.split(/\r?\n/, 1)[0]?.trim() ?? "";
246
+ }
247
+
248
+ function quote(value: string): string {
249
+ return `"${value.replaceAll('"', '\\"')}"`;
250
+ }
251
+
252
+ function oneLine(value: string): string {
253
+ return value.replace(/\s+/g, " ").trim();
254
+ }
255
+
256
+ function truncatePlain(value: string, maxLength: number): string {
257
+ return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 1))}…`;
258
+ }
259
+
260
+ function stringValue(value: unknown): string {
261
+ return typeof value === "string" ? value : "";
262
+ }
263
+
264
+ function numberValue(value: unknown): number | undefined {
265
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
266
+ }
267
+
268
+ function recordOrUndefined(value: unknown): Record<string, unknown> | undefined {
269
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
270
+ }
@@ -0,0 +1,96 @@
1
+ export const DONE_STATUSES = new Set(["done", "complete", "completed", "success", "succeeded"]);
2
+ export const PARTIAL_STATUSES = new Set(["partial", "incomplete", "blocked", "failed", "failure", "unknown"]);
3
+
4
+ const TASK_RESULT_MARKER_RE = /TASK_RESULT\s*:/gi;
5
+ const STATUS_LINE_RE = /^\s*status\s*:\s*([A-Za-z_-]+)\s*$/im;
6
+ const FENCED_BLOCK_RE = /```[^\r\n`]*\r?\n([\s\S]*?)\r?\n```/g;
7
+
8
+ export interface TaskResultBlock {
9
+ marker: "TASK_RESULT";
10
+ body: string;
11
+ fenced: boolean;
12
+ }
13
+
14
+ export function isDoneStatus(status: string): boolean {
15
+ return DONE_STATUSES.has(status.trim().toLowerCase());
16
+ }
17
+
18
+ export function isPartialStatus(status: string): boolean {
19
+ return PARTIAL_STATUSES.has(status.trim().toLowerCase());
20
+ }
21
+
22
+ export function hasTaskResult(assistantText: string): boolean {
23
+ return /TASK_RESULT\s*:/i.test(assistantText || "");
24
+ }
25
+
26
+ export function hasTaskResultStatus(assistantText: string): boolean {
27
+ const block = extractTaskResultBlock(assistantText);
28
+ return Boolean(block && STATUS_LINE_RE.test(block.body));
29
+ }
30
+
31
+ export function parseReportedStatus(assistantText: string): string {
32
+ const block = extractTaskResultBlock(assistantText);
33
+ const searchText = block ? block.body : assistantText || "";
34
+ const match = STATUS_LINE_RE.exec(searchText);
35
+ if (!match) {
36
+ return "unknown";
37
+ }
38
+
39
+ return match[1].trim().toLowerCase();
40
+ }
41
+
42
+ export function extractResultSummary(assistantText: string, limit = 8000): string {
43
+ let text = (assistantText || "").trim();
44
+ const block = extractTaskResultBlock(text);
45
+ if (block) {
46
+ text = `TASK_RESULT:\n${block.body.trim()}`;
47
+ }
48
+
49
+ if (text.length > limit) {
50
+ return `${text.slice(0, limit)}\n\n[truncated by Pi Long Task]\n`;
51
+ }
52
+ return text;
53
+ }
54
+
55
+ export const summarizeAssistantResult = extractResultSummary;
56
+
57
+ export function extractTaskResultBlock(assistantText: string): TaskResultBlock | undefined {
58
+ const text = assistantText || "";
59
+ const fencedBlocks = fencedCodeBlocks(text);
60
+ for (let idx = fencedBlocks.length - 1; idx >= 0; idx -= 1) {
61
+ const body = taskResultBodyFromText(fencedBlocks[idx]);
62
+ if (body !== undefined) {
63
+ return { marker: "TASK_RESULT", body, fenced: true };
64
+ }
65
+ }
66
+
67
+ const body = taskResultBodyFromText(text);
68
+ if (body === undefined) {
69
+ return undefined;
70
+ }
71
+ return { marker: "TASK_RESULT", body: stripTrailingFence(body), fenced: false };
72
+ }
73
+
74
+ function taskResultBodyFromText(text: string): string | undefined {
75
+ TASK_RESULT_MARKER_RE.lastIndex = 0;
76
+ let match: RegExpExecArray | null;
77
+ let lastMatch: RegExpExecArray | undefined;
78
+ while ((match = TASK_RESULT_MARKER_RE.exec(text)) !== null) {
79
+ lastMatch = match;
80
+ }
81
+
82
+ if (!lastMatch) {
83
+ return undefined;
84
+ }
85
+
86
+ return text.slice(lastMatch.index + lastMatch[0].length).trim();
87
+ }
88
+
89
+ function fencedCodeBlocks(text: string): string[] {
90
+ FENCED_BLOCK_RE.lastIndex = 0;
91
+ return [...text.matchAll(FENCED_BLOCK_RE)].map((match) => match[1]);
92
+ }
93
+
94
+ function stripTrailingFence(text: string): string {
95
+ return text.replace(/\r?\n```\s*$/g, "").trim();
96
+ }