pi-long-task 0.4.0 → 0.5.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/index.ts CHANGED
@@ -276,10 +276,8 @@ function renderSidebarWidgetLines(update: CoordinatorProgressUpdate): string[] {
276
276
  const progress = update.taskProgress;
277
277
  const summary = progress?.summary;
278
278
  const statusDetails = sidebarUpdateStateDetails(update);
279
- const lines = [
280
- "Pi Long Task",
281
- `${statusDetails.icon} ${statusDetails.label} · ${update.activeStatus ?? update.message}`,
282
- ];
279
+ const statusText = normalizeActiveStatus(update.activeStatus ?? update.message);
280
+ const lines = ["Pi Long Task", `${statusDetails.icon} ${statusDetails.label} · ${statusText}`];
283
281
  if (summary) {
284
282
  lines.push(
285
283
  `Tasks: ${summary.completedTasks}/${summary.totalTasks} · ${summary.completedPercent}%` +
@@ -396,7 +394,8 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
396
394
  rows.push(theme.fg("success", "No active task"));
397
395
  }
398
396
 
399
- const activeStatus = update.activeStatus ?? normalizeMessageForSidebar(update.message, update);
397
+ const rawActiveStatus = update.activeStatus ?? normalizeMessageForSidebar(update.message, update);
398
+ const activeStatus = rawActiveStatus ? normalizeActiveStatus(rawActiveStatus) : undefined;
400
399
  if (currentTask && activeStatus) {
401
400
  rows.push("", sidebarHeading("Active status", theme));
402
401
  rows.push(...wrapPlainText(activeStatus, width, 6).map((line) => theme.fg("accent", line)));
@@ -573,6 +572,8 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
573
572
  return { icon: "✓", label: "Plan ready", color: "success" };
574
573
  case "task_start":
575
574
  return { icon: "▢", label: "Running task", color: "accent" };
575
+ case "worker_session":
576
+ return { icon: "↻", label: "Worker session", color: "accent" };
576
577
  case "worker_tool":
577
578
  return { icon: "+", label: "Worker tool", color: "warning" };
578
579
  case "task_done":
@@ -681,6 +682,18 @@ function normalizeMessageForSidebar(updateMessage: string, update: CoordinatorPr
681
682
  return title && message.includes(title) && message.length <= title.length + 16 ? undefined : message;
682
683
  }
683
684
 
685
+ function normalizeActiveStatus(status: string): string {
686
+ const normalized = status.trim();
687
+ const firstOutcome = /^(Finished|Failed):\s*/i.exec(normalized)?.[1];
688
+ if (!firstOutcome) {
689
+ return normalized;
690
+ }
691
+
692
+ const activity = normalized.replace(/^(?:(?:Finished|Failed):\s*)+/i, "");
693
+ const outcome = firstOutcome.toLowerCase() === "failed" ? "Failed" : "Finished";
694
+ return activity ? `${outcome}: ${activity}` : `${outcome}:`;
695
+ }
696
+
684
697
  function wrapPlainText(text: string, width: number, limit?: number): string[] {
685
698
  const safeWidth = Math.max(8, width);
686
699
  const words = text.trim().split(/\s+/).filter(Boolean);
@@ -3,8 +3,12 @@ export interface ParsedWorkerRuntimeConfig {
3
3
  maxAttemptsPerTask?: number;
4
4
  taskTimeoutMs?: number;
5
5
  maxBashTimeoutMs?: number;
6
+ workerSessionReuseEnabled?: boolean;
7
+ workerSessionReuseContextThresholdPercent?: number;
6
8
  }
7
9
 
10
+ type MutableWorkerRuntimeConfig = ParsedWorkerRuntimeConfig & { provider?: string; model?: string };
11
+
8
12
  const MODEL_TOKEN_RE = /[A-Za-z0-9][A-Za-z0-9._~:+/@-]*/;
9
13
  const STOP_WORDS = new Set([
10
14
  "and",
@@ -28,7 +32,7 @@ const STOP_WORDS = new Set([
28
32
  ]);
29
33
 
30
34
  export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfig {
31
- const state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string } = {};
35
+ const state: MutableWorkerRuntimeConfig = {};
32
36
 
33
37
  parseLineDirectives(text, state);
34
38
  parseNaturalLanguageDirectives(text, state);
@@ -39,13 +43,16 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
39
43
  ...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
40
44
  ...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
41
45
  ...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
46
+ ...(state.workerSessionReuseEnabled !== undefined
47
+ ? { workerSessionReuseEnabled: state.workerSessionReuseEnabled }
48
+ : {}),
49
+ ...(state.workerSessionReuseContextThresholdPercent !== undefined
50
+ ? { workerSessionReuseContextThresholdPercent: state.workerSessionReuseContextThresholdPercent }
51
+ : {}),
42
52
  };
43
53
  }
44
54
 
45
- function parseLineDirectives(
46
- text: string,
47
- state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
48
- ): void {
55
+ function parseLineDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
49
56
  for (const rawLine of text.split(/\r?\n/)) {
50
57
  const line = rawLine.replace(/^\s{0,3}>+\s?/, "").trim();
51
58
  const match = line.match(
@@ -64,10 +71,7 @@ function parseLineDirectives(
64
71
  }
65
72
  }
66
73
 
67
- function parseNaturalLanguageDirectives(
68
- text: string,
69
- state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
70
- ): void {
74
+ function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
71
75
  captureTokens(
72
76
  text,
73
77
  /\bworker\s+(?:model|provider\/model)\s*(?:is|=|:|to|as)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
@@ -139,13 +143,38 @@ function parseNaturalLanguageDirectives(
139
143
  state.maxBashTimeoutMs = value;
140
144
  },
141
145
  );
146
+
147
+ for (const match of text.matchAll(
148
+ /\b(?:worker\s+)?session\s+reuse(?:\s+is|\s*=|\s*:|\s+to)?\s+(enabled|disabled|on|off|true|false)\b/gi,
149
+ )) {
150
+ const enabled = booleanSetting(match[1] ?? "");
151
+ if (enabled !== undefined) state.workerSessionReuseEnabled = enabled;
152
+ }
153
+ for (const match of text.matchAll(
154
+ /\b(?:worker\s+)?(?:session\s+)?reuse\s+context(?:\s+usage)?\s+threshold\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?)\s*%/gi,
155
+ )) {
156
+ const threshold = percentageFromText(match[1] ?? "");
157
+ if (threshold !== undefined) state.workerSessionReuseContextThresholdPercent = threshold;
158
+ }
142
159
  }
143
160
 
144
- function applyDirective(
145
- key: string,
146
- value: string,
147
- state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
148
- ): void {
161
+ function applyDirective(key: string, value: string, state: MutableWorkerRuntimeConfig): void {
162
+ if (/\breuse\b/.test(key) && /\b(?:threshold|context)\b/.test(key)) {
163
+ const threshold = percentageFromText(value);
164
+ if (threshold !== undefined) {
165
+ state.workerSessionReuseContextThresholdPercent = threshold;
166
+ }
167
+ return;
168
+ }
169
+
170
+ if (/\breuse\b/.test(key)) {
171
+ const enabled = booleanSetting(value);
172
+ if (enabled !== undefined) {
173
+ state.workerSessionReuseEnabled = enabled;
174
+ }
175
+ return;
176
+ }
177
+
149
178
  if (/\bprovider\b/.test(key)) {
150
179
  const token = modelToken(value);
151
180
  if (token) {
@@ -250,6 +279,26 @@ function positiveIntegerFromText(value: string): number | undefined {
250
279
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
251
280
  }
252
281
 
282
+ function percentageFromText(value: string): number | undefined {
283
+ const match = /\d+(?:\.\d+)?/.exec(value);
284
+ if (!match) {
285
+ return undefined;
286
+ }
287
+ const parsed = Number.parseFloat(match[0]);
288
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 100 ? parsed : undefined;
289
+ }
290
+
291
+ function booleanSetting(value: string): boolean | undefined {
292
+ const normalized = trimDirectiveValue(value).toLowerCase().split(/\s+/)[0];
293
+ if (normalized === "enabled" || normalized === "on" || normalized === "true" || normalized === "yes") {
294
+ return true;
295
+ }
296
+ if (normalized === "disabled" || normalized === "off" || normalized === "false" || normalized === "no") {
297
+ return false;
298
+ }
299
+ return undefined;
300
+ }
301
+
253
302
  function durationMsFromText(value: string, options: { allowBareSeconds: boolean }): number | undefined {
254
303
  const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
255
304
  value,
@@ -0,0 +1,389 @@
1
+ import path from "node:path";
2
+
3
+ /** Reuse starts conservatively rotating before two thirds of the context is occupied. */
4
+ export const DEFAULT_WORKER_SESSION_REUSE_CONTEXT_THRESHOLD_PERCENT = 62.5;
5
+ export const DEFAULT_WORKER_SESSION_REUSE_ENABLED = true;
6
+
7
+ export interface WorkerSessionReuseConfig {
8
+ enabled: boolean;
9
+ contextThresholdPercent: number;
10
+ }
11
+
12
+ export interface WorkerSessionCompatibilityInput {
13
+ coordinatorRunId: string;
14
+ repositoryRoot: string;
15
+ worktreeRoot?: string;
16
+ provider?: string;
17
+ modelName?: string;
18
+ model?: unknown;
19
+ tools?: readonly string[];
20
+ thinkingLevel?: string;
21
+ /** Any additional worker option whose value changes session behavior. */
22
+ workerOptions?: Readonly<Record<string, unknown>>;
23
+ agentDir?: string;
24
+ modelRuntime?: unknown;
25
+ authStorage?: unknown;
26
+ modelRegistry?: unknown;
27
+ settingsManager?: unknown;
28
+ resourceLoader?: unknown;
29
+ sessionFactory?: unknown;
30
+ /** Additional options that can alter the constructed AgentSession. */
31
+ sessionConfiguration?: Readonly<Record<string, unknown>>;
32
+ }
33
+
34
+ /**
35
+ * A run-scoped description of every input that can make two worker sessions
36
+ * incompatible. Individual fields are retained so policy diagnostics can name
37
+ * the mismatch instead of reporting only an opaque hash.
38
+ */
39
+ export interface WorkerSessionCompatibilityFingerprint {
40
+ coordinatorRunId: string;
41
+ repositoryRoot: string;
42
+ worktreeRoot: string;
43
+ provider: string;
44
+ model: string;
45
+ workerOptions: string;
46
+ sessionConfiguration: string;
47
+ }
48
+
49
+ export type WorkerSessionHealth =
50
+ | "healthy"
51
+ | "unknown"
52
+ | "timed_out"
53
+ | "aborted"
54
+ | "cancelled"
55
+ | "unrecoverable_error"
56
+ | "invalid_state";
57
+
58
+ export type WorkerSessionReuseState = "reusable" | "rotation_required" | "isolated";
59
+
60
+ export type WorkerSessionRetryMode = "no_retry" | "safe_partial_continuation" | "independent_retry";
61
+
62
+ export type WorkerSessionRetryReasonCode =
63
+ | "task_completed"
64
+ | "explicit_partial_work"
65
+ | "retry_after_timeout"
66
+ | "retry_after_abort"
67
+ | "retry_after_cancellation"
68
+ | "retry_after_error"
69
+ | "retry_after_invalid_result"
70
+ | "retry_after_non_partial_status";
71
+
72
+ export interface WorkerSessionRetryInput {
73
+ done: boolean;
74
+ reportedStatus: string;
75
+ completeTaskResult: boolean;
76
+ timedOut: boolean;
77
+ aborted: boolean;
78
+ cancelled?: boolean;
79
+ error?: string;
80
+ }
81
+
82
+ export interface WorkerSessionRetryDecision {
83
+ mode: WorkerSessionRetryMode;
84
+ reasonCode: WorkerSessionRetryReasonCode;
85
+ mayContinueInSession: boolean;
86
+ }
87
+
88
+ export type WorkerSessionReuseReasonCode =
89
+ | "reuse_eligible"
90
+ | "reuse_disabled"
91
+ | "session_disposed"
92
+ | "session_active"
93
+ | "health_unknown"
94
+ | "health_timed_out"
95
+ | "health_aborted"
96
+ | "health_cancelled"
97
+ | "health_unrecoverable_error"
98
+ | "health_invalid_state"
99
+ | "coordinator_run_mismatch"
100
+ | "repository_mismatch"
101
+ | "worktree_mismatch"
102
+ | "provider_mismatch"
103
+ | "model_mismatch"
104
+ | "worker_options_mismatch"
105
+ | "session_configuration_mismatch"
106
+ | "context_usage_unavailable"
107
+ | "context_usage_invalid"
108
+ | "context_threshold_reached";
109
+
110
+ export interface WorkerSessionReuseCandidate {
111
+ health: WorkerSessionHealth;
112
+ compatibility: WorkerSessionCompatibilityFingerprint;
113
+ /** A normalized percentage in the inclusive range 0..100. */
114
+ contextUsagePercent?: number;
115
+ assignmentState: "idle" | "active";
116
+ disposed: boolean;
117
+ }
118
+
119
+ export interface WorkerSessionReusePolicyInput {
120
+ config: WorkerSessionReuseConfig;
121
+ candidate: WorkerSessionReuseCandidate;
122
+ requestedCompatibility: WorkerSessionCompatibilityFingerprint;
123
+ }
124
+
125
+ export interface WorkerSessionReuseDecision {
126
+ state: WorkerSessionReuseState;
127
+ reasonCode: WorkerSessionReuseReasonCode;
128
+ reusable: boolean;
129
+ healthy: boolean;
130
+ contextUsagePercent?: number;
131
+ contextThresholdPercent: number;
132
+ }
133
+
134
+ const objectIds = new WeakMap<object, number>();
135
+ const symbolIds = new Map<symbol, number>();
136
+ let nextObjectId = 1;
137
+
138
+ export function normalizeWorkerSessionReuseThreshold(value: unknown): number {
139
+ return isValidThresholdPercentage(value) ? value : DEFAULT_WORKER_SESSION_REUSE_CONTEXT_THRESHOLD_PERCENT;
140
+ }
141
+
142
+ export function resolveWorkerSessionReuseConfig(options: {
143
+ enabled?: boolean;
144
+ contextThresholdPercent?: number;
145
+ }): WorkerSessionReuseConfig {
146
+ return {
147
+ enabled: options.enabled ?? DEFAULT_WORKER_SESSION_REUSE_ENABLED,
148
+ contextThresholdPercent: normalizeWorkerSessionReuseThreshold(options.contextThresholdPercent),
149
+ };
150
+ }
151
+
152
+ export function createWorkerSessionCompatibilityFingerprint(
153
+ input: WorkerSessionCompatibilityInput,
154
+ ): WorkerSessionCompatibilityFingerprint {
155
+ const parsedModel = splitProviderModel(input.modelName);
156
+ const provider = normalizeOptional(input.provider) ?? parsedModel.provider ?? "<default>";
157
+ const model = parsedModel.model
158
+ ? `name:${parsedModel.model}`
159
+ : input.model !== undefined
160
+ ? `value:${fingerprintValue(input.model)}`
161
+ : "<default>";
162
+ const repositoryRoot = path.resolve(input.repositoryRoot);
163
+ const worktreeRoot = path.resolve(input.worktreeRoot ?? repositoryRoot);
164
+
165
+ return {
166
+ coordinatorRunId: input.coordinatorRunId,
167
+ repositoryRoot,
168
+ worktreeRoot,
169
+ provider,
170
+ model,
171
+ workerOptions: canonicalRecord({
172
+ tools: input.tools ? [...input.tools] : undefined,
173
+ thinkingLevel: input.thinkingLevel,
174
+ workerOptions: input.workerOptions,
175
+ }),
176
+ sessionConfiguration: canonicalRecord({
177
+ agentDir: input.agentDir ? path.resolve(input.agentDir) : undefined,
178
+ modelRuntime: fingerprintOpaqueOption(input.modelRuntime),
179
+ authStorage: fingerprintOpaqueOption(input.authStorage),
180
+ modelRegistry: fingerprintOpaqueOption(input.modelRegistry),
181
+ settingsManager: fingerprintOpaqueOption(input.settingsManager),
182
+ resourceLoader: fingerprintOpaqueOption(input.resourceLoader),
183
+ sessionFactory: fingerprintOpaqueOption(input.sessionFactory),
184
+ sessionConfiguration: input.sessionConfiguration,
185
+ }),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Classify retry ownership independently from session health. Only an explicit,
191
+ * complete `partial` result is eligible to continue in the same session; every
192
+ * failure mode remains an independent attempt and therefore starts fresh.
193
+ */
194
+ export function classifyWorkerSessionRetry(input: WorkerSessionRetryInput): WorkerSessionRetryDecision {
195
+ if (input.timedOut) {
196
+ return { mode: "independent_retry", reasonCode: "retry_after_timeout", mayContinueInSession: false };
197
+ }
198
+ if (input.cancelled) {
199
+ return { mode: "independent_retry", reasonCode: "retry_after_cancellation", mayContinueInSession: false };
200
+ }
201
+ if (input.aborted) {
202
+ return { mode: "independent_retry", reasonCode: "retry_after_abort", mayContinueInSession: false };
203
+ }
204
+ if (input.error) {
205
+ return { mode: "independent_retry", reasonCode: "retry_after_error", mayContinueInSession: false };
206
+ }
207
+ if (input.done) {
208
+ return { mode: "no_retry", reasonCode: "task_completed", mayContinueInSession: false };
209
+ }
210
+ if (!input.completeTaskResult) {
211
+ return { mode: "independent_retry", reasonCode: "retry_after_invalid_result", mayContinueInSession: false };
212
+ }
213
+ if (input.reportedStatus.trim().toLowerCase() === "partial") {
214
+ return { mode: "safe_partial_continuation", reasonCode: "explicit_partial_work", mayContinueInSession: true };
215
+ }
216
+ return { mode: "independent_retry", reasonCode: "retry_after_non_partial_status", mayContinueInSession: false };
217
+ }
218
+
219
+ /** Decide whether an idle completed session can accept the next assignment. */
220
+ export function decideWorkerSessionReuse(input: WorkerSessionReusePolicyInput): WorkerSessionReuseDecision {
221
+ const threshold = normalizeWorkerSessionReuseThreshold(input.config.contextThresholdPercent);
222
+ const base = {
223
+ contextThresholdPercent: threshold,
224
+ ...(input.candidate.contextUsagePercent !== undefined
225
+ ? { contextUsagePercent: input.candidate.contextUsagePercent }
226
+ : {}),
227
+ };
228
+
229
+ if (!input.config.enabled) {
230
+ return {
231
+ ...base,
232
+ state: "isolated",
233
+ reasonCode: "reuse_disabled",
234
+ reusable: false,
235
+ healthy: input.candidate.health === "healthy" && !input.candidate.disposed,
236
+ };
237
+ }
238
+ if (input.candidate.disposed) {
239
+ return { ...base, state: "rotation_required", reasonCode: "session_disposed", reusable: false, healthy: false };
240
+ }
241
+ if (input.candidate.assignmentState === "active") {
242
+ return { ...base, state: "rotation_required", reasonCode: "session_active", reusable: false, healthy: false };
243
+ }
244
+ if (input.candidate.health !== "healthy") {
245
+ return {
246
+ ...base,
247
+ state: "rotation_required",
248
+ reasonCode: healthReason(input.candidate.health),
249
+ reusable: false,
250
+ healthy: false,
251
+ };
252
+ }
253
+
254
+ const incompatibility = compatibilityReason(input.candidate.compatibility, input.requestedCompatibility);
255
+ if (incompatibility) {
256
+ return { ...base, state: "rotation_required", reasonCode: incompatibility, reusable: false, healthy: true };
257
+ }
258
+
259
+ const usage = input.candidate.contextUsagePercent;
260
+ if (usage === undefined) {
261
+ return {
262
+ ...base,
263
+ state: "rotation_required",
264
+ reasonCode: "context_usage_unavailable",
265
+ reusable: false,
266
+ healthy: true,
267
+ };
268
+ }
269
+ if (!isValidContextUsagePercentage(usage)) {
270
+ return {
271
+ ...base,
272
+ state: "rotation_required",
273
+ reasonCode: "context_usage_invalid",
274
+ reusable: false,
275
+ healthy: true,
276
+ };
277
+ }
278
+ if (usage >= threshold) {
279
+ return {
280
+ ...base,
281
+ state: "rotation_required",
282
+ reasonCode: "context_threshold_reached",
283
+ reusable: false,
284
+ healthy: true,
285
+ };
286
+ }
287
+
288
+ return { ...base, state: "reusable", reasonCode: "reuse_eligible", reusable: true, healthy: true };
289
+ }
290
+
291
+ function compatibilityReason(
292
+ current: WorkerSessionCompatibilityFingerprint,
293
+ requested: WorkerSessionCompatibilityFingerprint,
294
+ ): WorkerSessionReuseReasonCode | undefined {
295
+ if (current.coordinatorRunId !== requested.coordinatorRunId) return "coordinator_run_mismatch";
296
+ if (current.repositoryRoot !== requested.repositoryRoot) return "repository_mismatch";
297
+ if (current.worktreeRoot !== requested.worktreeRoot) return "worktree_mismatch";
298
+ if (current.provider !== requested.provider) return "provider_mismatch";
299
+ if (current.model !== requested.model) return "model_mismatch";
300
+ if (current.workerOptions !== requested.workerOptions) return "worker_options_mismatch";
301
+ if (current.sessionConfiguration !== requested.sessionConfiguration) return "session_configuration_mismatch";
302
+ return undefined;
303
+ }
304
+
305
+ function healthReason(health: Exclude<WorkerSessionHealth, "healthy">): WorkerSessionReuseReasonCode {
306
+ return health === "unknown" ? "health_unknown" : `health_${health}`;
307
+ }
308
+
309
+ function splitProviderModel(modelName: string | undefined): { provider?: string; model?: string } {
310
+ const normalized = normalizeOptional(modelName);
311
+ if (!normalized) return {};
312
+ const separator = normalized.indexOf("/");
313
+ if (separator <= 0 || separator === normalized.length - 1) return { model: normalized };
314
+ return { provider: normalized.slice(0, separator), model: normalized.slice(separator + 1) };
315
+ }
316
+
317
+ function canonicalRecord(value: Readonly<Record<string, unknown>>, seen = new Set<object>()): string {
318
+ return Object.keys(value)
319
+ .sort()
320
+ .filter((key) => value[key] !== undefined)
321
+ .map((key) => `${JSON.stringify(key)}:${fingerprintValue(value[key], seen)}`)
322
+ .join("|");
323
+ }
324
+
325
+ function fingerprintValue(value: unknown, seen = new Set<object>()): string {
326
+ if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
327
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : `number:${String(value)}`;
328
+ if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") {
329
+ return opaqueValue(value);
330
+ }
331
+ if (value === undefined) return "undefined";
332
+ if (Array.isArray(value)) {
333
+ if (seen.has(value)) return opaqueValue(value);
334
+ seen.add(value);
335
+ const result = `[${value.map((item) => fingerprintValue(item, seen)).join(",")}]`;
336
+ seen.delete(value);
337
+ return result;
338
+ }
339
+ if (typeof value === "object") {
340
+ if (seen.has(value)) return opaqueValue(value);
341
+ const prototype = Object.getPrototypeOf(value);
342
+ if (prototype === Object.prototype || prototype === null) {
343
+ seen.add(value);
344
+ const result = `{${canonicalRecord(value as Record<string, unknown>, seen)}}`;
345
+ seen.delete(value);
346
+ return result;
347
+ }
348
+ return opaqueValue(value);
349
+ }
350
+ return String(value);
351
+ }
352
+
353
+ function fingerprintOpaqueOption(value: unknown): string | undefined {
354
+ return value === undefined ? undefined : opaqueValue(value);
355
+ }
356
+
357
+ function opaqueValue(value: unknown): string {
358
+ if ((typeof value !== "object" || value === null) && typeof value !== "function" && typeof value !== "symbol") {
359
+ return `${typeof value}:${String(value)}`;
360
+ }
361
+ if (typeof value === "symbol") {
362
+ let symbolId = symbolIds.get(value);
363
+ if (!symbolId) {
364
+ symbolId = nextObjectId++;
365
+ symbolIds.set(value, symbolId);
366
+ }
367
+ return `identity:${symbolId}`;
368
+ }
369
+ const object = value as object;
370
+ let objectId = objectIds.get(object);
371
+ if (!objectId) {
372
+ objectId = nextObjectId++;
373
+ objectIds.set(object, objectId);
374
+ }
375
+ return `identity:${objectId}`;
376
+ }
377
+
378
+ function normalizeOptional(value: string | undefined): string | undefined {
379
+ const normalized = value?.trim();
380
+ return normalized || undefined;
381
+ }
382
+
383
+ function isValidThresholdPercentage(value: unknown): value is number {
384
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100;
385
+ }
386
+
387
+ function isValidContextUsagePercentage(value: unknown): value is number {
388
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
389
+ }