dsh-codex-approval 0.2.2 → 0.4.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/LICENSE +21 -21
- package/README.md +193 -150
- package/cordis.patch.yml +4 -4
- package/enrich.js +68 -68
- package/i18n.js +140 -57
- package/index.js +541 -389
- package/judge.js +176 -164
- package/modes.js +62 -62
- package/package.json +43 -42
- package/rules.js +90 -90
- package/transcript.js +225 -0
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
|
+
}
|
package/transcript.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
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 - session.events
|
|
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
|
+
if (!Array.isArray(events)) return [];
|
|
70
|
+
const items = [];
|
|
71
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
72
|
+
const event = events[i];
|
|
73
|
+
if (event === null || typeof event !== "object") continue;
|
|
74
|
+
const type = event.type;
|
|
75
|
+
if (type === "user/message") {
|
|
76
|
+
const source = event.data?.source;
|
|
77
|
+
if (source?.kind === "plugin") continue; // never feed injections back
|
|
78
|
+
const text = userText(event.data);
|
|
79
|
+
if (text === "") continue;
|
|
80
|
+
items.push({ seq: items.length, kind: "user", text, time: event.time });
|
|
81
|
+
} else if (type === "assistant/message") {
|
|
82
|
+
const content = event.data?.message?.content;
|
|
83
|
+
if (!Array.isArray(content)) continue;
|
|
84
|
+
for (const part of content) {
|
|
85
|
+
if (part?.type !== "tool-call") continue;
|
|
86
|
+
const args = typeof part.arguments === "string" ? part.arguments : "";
|
|
87
|
+
items.push({ seq: items.length, kind: "tool", name: part.name, args, time: event.time });
|
|
88
|
+
}
|
|
89
|
+
} else if (type === "tool/result") {
|
|
90
|
+
const msg = event.data?.message;
|
|
91
|
+
const error = event.data?.error;
|
|
92
|
+
const text = typeof msg?.text === "string" ? msg.text
|
|
93
|
+
: Array.isArray(msg?.content)
|
|
94
|
+
? msg.content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join(" ")
|
|
95
|
+
: "";
|
|
96
|
+
items.push({
|
|
97
|
+
seq: items.length,
|
|
98
|
+
kind: "result",
|
|
99
|
+
ok: error === undefined,
|
|
100
|
+
errorCode: error?.code,
|
|
101
|
+
text: text === "" ? "" : truncateMiddle(text, 120, 60),
|
|
102
|
+
time: event.time
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return items;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render a semantic item to one skeleton line.
|
|
111
|
+
*/
|
|
112
|
+
export function renderItem(item) {
|
|
113
|
+
if (item.kind === "user") {
|
|
114
|
+
const elided = truncateMiddle(item.text, CAPS.shortUser.head, CAPS.shortUser.tail);
|
|
115
|
+
return `[U] 用户: ${elided}`;
|
|
116
|
+
}
|
|
117
|
+
if (item.kind === "tool") {
|
|
118
|
+
const args = truncateMiddle(item.args ?? "", CAPS.tool.head, CAPS.tool.tail);
|
|
119
|
+
return `[T] ${item.name}(${args})`;
|
|
120
|
+
}
|
|
121
|
+
if (item.kind === "result") {
|
|
122
|
+
const marker = item.ok ? "→ ok" : `→ error${item.errorCode ? ` (${item.errorCode})` : ""}`;
|
|
123
|
+
const extra = item.text === "" ? "" : ` ${truncateMiddle(item.text, 80, 40)}`;
|
|
124
|
+
return `[R] ${marker}${extra}`;
|
|
125
|
+
}
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the compact transcript for the judge.
|
|
131
|
+
*
|
|
132
|
+
* @param opts - {
|
|
133
|
+
* events, session.events
|
|
134
|
+
* cfg, plugin config (uses transcriptMaxChars)
|
|
135
|
+
* denialHistory, Map<sessionId, Array<...>> — recent denials (≤5 kept)
|
|
136
|
+
* sessionId, for denial history lookup
|
|
137
|
+
* mode, tolerance, effective mode / risk tolerance lines
|
|
138
|
+
* mode3OnAsk, cwd, optional context lines
|
|
139
|
+
* }
|
|
140
|
+
* @returns the bounded transcript text; "" when there is nothing to show.
|
|
141
|
+
*/
|
|
142
|
+
export function buildTranscript({ events, cfg, denialHistory, sessionId, mode, tolerance, mode3OnAsk, cwd } = {}) {
|
|
143
|
+
const maxChars = cfg?.transcriptMaxChars ?? 4000;
|
|
144
|
+
const items = collectSemanticItems(events);
|
|
145
|
+
if (items.length === 0) return "";
|
|
146
|
+
|
|
147
|
+
// Two-level window split (items are newest-first).
|
|
148
|
+
const firstUserIdx = items.findIndex((item) => item.kind === "user");
|
|
149
|
+
const shortItems = [];
|
|
150
|
+
const longUsers = [];
|
|
151
|
+
let userBudget = 0;
|
|
152
|
+
if (firstUserIdx !== -1) {
|
|
153
|
+
// Short window: the newest user message + up to 3 tool items around it.
|
|
154
|
+
shortItems.push(items[firstUserIdx]);
|
|
155
|
+
let tools = 0;
|
|
156
|
+
for (let i = firstUserIdx - 1; i >= 0 && tools < 3; i -= 1) {
|
|
157
|
+
if (items[i].kind === "tool") {
|
|
158
|
+
shortItems.push(items[i]);
|
|
159
|
+
tools += 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Long window: every older user message (intent line), capped per entry.
|
|
163
|
+
for (let i = items.length - 1; i > firstUserIdx; i -= 1) {
|
|
164
|
+
if (items[i].kind === "user") {
|
|
165
|
+
longUsers.push(truncateMiddle(items[i].text, CAPS.longUser.head, CAPS.longUser.tail));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Fallback: no user message at all (e.g. fresh session) — keep recent tools.
|
|
170
|
+
const fallbackTools = firstUserIdx === -1
|
|
171
|
+
? items.filter((item) => item.kind === "tool" || item.kind === "result").slice(0, 4)
|
|
172
|
+
: [];
|
|
173
|
+
|
|
174
|
+
// Build sections in stable-prefix order (oldest first) for prefix caching.
|
|
175
|
+
const sections = [];
|
|
176
|
+
if (mode !== undefined) {
|
|
177
|
+
const modeLine = `mode: ${mode}${tolerance !== undefined ? `, tolerance: ${tolerance}` : ""}${mode3OnAsk !== undefined ? `, mode3OnAsk: ${mode3OnAsk}` : ""}`;
|
|
178
|
+
sections.push(`[M] ${modeLine}`);
|
|
179
|
+
}
|
|
180
|
+
if (cwd !== undefined && cwd !== "") sections.push(`[W] ${cwd}`);
|
|
181
|
+
for (const line of longUsers) sections.push(`[U] 用户: ${line}`);
|
|
182
|
+
for (const item of [...fallbackTools].reverse()) sections.push(renderItem(item));
|
|
183
|
+
for (const item of [...shortItems].reverse()) sections.push(renderItem(item));
|
|
184
|
+
|
|
185
|
+
// Denial history (P2, dropped first on overflow).
|
|
186
|
+
const denials = denialHistory?.get(sessionId) ?? [];
|
|
187
|
+
const denialLines = denials.slice(-3).map((d) => {
|
|
188
|
+
const src = d.source ?? "?";
|
|
189
|
+
const risk = d.risk !== undefined ? `, risk: ${d.risk}` : "";
|
|
190
|
+
const cmd = truncateMiddle(d.command ?? "", 80, 0);
|
|
191
|
+
return `[D] deny ${cmd} (${src}${risk})`;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Budget: assemble; on overflow drop denial history, then oldest
|
|
195
|
+
// long-window entries, then oldest tool lines; final hard cut keeps the
|
|
196
|
+
// head and tail (mode line / newest user message are protected).
|
|
197
|
+
const joinLen = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0) - (lines.length > 0 ? 1 : 0);
|
|
198
|
+
let lines = [...sections, ...denialLines];
|
|
199
|
+
let text;
|
|
200
|
+
if (joinLen(lines) <= maxChars) {
|
|
201
|
+
text = lines.join("\n");
|
|
202
|
+
} else {
|
|
203
|
+
// 1) drop denial history entirely
|
|
204
|
+
lines = [...sections];
|
|
205
|
+
// 2) drop droppable lines from the oldest (top), protecting the first
|
|
206
|
+
// two lines ([M] mode / [W] cwd) and the last line (newest user).
|
|
207
|
+
while (joinLen(lines) > maxChars && lines.length > 3) {
|
|
208
|
+
lines.splice(2, 1);
|
|
209
|
+
}
|
|
210
|
+
text = lines.join("\n");
|
|
211
|
+
// 3) final hard cut — the elision marker costs ~12 chars itself, so
|
|
212
|
+
// shrink head/tail until the cut truly fits inside the cap.
|
|
213
|
+
let headChars = Math.floor(maxChars * 0.6);
|
|
214
|
+
let tailChars = Math.floor(maxChars * 0.3);
|
|
215
|
+
let cut = truncateMiddle(text, headChars, tailChars);
|
|
216
|
+
while (cut.length > maxChars && (headChars > 8 || tailChars > 4)) {
|
|
217
|
+
headChars = Math.floor(headChars * 0.8);
|
|
218
|
+
tailChars = Math.floor(tailChars * 0.8);
|
|
219
|
+
cut = truncateMiddle(text, headChars, tailChars);
|
|
220
|
+
}
|
|
221
|
+
if (cut.length > maxChars) cut = `${text.slice(0, Math.max(8, maxChars - 4))}…`;
|
|
222
|
+
text = cut;
|
|
223
|
+
}
|
|
224
|
+
return text.trim();
|
|
225
|
+
}
|