pi-long-task 0.3.9 → 0.3.10

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.
@@ -0,0 +1,575 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ cancelGoalLoop,
6
+ type GoalIterationState,
7
+ type GoalLoopState,
8
+ type GoalReviewerDecision,
9
+ type GoalReviewerResultState,
10
+ recordReviewerResult,
11
+ } from "./goal_loop.ts";
12
+ import { GoalStateStore } from "./goal_state.ts";
13
+ import { goalSpecificationToMarkdown, type GoalSpecification } from "./goal_spec.ts";
14
+ import {
15
+ assistantTextFromEvent,
16
+ createIsolatedWorkerSession,
17
+ DEFAULT_WORKER_TOOLS,
18
+ lastAssistantTextFromMessages,
19
+ workerUsageCostFromEvent,
20
+ workerUsageCostFromStats,
21
+ type WorkerSessionFactory,
22
+ } from "./worker_session.ts";
23
+
24
+ export const GOAL_REVIEW_PAYLOAD_FILE = "REVIEW_TASK.md";
25
+ export const GOAL_REVIEW_RAW_FILE = "REVIEW_RESULT_RAW.txt";
26
+
27
+ export interface GoalReviewOptions {
28
+ state: GoalLoopState;
29
+ cwd?: string;
30
+ store?: GoalStateStore;
31
+ reviewerRunner?: GoalReviewerRunner;
32
+ abortSignal?: AbortSignal;
33
+ model?: unknown;
34
+ modelName?: string;
35
+ thinkingLevel?: string;
36
+ now?: () => Date;
37
+ sessionFactory?: WorkerSessionFactory;
38
+ goalSpecification?: GoalSpecification;
39
+ }
40
+
41
+ export interface GoalReviewResult {
42
+ state: GoalLoopState;
43
+ iteration: GoalIterationState;
44
+ payload: string;
45
+ payloadPath: string;
46
+ rawReviewPath: string;
47
+ rawReviewerOutput: string;
48
+ reviewerResult: GoalReviewerResultState;
49
+ sessionResult: GoalReviewerSessionResult;
50
+ }
51
+
52
+ export interface GoalReviewerRunnerOptions {
53
+ prompt: string;
54
+ cwd: string;
55
+ abortSignal?: AbortSignal;
56
+ timeoutMs: number;
57
+ model?: unknown;
58
+ modelName?: string;
59
+ thinkingLevel?: string;
60
+ sessionFactory?: WorkerSessionFactory;
61
+ }
62
+
63
+ export interface GoalReviewerSessionResult {
64
+ assistantText: string;
65
+ reviewerSessionId?: string;
66
+ reviewerSessionFile?: string;
67
+ reviewerCostTotal?: number;
68
+ timedOut?: boolean;
69
+ aborted?: boolean;
70
+ error?: string;
71
+ }
72
+
73
+ export type GoalReviewerRunner = (options: GoalReviewerRunnerOptions) => Promise<GoalReviewerSessionResult>;
74
+
75
+ export class GoalReviewError extends Error {
76
+ readonly state: GoalLoopState | undefined;
77
+ readonly reviewerResult: GoalReviewerResultState | undefined;
78
+
79
+ constructor(
80
+ message: string,
81
+ options: { cause?: unknown; state?: GoalLoopState; reviewerResult?: GoalReviewerResultState } = {},
82
+ ) {
83
+ super(message, { cause: options.cause });
84
+ this.name = "GoalReviewError";
85
+ this.state = options.state;
86
+ this.reviewerResult = options.reviewerResult;
87
+ }
88
+ }
89
+
90
+ export async function runGoalReviewSession(options: GoalReviewOptions): Promise<GoalReviewResult> {
91
+ const now = options.now ?? (() => new Date());
92
+ let state = options.state;
93
+ const previousTraceLength = state.trace.length;
94
+ const store =
95
+ options.store ?? new GoalStateStore({ cwd: options.cwd, goalRunId: state.goalRunId, goalRunDir: state.goalRunDir });
96
+
97
+ const iteration = currentReviewableIteration(state);
98
+ const iterationDir = store.iterationDir(iteration.iteration);
99
+ await mkdir(iterationDir, { recursive: true });
100
+ const payloadPath = path.join(iterationDir, GOAL_REVIEW_PAYLOAD_FILE);
101
+ const rawReviewPath = path.join(iterationDir, GOAL_REVIEW_RAW_FILE);
102
+ const goalSpecification = options.goalSpecification ?? (await store.tryLoadGoalSpecification());
103
+ const payload = buildGoalReviewTaskPayload({
104
+ state,
105
+ iteration,
106
+ goalSpecification,
107
+ goalSpecificationPath: goalSpecification ? store.paths.goalSpecPath : undefined,
108
+ });
109
+ await writeFile(payloadPath, payload, "utf8");
110
+
111
+ if (options.abortSignal?.aborted) {
112
+ state = cancelGoalLoop(state, "Goal review was aborted before starting.", { now: now() });
113
+ await persistStateChange(store, previousTraceLength, state);
114
+ await store.writeIterationSnapshot(currentIteration(state, iteration.iteration));
115
+ throw new GoalReviewError("Goal review was aborted before starting.", { state });
116
+ }
117
+
118
+ let sessionResult: GoalReviewerSessionResult;
119
+ try {
120
+ sessionResult = await (options.reviewerRunner ?? runGoalReviewerSession)({
121
+ prompt: payload,
122
+ cwd: path.resolve(options.cwd ?? process.cwd()),
123
+ abortSignal: options.abortSignal,
124
+ timeoutMs: state.limits.reviewerTimeoutMs,
125
+ model: options.model,
126
+ modelName: options.modelName,
127
+ thinkingLevel: options.thinkingLevel,
128
+ sessionFactory: options.sessionFactory,
129
+ });
130
+ } catch (error) {
131
+ const failure = await recordReviewFailure({
132
+ state,
133
+ iteration,
134
+ store,
135
+ previousTraceLength,
136
+ payloadPath,
137
+ rawReviewPath,
138
+ message: `Reviewer session failed: ${errorMessage(error)}`,
139
+ error,
140
+ now,
141
+ });
142
+ throw new GoalReviewError(failure.reviewerResult.summary, {
143
+ cause: error,
144
+ state: failure.state,
145
+ reviewerResult: failure.reviewerResult,
146
+ });
147
+ }
148
+
149
+ const rawReviewerOutput = sessionResult.assistantText;
150
+ await writeFile(rawReviewPath, rawReviewerOutput, "utf8");
151
+
152
+ let reviewerResult: GoalReviewerResultState;
153
+ try {
154
+ reviewerResult = {
155
+ ...parseGoalReviewerOutput(rawReviewerOutput, { now: now() }),
156
+ reviewerSessionId: sessionResult.reviewerSessionId,
157
+ reviewerSessionFile: sessionResult.reviewerSessionFile,
158
+ payloadPath,
159
+ rawReviewPath,
160
+ reviewerCostTotal: sessionResult.reviewerCostTotal,
161
+ };
162
+ } catch (error) {
163
+ const failure = await recordReviewFailure({
164
+ state,
165
+ iteration,
166
+ store,
167
+ previousTraceLength,
168
+ payloadPath,
169
+ rawReviewPath,
170
+ message: `Reviewer output could not be parsed: ${errorMessage(error)}`,
171
+ error,
172
+ rawReviewerOutput,
173
+ sessionResult,
174
+ now,
175
+ });
176
+ throw new GoalReviewError(failure.reviewerResult.summary, {
177
+ cause: error,
178
+ state: failure.state,
179
+ reviewerResult: failure.reviewerResult,
180
+ });
181
+ }
182
+
183
+ state = recordReviewerResult(state, iteration.iteration, reviewerResult, { now: now() });
184
+ await persistStateChange(store, previousTraceLength, state);
185
+ const updatedIteration = currentIteration(state, iteration.iteration);
186
+ await store.writeIterationSnapshot(updatedIteration);
187
+ await store.appendIterationResult(updatedIteration);
188
+
189
+ return {
190
+ state,
191
+ iteration: updatedIteration,
192
+ payload,
193
+ payloadPath,
194
+ rawReviewPath,
195
+ rawReviewerOutput,
196
+ reviewerResult,
197
+ sessionResult,
198
+ };
199
+ }
200
+
201
+ export function buildGoalReviewTaskPayload(options: {
202
+ state: GoalLoopState;
203
+ iteration: GoalIterationState;
204
+ goalSpecification?: GoalSpecification;
205
+ goalSpecificationPath?: string;
206
+ }): string {
207
+ const { state, iteration } = options;
208
+ const workerResult = iteration.workerResult;
209
+ const generatedTodo = iteration.generatedTodo;
210
+ const previousContext = previousReviewContext(state, iteration.iteration);
211
+ const previousContextBlock = previousContext
212
+ ? `\nPrevious iteration review context:\n\n${markdownFence(previousContext, "text")}\n`
213
+ : "";
214
+ const specificationBlock = options.goalSpecification
215
+ ? `\nPersisted goal specification (primary review target):\n\n${markdownFence(
216
+ buildGoalSpecificationReviewContext(options.goalSpecification, options.goalSpecificationPath),
217
+ "markdown",
218
+ )}\n`
219
+ : "";
220
+ const reviewTargetInstruction = options.goalSpecification
221
+ ? "Review whether the latest worker run satisfies the persisted goal specification and definition-of-done. Treat the persisted specification as the primary review target; keep the original high-level goal available only as traceability/context."
222
+ : "Review whether the original high-level goal is complete after the latest worker run.";
223
+ const decisionRules = options.goalSpecification
224
+ ? `- Use "complete" only when the persisted definition-of-done is satisfied, including in-scope requirements, milestones, acceptance criteria, required verification gates, and applicable design/product constraints.
225
+ - Use "incomplete" when any required spec requirement, milestone, acceptance criterion, verification gate, artifact, or constraint still needs work and another TODO-generation iteration should be started.
226
+ - Use "blocked" when a required spec item cannot be evaluated or completed without external input or unavailable resources.
227
+ - Use "failed" when the loop should stop because the run is unrecoverably failed.
228
+ - In summary, rationale, and remainingWork, cite specific spec IDs or named criteria where applicable (for example REQ-*, MS-*, AC-*, VG-*).`
229
+ : `- Use "complete" only when the original high-level goal is satisfied, not merely when the worker finished its TODO.
230
+ - Use "incomplete" when meaningful work remains and another TODO-generation iteration should be started.
231
+ - Use "blocked" when external input or unavailable resources prevent progress.
232
+ - Use "failed" when the loop should stop because the run is unrecoverably failed.`;
233
+
234
+ return `You are a separate Pi SDK reviewer session for a goal-oriented long-task loop.
235
+
236
+ ${reviewTargetInstruction} Do not implement fixes, edit files, or commit. You may inspect files and run focused read-only verification commands when useful.
237
+
238
+ Original high-level goal:
239
+
240
+ ${markdownFence(state.goal, "text")}
241
+ ${specificationBlock}
242
+ Goal run: ${state.goalRunId}
243
+ Iteration: ${iteration.iteration}
244
+ Generated TODO path: ${generatedTodo?.todoPath ?? "unknown"}
245
+ Worker result:
246
+
247
+ ${markdownFence(JSON.stringify(workerResult ?? null, null, 2), "json")}
248
+ ${previousContextBlock}
249
+ Decision rules:
250
+ ${decisionRules}
251
+
252
+ Reply with only one JSON object, with no Markdown fence or commentary, matching this schema:
253
+ {
254
+ "decision": "complete" | "incomplete" | "blocked" | "failed",
255
+ "complete": boolean,
256
+ "summary": "short reviewer summary",
257
+ "rationale": "why the goal is or is not complete",
258
+ "remainingWork": ["specific remaining item", "..."]
259
+ }`;
260
+ }
261
+
262
+ export function parseGoalReviewerOutput(
263
+ output: string,
264
+ options: { now?: Date } = {},
265
+ ): Omit<GoalReviewerResultState, "reviewerSessionId" | "reviewerSessionFile"> {
266
+ const parsed = parseJsonObjectFromText(output);
267
+ const decision = normalizeDecision(parsed.decision, parsed.complete);
268
+ const complete = decision === "complete";
269
+ const summary = stringField(parsed.summary) || defaultSummary(decision);
270
+ const rationale = stringField(parsed.rationale) || stringField(parsed.reason) || summary;
271
+ const remainingWork = stringArrayField(parsed.remainingWork ?? parsed.remaining ?? parsed.remaining_items);
272
+ return {
273
+ decision,
274
+ complete,
275
+ summary,
276
+ rationale,
277
+ remainingWork: complete ? [] : remainingWork,
278
+ reviewedAt: (options.now ?? new Date()).toISOString(),
279
+ };
280
+ }
281
+
282
+ export async function runGoalReviewerSession(options: GoalReviewerRunnerOptions): Promise<GoalReviewerSessionResult> {
283
+ const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
284
+ const events: unknown[] = [];
285
+ let assistantText = "";
286
+ let timedOut = false;
287
+ let aborted = false;
288
+ let error: string | undefined;
289
+ let reviewerCostTotal = 0;
290
+ let session: Awaited<ReturnType<typeof sessionFactory>>["session"] | undefined;
291
+ let unsubscribe: (() => void) | undefined;
292
+ let timeout: ReturnType<typeof setTimeout> | undefined;
293
+
294
+ const abortSession = async (reason: string) => {
295
+ if (!session || aborted) {
296
+ return;
297
+ }
298
+ aborted = true;
299
+ error = error ?? reason;
300
+ await session.abort?.();
301
+ };
302
+ const abortListener = () => {
303
+ void abortSession("reviewer session aborted by outer signal").catch((exc: unknown) => {
304
+ error = error ?? errorMessage(exc);
305
+ });
306
+ };
307
+
308
+ try {
309
+ if (options.abortSignal?.aborted) {
310
+ throw new Error("reviewer session aborted before start");
311
+ }
312
+ const factoryResult = await sessionFactory({
313
+ cwd: options.cwd,
314
+ tools: DEFAULT_WORKER_TOOLS,
315
+ model: options.model,
316
+ modelName: options.modelName,
317
+ thinkingLevel: options.thinkingLevel,
318
+ });
319
+ session = factoryResult.session;
320
+ unsubscribe = session.subscribe((event: unknown) => {
321
+ events.push(event);
322
+ const text = assistantTextFromEvent(event);
323
+ if (text) {
324
+ assistantText = text;
325
+ }
326
+ const cost = workerUsageCostFromEvent(event);
327
+ if (cost !== undefined) {
328
+ reviewerCostTotal = Math.max(reviewerCostTotal, cost);
329
+ }
330
+ });
331
+ options.abortSignal?.addEventListener("abort", abortListener, { once: true });
332
+ if (options.timeoutMs > 0) {
333
+ timeout = setTimeout(() => {
334
+ timedOut = true;
335
+ void abortSession(`reviewer exceeded ${options.timeoutMs}ms timeout`).catch((exc: unknown) => {
336
+ error = error ?? errorMessage(exc);
337
+ });
338
+ }, options.timeoutMs);
339
+ }
340
+ await session.prompt(options.prompt);
341
+ assistantText = latestAssistantText(session, assistantText);
342
+ } catch (exc) {
343
+ error = error ?? errorMessage(exc);
344
+ } finally {
345
+ if (timeout) {
346
+ clearTimeout(timeout);
347
+ }
348
+ options.abortSignal?.removeEventListener("abort", abortListener);
349
+ unsubscribe?.();
350
+ if (session) {
351
+ assistantText = latestAssistantText(session, assistantText);
352
+ const statsCost = session.getSessionStats ? workerUsageCostFromStats(await session.getSessionStats()) : undefined;
353
+ if (statsCost !== undefined) {
354
+ reviewerCostTotal = statsCost;
355
+ }
356
+ session.dispose?.();
357
+ }
358
+ }
359
+
360
+ return {
361
+ assistantText,
362
+ reviewerSessionId: session?.sessionId,
363
+ reviewerSessionFile: session?.sessionFile,
364
+ reviewerCostTotal,
365
+ timedOut,
366
+ aborted: aborted || Boolean(options.abortSignal?.aborted),
367
+ error,
368
+ };
369
+ }
370
+
371
+ function currentReviewableIteration(state: GoalLoopState): GoalIterationState {
372
+ const iteration = currentIteration(state, state.currentIteration);
373
+ if (iteration.status !== "todo_executed" && iteration.status !== "failed") {
374
+ throw new GoalReviewError(
375
+ `Goal iteration ${iteration.iteration} is ${iteration.status}; expected worker result review.`,
376
+ {
377
+ state,
378
+ },
379
+ );
380
+ }
381
+ return iteration;
382
+ }
383
+
384
+ function currentIteration(state: GoalLoopState, iterationNumber: number): GoalIterationState {
385
+ const iteration = state.iterations.find((item) => item.iteration === iterationNumber);
386
+ if (!iteration) {
387
+ throw new GoalReviewError(`Goal iteration ${iterationNumber || "<none>"} does not exist.`, { state });
388
+ }
389
+ return iteration;
390
+ }
391
+
392
+ async function recordReviewFailure(options: {
393
+ state: GoalLoopState;
394
+ iteration: GoalIterationState;
395
+ store: GoalStateStore;
396
+ previousTraceLength: number;
397
+ payloadPath: string;
398
+ rawReviewPath: string;
399
+ message: string;
400
+ error: unknown;
401
+ rawReviewerOutput?: string;
402
+ sessionResult?: GoalReviewerSessionResult;
403
+ now: () => Date;
404
+ }): Promise<{ state: GoalLoopState; reviewerResult: GoalReviewerResultState }> {
405
+ if (options.rawReviewerOutput !== undefined) {
406
+ await writeFile(options.rawReviewPath, options.rawReviewerOutput, "utf8");
407
+ }
408
+ const timestamp = options.now().toISOString();
409
+ const reviewerResult: GoalReviewerResultState = {
410
+ decision: "failed",
411
+ complete: false,
412
+ summary: options.message,
413
+ rationale: options.message,
414
+ remainingWork: [],
415
+ reviewerSessionId: options.sessionResult?.reviewerSessionId,
416
+ reviewerSessionFile: options.sessionResult?.reviewerSessionFile,
417
+ payloadPath: options.payloadPath,
418
+ rawReviewPath: options.rawReviewPath,
419
+ reviewerCostTotal: options.sessionResult?.reviewerCostTotal,
420
+ error: errorMessage(options.error),
421
+ reviewedAt: timestamp,
422
+ };
423
+ const state = recordReviewerResult(options.state, options.iteration.iteration, reviewerResult, {
424
+ now: options.now(),
425
+ });
426
+ await persistStateChange(options.store, options.previousTraceLength, state);
427
+ const updatedIteration = currentIteration(state, options.iteration.iteration);
428
+ await options.store.writeIterationSnapshot(updatedIteration);
429
+ await options.store.appendIterationResult(updatedIteration);
430
+ return { state, reviewerResult };
431
+ }
432
+
433
+ async function persistStateChange(
434
+ store: GoalStateStore,
435
+ previousTraceLength: number,
436
+ state: GoalLoopState,
437
+ ): Promise<void> {
438
+ await store.saveState(state);
439
+ await store.appendNewTraceEvents(previousTraceLength, state);
440
+ }
441
+
442
+ function buildGoalSpecificationReviewContext(spec: GoalSpecification, goalSpecificationPath?: string): string {
443
+ return [
444
+ `Goal spec path: ${goalSpecificationPath ?? "<not provided>"}`,
445
+ "",
446
+ goalSpecificationToMarkdown(spec).trim(),
447
+ "",
448
+ "Reviewer evaluation instructions:",
449
+ "- Treat this persisted specification as the source of truth for final evaluation.",
450
+ "- Check in-scope requirements, milestones, acceptance criteria, required verification gates, required artifacts, and design/product constraints before deciding complete.",
451
+ "- Use the original user goal only as traceability context when interpreting the specification.",
452
+ "- Cite specific spec IDs or named criteria in rationale and remainingWork whenever a criterion is satisfied, missing, blocked, or not applicable.",
453
+ ].join("\n");
454
+ }
455
+
456
+ function previousReviewContext(state: GoalLoopState, currentIterationNumber: number): string {
457
+ return state.iterations
458
+ .filter((iteration) => iteration.iteration < currentIterationNumber)
459
+ .map((iteration) => {
460
+ const lines = [`Iteration ${iteration.iteration}: ${iteration.status}`];
461
+ if (iteration.workerResult) {
462
+ lines.push(`Worker: ${iteration.workerResult.status} — ${iteration.workerResult.summary}`);
463
+ }
464
+ if (iteration.reviewerResult) {
465
+ lines.push(`Reviewer: ${iteration.reviewerResult.decision} — ${iteration.reviewerResult.rationale}`);
466
+ if (iteration.reviewerResult.remainingWork.length > 0) {
467
+ lines.push("Remaining work:", ...iteration.reviewerResult.remainingWork.map((item) => `- ${item}`));
468
+ }
469
+ }
470
+ return lines.join("\n");
471
+ })
472
+ .join("\n\n");
473
+ }
474
+
475
+ function parseJsonObjectFromText(text: string): Record<string, unknown> {
476
+ const trimmed = text.trim();
477
+ if (!trimmed) {
478
+ throw new GoalReviewError("Reviewer output was empty.");
479
+ }
480
+
481
+ for (const candidate of jsonCandidates(trimmed)) {
482
+ try {
483
+ const parsed = JSON.parse(candidate) as unknown;
484
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
485
+ return parsed as Record<string, unknown>;
486
+ }
487
+ } catch {
488
+ // Try the next candidate.
489
+ }
490
+ }
491
+ throw new GoalReviewError("Reviewer output did not contain a JSON object.");
492
+ }
493
+
494
+ function jsonCandidates(text: string): string[] {
495
+ const candidates = [text];
496
+ const fenced = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(text);
497
+ if (fenced?.[1]) {
498
+ candidates.push(fenced[1].trim());
499
+ }
500
+ const start = text.indexOf("{");
501
+ const end = text.lastIndexOf("}");
502
+ if (start >= 0 && end > start) {
503
+ candidates.push(text.slice(start, end + 1));
504
+ }
505
+ return candidates;
506
+ }
507
+
508
+ function normalizeDecision(decisionValue: unknown, completeValue: unknown): GoalReviewerDecision {
509
+ const decision = typeof decisionValue === "string" ? decisionValue.trim().toLowerCase() : "";
510
+ if (decision === "complete" || decision === "incomplete" || decision === "blocked" || decision === "failed") {
511
+ return decision;
512
+ }
513
+ if (typeof completeValue === "boolean") {
514
+ return completeValue ? "complete" : "incomplete";
515
+ }
516
+ throw new GoalReviewError("Reviewer JSON must include decision or complete.");
517
+ }
518
+
519
+ function stringField(value: unknown): string {
520
+ return typeof value === "string" ? value.trim() : "";
521
+ }
522
+
523
+ function stringArrayField(value: unknown): string[] {
524
+ if (Array.isArray(value)) {
525
+ return value.map((item) => String(item).trim()).filter(Boolean);
526
+ }
527
+ if (typeof value === "string" && value.trim()) {
528
+ return [value.trim()];
529
+ }
530
+ return [];
531
+ }
532
+
533
+ function defaultSummary(decision: GoalReviewerDecision): string {
534
+ switch (decision) {
535
+ case "complete":
536
+ return "Reviewer confirmed the goal is complete.";
537
+ case "incomplete":
538
+ return "Reviewer found remaining work.";
539
+ case "blocked":
540
+ return "Reviewer found the goal is blocked.";
541
+ case "failed":
542
+ return "Reviewer found the goal loop failed.";
543
+ }
544
+ }
545
+
546
+ function latestAssistantText(
547
+ session: { getLastAssistantText?: () => string | undefined; messages?: unknown[] },
548
+ fallback: string,
549
+ ): string {
550
+ return session.getLastAssistantText?.() || lastAssistantTextFromMessages(session.messages) || fallback;
551
+ }
552
+
553
+ function markdownFence(value: string, language: string): string {
554
+ const ticks = longestBacktickRun(value) + 1;
555
+ const fence = "`".repeat(Math.max(3, ticks));
556
+ return `${fence}${language}\n${value.trim()}\n${fence}`;
557
+ }
558
+
559
+ function longestBacktickRun(value: string): number {
560
+ let longest = 0;
561
+ let current = 0;
562
+ for (const char of value) {
563
+ if (char === "`") {
564
+ current += 1;
565
+ longest = Math.max(longest, current);
566
+ } else {
567
+ current = 0;
568
+ }
569
+ }
570
+ return longest;
571
+ }
572
+
573
+ function errorMessage(error: unknown): string {
574
+ return error instanceof Error ? error.message : String(error);
575
+ }