pi-jscpd 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ published releases use [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-06
11
+
12
+ ### Added
13
+
14
+ - Added Pi-native compact and expanded transcript rendering for `jscpd_run`,
15
+ covering scan targets, status, findings, expected fail-open outcomes, and
16
+ bounded public details without exposing overlay-only or analyzer internals.
17
+ - Added a monthly npm downloads badge to the README.
18
+
19
+ ## [0.1.1] - 2026-09-05
20
+
21
+ ### Changed
22
+
23
+ - Simplified README installation guidance, kept detailed Effect internals in the
24
+ architecture guide, and added links to the maintainer's other Pi packages.
25
+
26
+ ### Security
27
+
28
+ - Removed the one-time npm bootstrap credential path and repository secret after
29
+ the registry confirmed `0.1.0`; future releases use trusted publishing only.
30
+
10
31
  ## [0.1.0] - 2026-09-05
11
32
 
12
33
  ### Added
@@ -95,5 +116,7 @@ published releases use [Semantic Versioning](https://semver.org/).
95
116
  - Project paths, child output, reports, temporary directories, cancellation,
96
117
  configuration trust, and lifecycle cleanup are bounded and fail open.
97
118
 
98
- [Unreleased]: https://github.com/revazi/pi-jscpd/compare/v0.1.0...HEAD
119
+ [Unreleased]: https://github.com/revazi/pi-jscpd/compare/v0.2.0...HEAD
120
+ [0.2.0]: https://github.com/revazi/pi-jscpd/compare/v0.1.1...v0.2.0
121
+ [0.1.1]: https://github.com/revazi/pi-jscpd/compare/v0.1.0...v0.1.1
99
122
  [0.1.0]: https://github.com/revazi/pi-jscpd/releases/tag/v0.1.0
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/revazi/pi-jscpd/actions/workflows/ci.yml/badge.svg)](https://github.com/revazi/pi-jscpd/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/pi-jscpd.svg)](https://www.npmjs.com/package/pi-jscpd)
5
+ [![npm downloads](https://img.shields.io/npm/dm/pi-jscpd.svg)](https://www.npmjs.com/package/pi-jscpd)
5
6
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
7
  [![GitHub issues](https://img.shields.io/github/issues/revazi/pi-jscpd.svg)](https://github.com/revazi/pi-jscpd/issues)
7
8
 
@@ -18,20 +19,9 @@ languages, and statistics.
18
19
  pi install npm:pi-jscpd
19
20
  ```
20
21
 
21
- The package includes pinned jscpd `5.1.2` and Effect `3.22.1` runtime
22
- dependencies plus an on-demand `jscpd` agent skill—there is no separate analyzer
23
- installation and no runtime download.
24
- Effect owns bounded process execution, analyzer probing, temporary report
25
- workspaces, adapter serialization, trusted configuration and signal reads,
26
- canonical source-path validation, baseline generations, changed-file attribution,
27
- acknowledgement transactions, verification checkpoints, scoped scheduling,
28
- automatic finding delivery, scan/changed/status/Fallow application workflows,
29
- and one managed runtime/layer graph per extension instance. Legacy Promise/service
30
- paths are removed from those layers, and host-launched baseline work is settled at
31
- lifecycle boundaries. If your project already installs
32
- a compatible jscpd locally, or `jscpd`/`cpd` is available on `PATH`, the
33
- extension uses that installation first and keeps the bundled version as a
34
- fallback.
22
+ `pi-jscpd` includes jscpd and its on-demand agent skill, so there is no
23
+ separate analyzer setup or runtime download. It prefers a compatible project or
24
+ `PATH` installation and otherwise uses its bundled analyzer.
35
25
 
36
26
  Start Pi in your project and verify the setup:
37
27
 
@@ -68,7 +58,13 @@ Pi can use the same operations through the `jscpd_run` tool:
68
58
  ```
69
59
 
70
60
  Supported tool commands are `scan`, `changed`, `status`, `off`, `on`, and
71
- `help`. In TUI mode, `/jscpd ` provides subcommand autocomplete with labels and
61
+ `help`. In TUI mode, tool calls use a compact native transcript view for clean,
62
+ findings, unavailable, timeout, cancellation, status, and session-control
63
+ results. Expanding a result shows only its bounded public terminal presentation;
64
+ it never reveals analyzer output, temporary paths, source fragments, or the
65
+ overlay-only finding cache.
66
+
67
+ In TUI mode, `/jscpd ` provides subcommand autocomplete with labels and
72
68
  descriptions; selecting `scan` leaves the editor ready for an optional target.
73
69
 
74
70
  The package also exposes `/skill:jscpd`. Pi advertises only the skill's concise
@@ -194,6 +190,16 @@ and limitations.
194
190
  See the [compatibility policy](docs/compatibility.md) for the exact tested
195
191
  fixtures and certification matrix.
196
192
 
193
+ ## More Pi packages by Revaz
194
+
195
+ | Package | Purpose |
196
+ | --- | --- |
197
+ | [`pi-fallow`](https://www.npmjs.com/package/pi-fallow) | Fallow codebase intelligence for Pi |
198
+ | [`pi-reads`](https://www.npmjs.com/package/pi-reads) | Source capture, cited reading, Obsidian, EPUB, PDF, and Kindle workflows |
199
+ | [`pi-career`](https://www.npmjs.com/package/pi-career) | Deterministic resume and career workflows |
200
+ | [`pi-tmux-orchestrator`](https://www.npmjs.com/package/pi-tmux-orchestrator) | Multi-agent coordination in tmux |
201
+ | [`@tasklight/pi-tasklight`](https://www.npmjs.com/package/@tasklight/pi-tasklight) | Tasklight notifications for Pi |
202
+
197
203
  ## Development
198
204
 
199
205
  ```sh
@@ -6,6 +6,21 @@ Applies to: bare `/jscpd` only
6
6
 
7
7
  Does not change: `/jscpd scan`, `/jscpd changed`, `/jscpd status`, session controls, or `jscpd_run`
8
8
 
9
+ ## Relationship to the tool transcript
10
+
11
+ The `jscpd_run` agent tool has a separate Pi-native transcript renderer. Its
12
+ collapsed call shows the operation, at most three bounded scan targets, any
13
+ additional-target count, and Pi's project working directory. Collapsed results
14
+ summarize clean, findings, unavailable, timeout, cancellation, fail-open,
15
+ status, help, and session-control states without synthetic exit codes or timing
16
+ placeholders. Expanding a result renders only the configured-limit public
17
+ terminal presentation.
18
+
19
+ Transcript rendering is display-only. It never reads source fragments, raw
20
+ analyzer output, temporary report paths, environment values, internal
21
+ fingerprints, or the overlay-only finding cache, and it does not change tool
22
+ content, scans, acknowledgements, persistence, or cancellation.
23
+
9
24
  Overlay actions route through the extension's single managed runtime while Pi TUI
10
25
  rendering and input remain the host adapter. The implementation preserves every
11
26
  interaction below.
package/docs/release.md CHANGED
@@ -43,20 +43,18 @@ artifact, and cannot publish or create a GitHub release.
43
43
  lifecycle scripts disabled; and
44
44
  8. creates the matching GitHub release only after npm publication succeeds.
45
45
 
46
- Because npm cannot attach trusted-publisher settings before an unscoped package
47
- exists, the `v0.1.0` publish step alone may receive a short-lived granular
48
- `NPM_TOKEN` repository secret for first-publication bootstrap. Dependency
49
- installation and release validation do not receive it, and `npm publish` runs
50
- with lifecycle scripts disabled. Delete the secret and remove this fallback as
51
- soon as npm confirms `0.1.0`; all later releases must use OIDC only. Default
52
- GitHub permissions are empty; the release job receives only `contents: write`
53
- for the GitHub release and `id-token: write` for trusted publishing.
54
-
55
- Before later tags are pushed, the repository's protected `npm` environment and
56
- npm trusted-publisher binding must match the exact repository and
57
- `.github/workflows/release.yml`. Authentication details, token values, `.npmrc`
58
- files, screenshots, and credential output must never be committed or copied into
59
- issues or CI logs.
46
+ The first publication used a short-lived granular token isolated to the
47
+ lifecycle-disabled publish step because npm could not attach trusted-publisher
48
+ settings before the unscoped package existed. That repository secret and
49
+ workflow fallback were deleted immediately after npm confirmed `0.1.0`.
50
+
51
+ All later releases must use OIDC only. Default GitHub permissions are empty; the
52
+ release job receives only `contents: write` for the GitHub release and
53
+ `id-token: write` for trusted publishing. Before another tag is pushed, the
54
+ repository's protected `npm` environment and npm trusted-publisher binding must
55
+ match the exact repository and `.github/workflows/release.yml`. Authentication
56
+ details, token values, `.npmrc` files, screenshots, and credential output must
57
+ never be committed or copied into issues or CI logs.
60
58
 
61
59
  ## Version and changelog procedure
62
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jscpd",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A Pi-native, polyglot duplication guardrail powered by jscpd.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/extension.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  type JscpdSessionModeService,
69
69
  type JscpdStatusService,
70
70
  } from "./status.js";
71
+ import { renderJscpdToolCall, renderJscpdToolResult } from "./tool-render.js";
71
72
  import type { JscpdCommandExecutor, JscpdDispatchResult } from "./types.js";
72
73
  import { createJscpdVerificationService, type JscpdVerificationService } from "./verification.js";
73
74
 
@@ -585,6 +586,12 @@ export function createJscpdToolDefinition(
585
586
  details: withoutOverlayCache(result),
586
587
  };
587
588
  },
589
+ renderCall(args, theme, context) {
590
+ return renderJscpdToolCall(args, theme, context.cwd);
591
+ },
592
+ renderResult(result, options, theme) {
593
+ return renderJscpdToolResult(result, options, theme);
594
+ },
588
595
  };
589
596
  }
590
597
 
@@ -0,0 +1,385 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import type { JscpdDispatchResult } from "./types.js";
4
+
5
+ const MAX_CALL_TARGETS = 3;
6
+ const MAX_TARGET_CHARACTERS = 120;
7
+ const MAX_CWD_CHARACTERS = 180;
8
+ const MAX_EXPANDED_CHARACTERS = 100_000;
9
+ const MAX_EXPANDED_LINES = 512;
10
+ const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
11
+ missing: "analyzer missing",
12
+ incompatible: "analyzer incompatible",
13
+ cancelled: "probe cancelled",
14
+ "timed-out": "probe timed out",
15
+ failed: "probe failed",
16
+ };
17
+ const PUBLIC_RESULT_STATUSES = new Set([
18
+ "completed",
19
+ "unavailable",
20
+ "failed",
21
+ "status",
22
+ "control",
23
+ "help",
24
+ "changed",
25
+ "changed-unavailable",
26
+ "invalid",
27
+ "error",
28
+ ]);
29
+
30
+ type ToolCallArgs = {
31
+ readonly command?: unknown;
32
+ readonly args?: unknown;
33
+ };
34
+
35
+ type ToolRenderResult = {
36
+ readonly details?: unknown;
37
+ };
38
+
39
+ type Tone = "success" | "warning" | "error" | "muted";
40
+
41
+ interface CompactResult {
42
+ readonly tone: Tone;
43
+ readonly headline: string;
44
+ readonly metadata?: string;
45
+ }
46
+
47
+ type CompactRenderer = (details: JscpdDispatchResult) => CompactResult;
48
+
49
+ const COMPACT_RENDERERS: Readonly<Record<JscpdDispatchResult["status"], CompactRenderer>> = {
50
+ completed: (details) =>
51
+ completedCompact(details as Extract<JscpdDispatchResult, { status: "completed" }>),
52
+ changed: (details) =>
53
+ changedCompact(details as Extract<JscpdDispatchResult, { status: "changed" }>),
54
+ unavailable: (details) =>
55
+ unavailableCompact((details as Extract<JscpdDispatchResult, { status: "unavailable" }>).reason),
56
+ "changed-unavailable": (details) =>
57
+ changedUnavailableCompact(
58
+ (details as Extract<JscpdDispatchResult, { status: "changed-unavailable" }>).reason,
59
+ ),
60
+ failed: (details) =>
61
+ failedCompact((details as Extract<JscpdDispatchResult, { status: "failed" }>).reason),
62
+ status: (details) => statusCompact(details as Extract<JscpdDispatchResult, { status: "status" }>),
63
+ control: (details) =>
64
+ controlCompact(details as Extract<JscpdDispatchResult, { status: "control" }>),
65
+ help: () => ({ tone: "muted", headline: "jscpd help" }),
66
+ invalid: (details) => ({
67
+ tone: "error",
68
+ headline: "Invalid jscpd request",
69
+ metadata: reasonLabel((details as Extract<JscpdDispatchResult, { status: "invalid" }>).reason),
70
+ }),
71
+ error: (details) => ({
72
+ tone: "error",
73
+ headline: "jscpd could not run",
74
+ metadata: reasonLabel((details as Extract<JscpdDispatchResult, { status: "error" }>).reason),
75
+ }),
76
+ };
77
+
78
+ /** Render the bounded jscpd operation, scan targets, and Pi-provided working directory. */
79
+ export function renderJscpdToolCall(args: ToolCallArgs, theme: Theme, cwd: string): Text {
80
+ const command = jscpdCommand(args.command);
81
+ const targets = command === "scan" ? scanTargetSummary(args.args) : undefined;
82
+ const location = boundedInline(displayToken(cwd || "."), MAX_CWD_CHARACTERS);
83
+ const content = [
84
+ theme.fg("toolTitle", theme.bold("jscpd ")),
85
+ theme.fg("accent", command),
86
+ targets ? theme.fg("muted", ` ${targets}`) : "",
87
+ theme.fg("dim", ` in ${location}`),
88
+ ].join("");
89
+ return new Text(content, 0, 0);
90
+ }
91
+
92
+ /** Render only normalized public details; model content and overlay-only caches are never read. */
93
+ export function renderJscpdToolResult(
94
+ result: ToolRenderResult,
95
+ options: { readonly expanded?: boolean; readonly isPartial?: boolean },
96
+ theme: Theme,
97
+ ): Text {
98
+ if (options.isPartial) {
99
+ return new Text(theme.fg("warning", "Running jscpd…"), 0, 0);
100
+ }
101
+
102
+ const details = publicDetails(result.details);
103
+ if (!details) {
104
+ return new Text(theme.fg("dim", "No jscpd result details"), 0, 0);
105
+ }
106
+
107
+ const compact = compactResult(details);
108
+ let content = theme.fg(compact.tone, compact.headline);
109
+ if (compact.metadata) content += theme.fg("dim", ` · ${compact.metadata}`);
110
+
111
+ if (options.expanded) {
112
+ const expanded = expandedPublicMessage(details);
113
+ if (expanded) content += `\n${styleExpandedMessage(expanded, theme)}`;
114
+ }
115
+
116
+ return new Text(content, 0, 0);
117
+ }
118
+
119
+ function publicDetails(value: unknown): JscpdDispatchResult | undefined {
120
+ if (!isRecord(value) || typeof value.status !== "string") return undefined;
121
+ return PUBLIC_RESULT_STATUSES.has(value.status)
122
+ ? (value as unknown as JscpdDispatchResult)
123
+ : undefined;
124
+ }
125
+
126
+ function compactResult(details: JscpdDispatchResult): CompactResult {
127
+ return COMPACT_RENDERERS[details.status](details);
128
+ }
129
+
130
+ function statusCompact(details: Extract<JscpdDispatchResult, { status: "status" }>): CompactResult {
131
+ const mode = details.mode === "enabled" || details.mode === "disabled" ? details.mode : "status";
132
+ return {
133
+ tone: mode === "enabled" ? "success" : "warning",
134
+ headline: mode === "status" ? "jscpd status unavailable" : `jscpd ${mode}`,
135
+ metadata: [capabilityLabel(details.capability), lastCheckLabel(details.lastCheck)].join(" · "),
136
+ };
137
+ }
138
+
139
+ function controlCompact(
140
+ details: Extract<JscpdDispatchResult, { status: "control" }>,
141
+ ): CompactResult {
142
+ const action =
143
+ details.action === "enabled" || details.action === "disabled" ? details.action : undefined;
144
+ return {
145
+ tone: action === "enabled" ? "success" : "warning",
146
+ headline: action ? `jscpd ${action} for this session` : "jscpd session state updated",
147
+ };
148
+ }
149
+
150
+ function completedCompact(
151
+ details: Extract<JscpdDispatchResult, { status: "completed" }>,
152
+ ): CompactResult {
153
+ if (details.outcome === "clean") {
154
+ const sources = safeCount(details.summary?.sources);
155
+ return {
156
+ tone: "success",
157
+ headline: "No duplicate blocks found",
158
+ metadata: sources === undefined ? undefined : plural(sources, "source"),
159
+ };
160
+ }
161
+
162
+ const clones = safeCount(details.summary?.clones) ?? findingTotal(details);
163
+ const duplicatedLines = safeCount(details.summary?.duplicatedLines);
164
+ return {
165
+ tone: "warning",
166
+ headline: `${plural(clones, "duplicate block")} found`,
167
+ metadata:
168
+ duplicatedLines === undefined ? undefined : `${plural(duplicatedLines, "duplicated line")}`,
169
+ };
170
+ }
171
+
172
+ function changedCompact(
173
+ details: Extract<JscpdDispatchResult, { status: "changed" }>,
174
+ ): CompactResult {
175
+ if (details.outcome === "clean") {
176
+ return {
177
+ tone: "success",
178
+ headline: details.scanPerformed
179
+ ? "No new duplicate blocks found"
180
+ : "No session changes to scan",
181
+ metadata: ambiguityLabel(details.ambiguousFindings),
182
+ };
183
+ }
184
+
185
+ return {
186
+ tone: "warning",
187
+ headline: `${plural(findingTotal(details), "new duplicate block")} found`,
188
+ metadata: ambiguityLabel(details.ambiguousFindings),
189
+ };
190
+ }
191
+
192
+ function unavailableCompact(reason: string): CompactResult {
193
+ switch (reason) {
194
+ case "probe-cancelled":
195
+ return { tone: "muted", headline: "jscpd check cancelled" };
196
+ case "probe-timed-out":
197
+ return { tone: "warning", headline: "jscpd check timed out" };
198
+ case "disabled":
199
+ return { tone: "warning", headline: "jscpd is disabled for this session" };
200
+ case "missing-binary":
201
+ return { tone: "warning", headline: "jscpd unavailable", metadata: "analyzer missing" };
202
+ case "incompatible-version":
203
+ return { tone: "warning", headline: "jscpd unavailable", metadata: "incompatible analyzer" };
204
+ default:
205
+ return { tone: "warning", headline: "jscpd unavailable", metadata: reasonLabel(reason) };
206
+ }
207
+ }
208
+
209
+ function changedUnavailableCompact(reason: string): CompactResult {
210
+ if (reason === "baseline-cancelled") {
211
+ return { tone: "muted", headline: "Changed check cancelled" };
212
+ }
213
+ if (reason === "baseline-timed-out") {
214
+ return { tone: "warning", headline: "Changed check timed out" };
215
+ }
216
+ return { tone: "warning", headline: "Changed check unavailable", metadata: reasonLabel(reason) };
217
+ }
218
+
219
+ function failedCompact(reason: string): CompactResult {
220
+ if (reason === "scan-cancelled") return { tone: "muted", headline: "jscpd scan cancelled" };
221
+ if (reason === "scan-timed-out") return { tone: "warning", headline: "jscpd scan timed out" };
222
+ return { tone: "warning", headline: "jscpd scan failed open", metadata: reasonLabel(reason) };
223
+ }
224
+
225
+ function capabilityLabel(value: unknown): string {
226
+ if (!isRecord(value) || typeof value.status !== "string") return "analyzer state unavailable";
227
+ if (value.status !== "available") {
228
+ return CAPABILITY_LABELS[value.status] ?? "analyzer state unavailable";
229
+ }
230
+
231
+ const executable = safeInline(value.executable) ?? "jscpd";
232
+ const version = safeInline(value.version);
233
+ const source =
234
+ value.source === "bundled"
235
+ ? "bundled"
236
+ : value.source === "project-or-path"
237
+ ? "project/PATH"
238
+ : undefined;
239
+ return [executable, version, source].filter(Boolean).join(" ");
240
+ }
241
+
242
+ function lastCheckLabel(value: unknown): string {
243
+ if (!isRecord(value) || typeof value.state !== "string") return "last check unavailable";
244
+ switch (value.state) {
245
+ case "never":
246
+ return "not checked yet";
247
+ case "clean":
248
+ return "last check clean";
249
+ case "findings": {
250
+ const clones = safeCount(value.clones);
251
+ return clones === undefined
252
+ ? "last check found duplication"
253
+ : `last check ${plural(clones, "block")}`;
254
+ }
255
+ case "cancelled":
256
+ return "last check cancelled";
257
+ case "failed":
258
+ return "last check failed";
259
+ default:
260
+ return "last check unavailable";
261
+ }
262
+ }
263
+
264
+ function expandedPublicMessage(details: JscpdDispatchResult): string | undefined {
265
+ const candidate =
266
+ "terminalMessage" in details && typeof details.terminalMessage === "string"
267
+ ? details.terminalMessage
268
+ : typeof details.message === "string"
269
+ ? details.message
270
+ : undefined;
271
+ if (!candidate) return undefined;
272
+ return boundedMultiline(candidate);
273
+ }
274
+
275
+ function styleExpandedMessage(message: string, theme: Theme): string {
276
+ return message
277
+ .split("\n")
278
+ .map((line) => theme.fg("muted", line))
279
+ .join("\n");
280
+ }
281
+
282
+ function scanTargetSummary(value: unknown): string | undefined {
283
+ if (!Array.isArray(value) || value.length === 0) return undefined;
284
+ const targets = value.filter((item): item is string => typeof item === "string");
285
+ if (targets.length === 0) return undefined;
286
+ const visible = targets
287
+ .slice(0, MAX_CALL_TARGETS)
288
+ .map((target) => boundedInline(displayToken(target), MAX_TARGET_CHARACTERS));
289
+ if (targets.length > visible.length) visible.push(`… +${targets.length - visible.length}`);
290
+ return visible.join(" ");
291
+ }
292
+
293
+ function jscpdCommand(value: unknown): string {
294
+ switch (value) {
295
+ case "scan":
296
+ case "changed":
297
+ case "status":
298
+ case "off":
299
+ case "on":
300
+ case "help":
301
+ return value;
302
+ default:
303
+ return "run";
304
+ }
305
+ }
306
+
307
+ function findingTotal(details: {
308
+ readonly findings?: unknown;
309
+ readonly omittedFindings?: unknown;
310
+ }): number {
311
+ const shown = Array.isArray(details.findings) ? details.findings.length : 0;
312
+ return shown + (safeCount(details.omittedFindings) ?? 0);
313
+ }
314
+
315
+ function ambiguityLabel(value: unknown): string | undefined {
316
+ const count = safeCount(value);
317
+ return count && count > 0 ? plural(count, "unclassified block") : undefined;
318
+ }
319
+
320
+ function safeCount(value: unknown): number | undefined {
321
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
322
+ }
323
+
324
+ function safeInline(value: unknown): string | undefined {
325
+ return typeof value === "string" && value.length > 0
326
+ ? boundedInline(displayToken(value), MAX_TARGET_CHARACTERS)
327
+ : undefined;
328
+ }
329
+
330
+ function displayToken(value: string): string {
331
+ const safeValue = escapeControlCharacters(value, false);
332
+ return /^[\p{L}\p{N}_./@:+~-]+$/u.test(safeValue) ? safeValue : JSON.stringify(safeValue);
333
+ }
334
+
335
+ function boundedInline(value: string, maximum: number): string {
336
+ const characters = Array.from(value);
337
+ if (characters.length <= maximum) return value;
338
+ const retained = maximum - 1;
339
+ const beginning = Math.ceil(retained / 2);
340
+ const ending = Math.floor(retained / 2);
341
+ return `${characters.slice(0, beginning).join("")}…${characters.slice(-ending).join("")}`;
342
+ }
343
+
344
+ function boundedMultiline(value: string): string {
345
+ const sanitized = escapeControlCharacters(
346
+ value.replaceAll("\r\n", "\n").replaceAll("\r", "\n"),
347
+ true,
348
+ );
349
+ const sourceLines = sanitized.split("\n");
350
+ const lines = sourceLines.slice(0, MAX_EXPANDED_LINES);
351
+ let result = Array.from(lines.join("\n")).slice(0, MAX_EXPANDED_CHARACTERS).join("");
352
+ if (
353
+ sourceLines.length > lines.length ||
354
+ Array.from(lines.join("\n")).length > MAX_EXPANDED_CHARACTERS
355
+ ) {
356
+ result += "\n… transcript detail truncated";
357
+ }
358
+ return result;
359
+ }
360
+
361
+ function escapeControlCharacters(value: string, preserveNewlines: boolean): string {
362
+ return Array.from(value)
363
+ .map((character) => {
364
+ if (preserveNewlines && character === "\n") return character;
365
+ const codePoint = character.codePointAt(0) ?? 0;
366
+ return codePoint < 32 || (codePoint >= 127 && codePoint <= 159)
367
+ ? `\\u${codePoint.toString(16).padStart(4, "0")}`
368
+ : character;
369
+ })
370
+ .join("");
371
+ }
372
+
373
+ function reasonLabel(value: unknown): string {
374
+ return typeof value === "string" && value.length > 0
375
+ ? boundedInline(escapeControlCharacters(value.replaceAll("-", " "), false), 80)
376
+ : "details unavailable";
377
+ }
378
+
379
+ function plural(count: number, singular: string): string {
380
+ return `${count} ${singular}${count === 1 ? "" : "s"}`;
381
+ }
382
+
383
+ function isRecord(value: unknown): value is Record<string, unknown> {
384
+ return typeof value === "object" && value !== null;
385
+ }