dsh-codex-approval 0.3.0 → 0.4.2

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/package.json CHANGED
@@ -1,42 +1,76 @@
1
- {
2
- "name": "dsh-codex-approval",
3
- "version": "0.3.0",
4
- "description": "Codex-style approval autopilot for DeepSeek Harness: ordered glob rules (allow/ask/deny) plus an AI risk judge (low/medium/high) mapped through a risk tolerance, as an approval answerer.",
5
- "type": "module",
6
- "main": "index.js",
7
- "files": [
8
- "index.js",
9
- "rules.js",
10
- "enrich.js",
11
- "i18n.js",
12
- "judge.js",
13
- "modes.js",
14
- "cordis.patch.yml",
15
- "README.md",
16
- "LICENSE"
17
- ],
18
- "dsh": {
19
- "bundle": {
20
- "patch": "./cordis.patch.yml"
21
- }
22
- },
23
- "keywords": [
24
- "dsh",
25
- "dsh-plugin",
26
- "deepseek-harness",
27
- "approval",
28
- "codex",
29
- "risk"
30
- ],
31
- "license": "MIT",
32
- "repository": {
33
- "type": "git",
34
- "url": "git+https://github.com/040822/dsh-codex-approval.git"
35
- },
36
- "engines": {
37
- "node": ">=22.19"
38
- },
39
- "dependencies": {
40
- "@deepseek-ai/schemastery": "^3.18.1"
41
- }
42
- }
1
+ {
2
+ "name": "dsh-codex-approval",
3
+ "version": "0.4.2",
4
+ "description": "Codex-style approval autopilot for DeepSeek Harness: ordered glob rules (allow/ask/deny) plus an AI risk judge (low/medium/high) mapped through a risk tolerance, as an approval answerer.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "build:client": "node scripts/build-client.mjs",
9
+ "test": "node --test"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "default": "./index.js"
14
+ },
15
+ "./client": "./lib/client.js"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "lib/client.js",
20
+ "client-model-picker.js",
21
+ "client-remote.js",
22
+ "client-card-style.js",
23
+ "rules.js",
24
+ "enrich.js",
25
+ "i18n.js",
26
+ "judge.js",
27
+ "modes.js",
28
+ "transcript.js",
29
+ "cordis.patch.yml",
30
+ "scripts/build-client.mjs",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "platform": "web",
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-ui-renderer",
42
+ "@deepseek-ai/dsh-client-ui-settings",
43
+ "@deepseek-ai/dsh-client-ui-settings-plugins",
44
+ "@deepseek-ai/dsh-api-remotes",
45
+ "@deepseek-ai/dsh-client-locale"
46
+ ]
47
+ },
48
+ "compatibility": {
49
+ "dsh": ">=0.1.2-rc.1",
50
+ "dshReleases": {
51
+ "0.1.0-rc.6": "compatible",
52
+ "0.1.2-rc.1": "compatible",
53
+ "0.1.5-rc.1": "compatible"
54
+ }
55
+ }
56
+ },
57
+ "keywords": [
58
+ "dsh",
59
+ "dsh-plugin",
60
+ "deepseek-harness",
61
+ "approval",
62
+ "codex",
63
+ "risk"
64
+ ],
65
+ "license": "MIT",
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/040822/dsh-codex-approval.git"
69
+ },
70
+ "engines": {
71
+ "node": ">=22.19"
72
+ },
73
+ "dependencies": {
74
+ "@deepseek-ai/schemastery": "^3.18.1"
75
+ }
76
+ }
package/rules.js CHANGED
@@ -1,90 +1,90 @@
1
- /**
2
- * dsh-codex-approval — rules.js
3
- *
4
- * Codex-style rule matching. A rule matches a request via a single glob
5
- * pattern over the "matchable text": `ToolName(args preview) reason:<reason>`.
6
- * Examples:
7
- * - `Bash(git *)` — the recovered bash command starts with "git "
8
- * - `Bash(rm -rf /*)` — destructive command
9
- * - `reason:*curl*` — the approval reason mentions curl
10
- *
11
- * Evaluation priority is safety-first regardless of list order:
12
- * deny > ask > allow
13
- * (an explicit ask or deny can never be overridden by a blanket allow,
14
- * mirroring Codex where ask/reject rules take precedence over auto-approve).
15
- */
16
-
17
- /** Classic glob match: `*` = any sequence (incl. empty), `?` = one char. Case-insensitive. */
18
- export function wildcardMatch(pattern, text) {
19
- if (typeof pattern !== "string" || typeof text !== "string") return false;
20
- pattern = pattern.toLowerCase();
21
- text = text.toLowerCase();
22
- let pi = 0;
23
- let ti = 0;
24
- let star = -1;
25
- let mark = 0;
26
- while (ti < text.length) {
27
- if (pi < pattern.length && (pattern[pi] === "?" || pattern[pi] === text[ti])) {
28
- pi += 1;
29
- ti += 1;
30
- } else if (pi < pattern.length && pattern[pi] === "*") {
31
- star = pi;
32
- pi += 1;
33
- mark = ti;
34
- } else if (star !== -1) {
35
- pi = star + 1;
36
- ti = mark + 1;
37
- mark += 1;
38
- } else {
39
- return false;
40
- }
41
- }
42
- while (pi < pattern.length && pattern[pi] === "*") pi += 1;
43
- return pi === pattern.length;
44
- }
45
-
46
- /**
47
- * Build the single string rules match against.
48
- * @param req - { toolName, argsText, reason }
49
- */
50
- export function matchableText(req) {
51
- const bits = [];
52
- if (req.toolName) bits.push(`${req.toolName}(${req.argsText ?? ""})`);
53
- if (req.reason) bits.push(`reason:${req.reason}`);
54
- return bits.join(" ");
55
- }
56
-
57
- /**
58
- * The surfaces a rule pattern is tested against, in order: the tool call
59
- * alone (`ToolName(args)`), the reason alone (`reason:...`), then the
60
- * combined string. This lets `Bash(git *)` match regardless of an appended
61
- * reason, and `reason:*curl*` match the reason alone.
62
- */
63
- export function matchSurfaces(req) {
64
- const surfaces = [];
65
- if (req.toolName) surfaces.push(`${req.toolName}(${req.argsText ?? ""})`);
66
- if (req.reason) surfaces.push(`reason:${req.reason}`);
67
- const combined = surfaces.join(" ");
68
- if (!surfaces.includes(combined)) surfaces.push(combined);
69
- return surfaces.filter((surface) => surface !== "");
70
- }
71
-
72
- /**
73
- * Evaluate an ordered rule list against one request.
74
- * @param rules - [{ match: string, action: "allow"|"ask"|"deny" }]
75
- * @param req - { toolName, argsText, reason }
76
- * @returns the first matching rule under deny > ask > allow priority, or null.
77
- */
78
- export function evaluateRules(rules, req) {
79
- const surfaces = matchSurfaces(req);
80
- if (surfaces.length === 0) return null;
81
- for (const action of ["deny", "ask", "allow"]) {
82
- for (const rule of rules) {
83
- if (rule.action !== action) continue;
84
- for (const surface of surfaces) {
85
- if (wildcardMatch(rule.match, surface)) return rule;
86
- }
87
- }
88
- }
89
- return null;
90
- }
1
+ /**
2
+ * dsh-codex-approval — rules.js
3
+ *
4
+ * Codex-style rule matching. A rule matches a request via a single glob
5
+ * pattern over the "matchable text": `ToolName(args preview) reason:<reason>`.
6
+ * Examples:
7
+ * - `Bash(git *)` — the recovered bash command starts with "git "
8
+ * - `Bash(rm -rf /*)` — destructive command
9
+ * - `reason:*curl*` — the approval reason mentions curl
10
+ *
11
+ * Evaluation priority is safety-first regardless of list order:
12
+ * deny > ask > allow
13
+ * (an explicit ask or deny can never be overridden by a blanket allow,
14
+ * mirroring Codex where ask/reject rules take precedence over auto-approve).
15
+ */
16
+
17
+ /** Classic glob match: `*` = any sequence (incl. empty), `?` = one char. Case-insensitive. */
18
+ export function wildcardMatch(pattern, text) {
19
+ if (typeof pattern !== "string" || typeof text !== "string") return false;
20
+ pattern = pattern.toLowerCase();
21
+ text = text.toLowerCase();
22
+ let pi = 0;
23
+ let ti = 0;
24
+ let star = -1;
25
+ let mark = 0;
26
+ while (ti < text.length) {
27
+ if (pi < pattern.length && (pattern[pi] === "?" || pattern[pi] === text[ti])) {
28
+ pi += 1;
29
+ ti += 1;
30
+ } else if (pi < pattern.length && pattern[pi] === "*") {
31
+ star = pi;
32
+ pi += 1;
33
+ mark = ti;
34
+ } else if (star !== -1) {
35
+ pi = star + 1;
36
+ ti = mark + 1;
37
+ mark += 1;
38
+ } else {
39
+ return false;
40
+ }
41
+ }
42
+ while (pi < pattern.length && pattern[pi] === "*") pi += 1;
43
+ return pi === pattern.length;
44
+ }
45
+
46
+ /**
47
+ * Build the single string rules match against.
48
+ * @param req - { toolName, argsText, reason }
49
+ */
50
+ export function matchableText(req) {
51
+ const bits = [];
52
+ if (req.toolName) bits.push(`${req.toolName}(${req.argsText ?? ""})`);
53
+ if (req.reason) bits.push(`reason:${req.reason}`);
54
+ return bits.join(" ");
55
+ }
56
+
57
+ /**
58
+ * The surfaces a rule pattern is tested against, in order: the tool call
59
+ * alone (`ToolName(args)`), the reason alone (`reason:...`), then the
60
+ * combined string. This lets `Bash(git *)` match regardless of an appended
61
+ * reason, and `reason:*curl*` match the reason alone.
62
+ */
63
+ export function matchSurfaces(req) {
64
+ const surfaces = [];
65
+ if (req.toolName) surfaces.push(`${req.toolName}(${req.argsText ?? ""})`);
66
+ if (req.reason) surfaces.push(`reason:${req.reason}`);
67
+ const combined = surfaces.join(" ");
68
+ if (!surfaces.includes(combined)) surfaces.push(combined);
69
+ return surfaces.filter((surface) => surface !== "");
70
+ }
71
+
72
+ /**
73
+ * Evaluate an ordered rule list against one request.
74
+ * @param rules - [{ match: string, action: "allow"|"ask"|"deny" }]
75
+ * @param req - { toolName, argsText, reason }
76
+ * @returns the first matching rule under deny > ask > allow priority, or null.
77
+ */
78
+ export function evaluateRules(rules, req) {
79
+ const surfaces = matchSurfaces(req);
80
+ if (surfaces.length === 0) return null;
81
+ for (const action of ["deny", "ask", "allow"]) {
82
+ for (const rule of rules) {
83
+ if (rule.action !== action) continue;
84
+ for (const surface of surfaces) {
85
+ if (wildcardMatch(rule.match, surface)) return rule;
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-codex-approval — build the browser half (lib/client.js).
4
+ *
5
+ * DSH loads a client plugin as `window.__ModuleLoader__.load({ id, factory })`,
6
+ * so the shipped artifact is an esbuild CJS bundle wrapped in that loader call.
7
+ * React and every `@deepseek-ai/*` package stay external: the host's module
8
+ * loader supplies them, and bundling them would duplicate the client runtime.
9
+ *
10
+ * Usage (from the package root):
11
+ * node scripts/build-client.mjs
12
+ *
13
+ * esbuild is not a dependency of this package (the published tarball has none),
14
+ * so the script resolves it from npx unless ESBUILD_BIN is set.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ const PACKAGE_ID = "dsh-codex-approval";
22
+ const ENTRY = "src/client/index.ts";
23
+ const OUT = "lib/client.js";
24
+
25
+ const workdir = mkdtempSync(join(tmpdir(), "dsh-codex-approval-build-"));
26
+ const bundlePath = join(workdir, "client.bundle.cjs");
27
+ const esbuild = process.env.ESBUILD_BIN ?? "npx";
28
+ const esbuildArgs = process.env.ESBUILD_BIN === undefined ? ["--yes", "esbuild@0.25.0"] : [];
29
+
30
+ execFileSync(esbuild, [
31
+ ...esbuildArgs,
32
+ ENTRY,
33
+ "--bundle",
34
+ "--format=cjs",
35
+ "--platform=browser",
36
+ "--target=es2022",
37
+ "--jsx=transform",
38
+ `--outfile=${bundlePath}`,
39
+ "--external:react",
40
+ "--external:react/*",
41
+ "--external:@deepseek-ai/*",
42
+ "--log-level=warning"
43
+ ], { stdio: "inherit" });
44
+
45
+ const bundle = readFileSync(bundlePath, "utf8");
46
+ const wrapped = `window.__ModuleLoader__.load({ id: ${JSON.stringify(PACKAGE_ID)}, factory: (require) => { var module = { exports: {} }; var exports = module.exports;\n${bundle}\nreturn module.exports; } });\n`;
47
+ writeFileSync(OUT, wrapped);
48
+ console.log(`built ${OUT} (${wrapped.length} bytes) from ${ENTRY}`);
package/transcript.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * dsh-codex-approval — transcript.js
3
+ *
4
+ * Compact-session-transcript builder (`transcript: "short"`). Turns the live
5
+ * session event stream into a bounded, skeletonized context block for the AI
6
+ * approval judge, so the judge can see user intent and the surrounding tool
7
+ * chain — not just the bare command.
8
+ *
9
+ * Pipeline:
10
+ * 1. semantic filter — keep only user/message, tool-calls, tool/results,
11
+ * turn markers; drop streaming chunks and plugin-
12
+ * sourced user messages (denyFeedback / time-context
13
+ * injections must never be fed back to the judge).
14
+ * 2. two-level window — short window (recent user message + ≤3 tool
15
+ * calls) rendered in full skeleton; long window
16
+ * (older user messages only) as an intent line.
17
+ * 3. head/tail truncation — overlong messages keep head + tail with an
18
+ * elision counter (error-report pastes: head =
19
+ * action, tail = crux, middle = noise).
20
+ * 4. budget tiers — total bounded by transcriptMaxChars; overflow
21
+ * drops lowest-priority items first (denial history
22
+ * → cwd → oldest long-window entries).
23
+ *
24
+ * Pure functions only; everything defensive, never throws into the
25
+ * approval path.
26
+ */
27
+
28
+ /** Head/tail caps per item class, in chars. */
29
+ const CAPS = {
30
+ shortUser: { head: 600, tail: 600 }, // the most recent user message (P0)
31
+ longUser: { head: 120, tail: 80 }, // older intent-line entries (P2)
32
+ tool: { head: 200, tail: 0 } // tool-call arguments (P1)
33
+ };
34
+
35
+ /** Elide the middle of an over-long text: `head…〔省略 N 字符〕…tail`. */
36
+ export function truncateMiddle(text, headChars, tailChars) {
37
+ if (typeof text !== "string" || text.length <= headChars + tailChars) return text;
38
+ const head = text.slice(0, headChars);
39
+ const tail = tailChars > 0 ? text.slice(text.length - tailChars) : "";
40
+ const omitted = text.length - headChars - tailChars;
41
+ return omitted > 0
42
+ ? `${head}…〔省略 ${omitted} 字符〕…${tail}`
43
+ : text;
44
+ }
45
+
46
+ /**
47
+ * Extract the text of a user/message event. Returns "" for non-text shapes.
48
+ */
49
+ function userText(data) {
50
+ const content = data?.content;
51
+ if (Array.isArray(content)) {
52
+ return content
53
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
54
+ .map((part) => part.text)
55
+ .join(" ");
56
+ }
57
+ if (content && content.type === "text" && typeof content.text === "string") return content.text;
58
+ return "";
59
+ }
60
+
61
+ /**
62
+ * Collect semantic items from the raw event stream, newest first.
63
+ * Plugin-sourced user messages and streaming chunks are excluded here.
64
+ * @param events - a Session-like object or event array
65
+ * @returns array of { seq, kind, ... } with seq counting only semantic items
66
+ * (newest first, so index 0 is the most recent).
67
+ */
68
+ export function collectSemanticItems(events) {
69
+ const list = Array.isArray(events)
70
+ ? events
71
+ : typeof events?.snapshotEvents === "function"
72
+ ? (() => { try { return events.snapshotEvents(); } catch { return []; } })()
73
+ : typeof events?.ownEvents === "function"
74
+ ? (() => { try { return events.ownEvents(); } catch { return []; } })()
75
+ : Array.isArray(events?.events)
76
+ ? events.events
77
+ : [];
78
+ if (!Array.isArray(list)) return [];
79
+ const items = [];
80
+ for (let i = list.length - 1; i >= 0; i -= 1) {
81
+ const event = list[i];
82
+ if (event === null || typeof event !== "object") continue;
83
+ const type = event.type;
84
+ if (type === "user/message") {
85
+ const source = event.data?.source;
86
+ if (source?.kind === "plugin") continue; // never feed injections back
87
+ const text = userText(event.data);
88
+ if (text === "") continue;
89
+ items.push({ seq: items.length, kind: "user", text, time: event.time });
90
+ } else if (type === "assistant/message") {
91
+ const content = event.data?.message?.content;
92
+ if (!Array.isArray(content)) continue;
93
+ for (const part of content) {
94
+ if (part?.type !== "tool-call") continue;
95
+ const args = typeof part.arguments === "string" ? part.arguments : "";
96
+ items.push({ seq: items.length, kind: "tool", name: part.name, args, time: event.time });
97
+ }
98
+ } else if (type === "tool/result") {
99
+ const msg = event.data?.message;
100
+ const error = event.data?.error;
101
+ const text = typeof msg?.text === "string" ? msg.text
102
+ : Array.isArray(msg?.content)
103
+ ? msg.content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join(" ")
104
+ : "";
105
+ items.push({
106
+ seq: items.length,
107
+ kind: "result",
108
+ ok: error === undefined,
109
+ errorCode: error?.code,
110
+ text: text === "" ? "" : truncateMiddle(text, 120, 60),
111
+ time: event.time
112
+ });
113
+ }
114
+ }
115
+ return items;
116
+ }
117
+
118
+ /**
119
+ * Render a semantic item to one skeleton line.
120
+ */
121
+ export function renderItem(item) {
122
+ if (item.kind === "user") {
123
+ const elided = truncateMiddle(item.text, CAPS.shortUser.head, CAPS.shortUser.tail);
124
+ return `[U] 用户: ${elided}`;
125
+ }
126
+ if (item.kind === "tool") {
127
+ const args = truncateMiddle(item.args ?? "", CAPS.tool.head, CAPS.tool.tail);
128
+ return `[T] ${item.name}(${args})`;
129
+ }
130
+ if (item.kind === "result") {
131
+ const marker = item.ok ? "→ ok" : `→ error${item.errorCode ? ` (${item.errorCode})` : ""}`;
132
+ const extra = item.text === "" ? "" : ` ${truncateMiddle(item.text, 80, 40)}`;
133
+ return `[R] ${marker}${extra}`;
134
+ }
135
+ return "";
136
+ }
137
+
138
+ /**
139
+ * Build the compact transcript for the judge.
140
+ *
141
+ * @param opts - {
142
+ * events, session.events
143
+ * cfg, plugin config (uses transcriptMaxChars)
144
+ * denialHistory, Map<sessionId, Array<...>> — recent denials (≤5 kept)
145
+ * sessionId, for denial history lookup
146
+ * mode, tolerance, effective mode / risk tolerance lines
147
+ * mode3OnAsk, cwd, optional context lines
148
+ * }
149
+ * @returns the bounded transcript text; "" when there is nothing to show.
150
+ */
151
+ export function buildTranscript({ events, cfg, denialHistory, sessionId, mode, tolerance, mode3OnAsk, cwd } = {}) {
152
+ const maxChars = cfg?.transcriptMaxChars ?? 4000;
153
+ const items = collectSemanticItems(events);
154
+ if (items.length === 0) return "";
155
+
156
+ // Two-level window split (items are newest-first).
157
+ const firstUserIdx = items.findIndex((item) => item.kind === "user");
158
+ const shortItems = [];
159
+ const longUsers = [];
160
+ let userBudget = 0;
161
+ if (firstUserIdx !== -1) {
162
+ // Short window: the newest user message + up to 3 tool items around it.
163
+ shortItems.push(items[firstUserIdx]);
164
+ let tools = 0;
165
+ for (let i = firstUserIdx - 1; i >= 0 && tools < 3; i -= 1) {
166
+ if (items[i].kind === "tool") {
167
+ shortItems.push(items[i]);
168
+ tools += 1;
169
+ }
170
+ }
171
+ // Long window: every older user message (intent line), capped per entry.
172
+ for (let i = items.length - 1; i > firstUserIdx; i -= 1) {
173
+ if (items[i].kind === "user") {
174
+ longUsers.push(truncateMiddle(items[i].text, CAPS.longUser.head, CAPS.longUser.tail));
175
+ }
176
+ }
177
+ }
178
+ // Fallback: no user message at all (e.g. fresh session) — keep recent tools.
179
+ const fallbackTools = firstUserIdx === -1
180
+ ? items.filter((item) => item.kind === "tool" || item.kind === "result").slice(0, 4)
181
+ : [];
182
+
183
+ // Build sections in stable-prefix order (oldest first) for prefix caching.
184
+ const sections = [];
185
+ if (mode !== undefined) {
186
+ const modeLine = `mode: ${mode}${tolerance !== undefined ? `, tolerance: ${tolerance}` : ""}${mode3OnAsk !== undefined ? `, mode3OnAsk: ${mode3OnAsk}` : ""}`;
187
+ sections.push(`[M] ${modeLine}`);
188
+ }
189
+ if (cwd !== undefined && cwd !== "") sections.push(`[W] ${cwd}`);
190
+ for (const line of longUsers) sections.push(`[U] 用户: ${line}`);
191
+ for (const item of [...fallbackTools].reverse()) sections.push(renderItem(item));
192
+ for (const item of [...shortItems].reverse()) sections.push(renderItem(item));
193
+
194
+ // Denial history (P2, dropped first on overflow).
195
+ const denials = denialHistory?.get(sessionId) ?? [];
196
+ const denialLines = denials.slice(-3).map((d) => {
197
+ const src = d.source ?? "?";
198
+ const risk = d.risk !== undefined ? `, risk: ${d.risk}` : "";
199
+ const cmd = truncateMiddle(d.command ?? "", 80, 0);
200
+ return `[D] deny ${cmd} (${src}${risk})`;
201
+ });
202
+
203
+ // Budget: assemble; on overflow drop denial history, then oldest
204
+ // long-window entries, then oldest tool lines; final hard cut keeps the
205
+ // head and tail (mode line / newest user message are protected).
206
+ const joinLen = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0) - (lines.length > 0 ? 1 : 0);
207
+ let lines = [...sections, ...denialLines];
208
+ let text;
209
+ if (joinLen(lines) <= maxChars) {
210
+ text = lines.join("\n");
211
+ } else {
212
+ // 1) drop denial history entirely
213
+ lines = [...sections];
214
+ // 2) drop droppable lines from the oldest (top), protecting the first
215
+ // two lines ([M] mode / [W] cwd) and the last line (newest user).
216
+ while (joinLen(lines) > maxChars && lines.length > 3) {
217
+ lines.splice(2, 1);
218
+ }
219
+ text = lines.join("\n");
220
+ // 3) final hard cut — the elision marker costs ~12 chars itself, so
221
+ // shrink head/tail until the cut truly fits inside the cap.
222
+ let headChars = Math.floor(maxChars * 0.6);
223
+ let tailChars = Math.floor(maxChars * 0.3);
224
+ let cut = truncateMiddle(text, headChars, tailChars);
225
+ while (cut.length > maxChars && (headChars > 8 || tailChars > 4)) {
226
+ headChars = Math.floor(headChars * 0.8);
227
+ tailChars = Math.floor(tailChars * 0.8);
228
+ cut = truncateMiddle(text, headChars, tailChars);
229
+ }
230
+ if (cut.length > maxChars) cut = `${text.slice(0, Math.max(8, maxChars - 4))}…`;
231
+ text = cut;
232
+ }
233
+ return text.trim();
234
+ }