agentlas 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.
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * Global terminal reply style for Agentlas.
5
+ *
6
+ * This is intentionally kept outside individual agent prompts so imported,
7
+ * cloud-installed, company, native-CLI, and BYOK agents all share one contract.
8
+ */
9
+
10
+ function detectResponseLanguage(prompt, fallback) {
11
+ const text = String(prompt || "");
12
+ if (/\b(answer|reply|respond|write)\s+in\s+(english|en)\b/i.test(text)) return "en";
13
+ if (/(영어로|영문으로|english로)/i.test(text)) return "en";
14
+ if (/(한국어로|한글로|한글\s*답|korean으로)/i.test(text)) return "ko";
15
+ const hangul = (text.match(/[가-힣]/g) || []).length;
16
+ const latin = (text.match(/[A-Za-z]/g) || []).length;
17
+ if (hangul >= 2 && hangul >= latin * 0.15) return "ko";
18
+ if (latin >= 2 && hangul === 0) return "en";
19
+ return fallback === "ko" ? "ko" : "en";
20
+ }
21
+
22
+ function responseLanguageDirective(lang) {
23
+ return lang === "ko"
24
+ ? [
25
+ "응답 언어: 한국어.",
26
+ "이번 사용자 메시지가 다른 언어를 명시적으로 요구하지 않는 한 한국어만 사용하세요.",
27
+ "제품명, 명령어, 파일 경로, 코드 식별자처럼 번역하면 안 되는 고유명사만 원문을 유지하세요.",
28
+ "한 문단 안에서 한국어와 영어 설명을 섞지 마세요.",
29
+ ].join("\n")
30
+ : [
31
+ "Response language: English.",
32
+ "Use English only unless this user message explicitly asks for another language.",
33
+ "Keep product names, commands, file paths, and code identifiers unchanged.",
34
+ "Do not mix Korean and English explanatory prose in the same reply.",
35
+ ].join("\n");
36
+ }
37
+
38
+ function responseStyleDirective() {
39
+ return [
40
+ "Global Agentlas reply style:",
41
+ "Use normal Markdown when it aids clarity — **bold** for emphasis, `code`/code blocks for code,",
42
+ "paths, and commands, and # headings, - bullets, or 1. numbered lists for structure.",
43
+ "Keep replies concise; make the first sentence concrete and action-oriented.",
44
+ "Do not expose hidden chain-of-thought — give the result and a short rationale.",
45
+ ].join("\n");
46
+ }
47
+
48
+ function responseDirective(lang) {
49
+ return responseLanguageDirective(lang) + "\n\n" + responseStyleDirective();
50
+ }
51
+
52
+ let EMOJI_RE = null;
53
+ try {
54
+ EMOJI_RE = new RegExp("[\\p{Extended_Pictographic}\\uFE0F\\u200D]+", "gu");
55
+ } catch {
56
+ EMOJI_RE = /[\u2600-\u27BF\uD83C-\uDBFF\uDC00-\uDFFF]/g;
57
+ }
58
+
59
+ function sanitizeAssistantText(text) {
60
+ return String(text || "")
61
+ .replace(EMOJI_RE, "")
62
+ .replace(/\*\*/g, "")
63
+ .replace(/^\s{0,3}#{1,6}\s*/gm, "")
64
+ .replace(/^\s{0,3}>\s?/gm, "")
65
+ .replace(/^\s{0,3}[-*+]\s+/gm, "")
66
+ .replace(/^\s{0,3}-{3,}\s*$/gm, "")
67
+ .replace(/[ \t]+[-–—][ \t]+/g, ": ");
68
+ }
69
+
70
+ function createStreamingSanitizer() {
71
+ let pending = "";
72
+ return {
73
+ reset() {
74
+ pending = "";
75
+ },
76
+ push(chunk) {
77
+ let value = pending + String(chunk || "");
78
+ pending = "";
79
+ if (value.endsWith("*")) {
80
+ pending = "*";
81
+ value = value.slice(0, -1);
82
+ }
83
+ return sanitizeAssistantText(value);
84
+ },
85
+ flush() {
86
+ const value = sanitizeAssistantText(pending);
87
+ pending = "";
88
+ return value;
89
+ },
90
+ };
91
+ }
92
+
93
+ module.exports = {
94
+ createStreamingSanitizer,
95
+ detectResponseLanguage,
96
+ responseDirective,
97
+ responseLanguageDirective,
98
+ responseStyleDirective,
99
+ sanitizeAssistantText,
100
+ };
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ /*
3
+ * agentlas-tools: BYOK/Ollama 자체 에이전트 루프가 실행하는 로컬 툴.
4
+ * 권한 모델(read|write|full)을 코드 레벨에서 강제한다 — Claude/Codex의 permission-mode와 동일 의미.
5
+ * read : 읽기 전용 (list_dir, read_file)
6
+ * write : + 파일 생성/편집 (write_file, edit_file)
7
+ * full : + 셸 실행 (bash)
8
+ * 위험 동작이 현재 권한을 넘으면 던지지 않고 에러 문자열을 tool_result로 돌려준다(루프 안전).
9
+ */
10
+ const path = require("node:path");
11
+ const fs = require("node:fs");
12
+ const { spawnSync } = require("node:child_process");
13
+
14
+ const PERM_RANK = { read: 0, write: 1, full: 2 };
15
+
16
+ function resolveIn(cwd, p) {
17
+ if (!p) return cwd;
18
+ return path.isAbsolute(p) ? p : path.resolve(cwd, p);
19
+ }
20
+ function truncate(s, n) {
21
+ s = String(s);
22
+ return s.length <= n ? s : s.slice(0, n) + `\n…(${s.length - n} chars truncated)`;
23
+ }
24
+
25
+ const TOOLS = [
26
+ {
27
+ name: "list_dir",
28
+ minPerm: "read",
29
+ description: "List files and folders in a directory (relative to the working folder).",
30
+ parameters: {
31
+ type: "object",
32
+ properties: { path: { type: "string", description: "Directory path (default: working folder)" } },
33
+ },
34
+ run(args, ctx) {
35
+ const dir = resolveIn(ctx.cwd, args.path || ".");
36
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
37
+ const lines = entries
38
+ .slice(0, 400)
39
+ .map((e) => (e.isDirectory() ? e.name + "/" : e.name))
40
+ .sort();
41
+ return `${dir}\n` + lines.join("\n");
42
+ },
43
+ },
44
+ {
45
+ name: "read_file",
46
+ minPerm: "read",
47
+ description: "Read a UTF-8 text file. Optionally from a line offset.",
48
+ parameters: {
49
+ type: "object",
50
+ properties: {
51
+ path: { type: "string" },
52
+ offset: { type: "number", description: "1-based start line" },
53
+ limit: { type: "number", description: "max lines" },
54
+ },
55
+ required: ["path"],
56
+ },
57
+ run(args, ctx) {
58
+ const file = resolveIn(ctx.cwd, args.path);
59
+ let content = fs.readFileSync(file, "utf8");
60
+ if (args.offset || args.limit) {
61
+ const lines = content.split("\n");
62
+ const start = Math.max(0, (args.offset || 1) - 1);
63
+ const end = args.limit ? start + args.limit : lines.length;
64
+ content = lines.slice(start, end).join("\n");
65
+ }
66
+ return truncate(content, 20000);
67
+ },
68
+ },
69
+ {
70
+ name: "write_file",
71
+ minPerm: "write",
72
+ description: "Create or overwrite a file with the given content.",
73
+ parameters: {
74
+ type: "object",
75
+ properties: { path: { type: "string" }, content: { type: "string" } },
76
+ required: ["path", "content"],
77
+ },
78
+ run(args, ctx) {
79
+ const file = resolveIn(ctx.cwd, args.path);
80
+ fs.mkdirSync(path.dirname(file), { recursive: true });
81
+ const existed = fs.existsSync(file);
82
+ fs.writeFileSync(file, args.content, "utf8");
83
+ return `${existed ? "overwrote" : "created"} ${file} (${args.content.length} bytes)`;
84
+ },
85
+ },
86
+ {
87
+ name: "edit_file",
88
+ minPerm: "write",
89
+ description:
90
+ "Replace an exact substring in a file. old_string must occur exactly once unless replace_all is true.",
91
+ parameters: {
92
+ type: "object",
93
+ properties: {
94
+ path: { type: "string" },
95
+ old_string: { type: "string" },
96
+ new_string: { type: "string" },
97
+ replace_all: { type: "boolean" },
98
+ },
99
+ required: ["path", "old_string", "new_string"],
100
+ },
101
+ run(args, ctx) {
102
+ if (args.old_string === "") throw new Error("old_string must be non-empty");
103
+ const file = resolveIn(ctx.cwd, args.path);
104
+ const src = fs.readFileSync(file, "utf8");
105
+ if (!src.includes(args.old_string)) throw new Error("old_string not found");
106
+ const count = src.split(args.old_string).length - 1;
107
+ if (!args.replace_all && count > 1) throw new Error(`old_string occurs ${count}× (use replace_all or add context)`);
108
+ const out = args.replace_all
109
+ ? src.split(args.old_string).join(args.new_string)
110
+ : src.replace(args.old_string, args.new_string);
111
+ fs.writeFileSync(file, out, "utf8");
112
+ return `edited ${file} (${count} replacement${count > 1 ? "s" : ""})`;
113
+ },
114
+ },
115
+ {
116
+ name: "bash",
117
+ minPerm: "full",
118
+ description: "Run a shell command in the working folder. Requires 'full' permission.",
119
+ parameters: {
120
+ type: "object",
121
+ properties: { command: { type: "string" }, timeout_ms: { type: "number" } },
122
+ required: ["command"],
123
+ },
124
+ run(args, ctx) {
125
+ const t = Number(args.timeout_ms);
126
+ const timeout = Math.min(Math.max(Number.isFinite(t) && t > 0 ? t : 120000, 1000), 600000);
127
+ const res = spawnSync("bash", ["-lc", args.command], {
128
+ cwd: ctx.cwd,
129
+ encoding: "utf8",
130
+ timeout,
131
+ maxBuffer: 8 * 1024 * 1024,
132
+ env: process.env,
133
+ });
134
+ const parts = [];
135
+ if (res.stdout) parts.push(res.stdout);
136
+ if (res.stderr) parts.push(res.stderr);
137
+ // spawnSync는 timeout/maxBuffer/spawn 실패를 status=null + error/signal로 알린다 — 무음 실패 방지.
138
+ let head = `exit ${res.status == null ? "?" : res.status}`;
139
+ if (res.error) {
140
+ head +=
141
+ res.error.code === "ETIMEDOUT"
142
+ ? ` (timed out after ${timeout}ms)`
143
+ : res.error.code === "ENOBUFS"
144
+ ? " (output exceeded 8MB, truncated)"
145
+ : ` (spawn error: ${res.error.message})`;
146
+ } else if (res.signal) {
147
+ head += ` (killed by ${res.signal})`;
148
+ }
149
+ const body = truncate(parts.join("\n").trim() || "(no output)", 12000);
150
+ return `${head}\n${body}`;
151
+ },
152
+ },
153
+ ];
154
+
155
+ const BY_NAME = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
156
+
157
+ // 현재 권한에서 허용되는 툴만.
158
+ function allowedTools(permission) {
159
+ const rank = PERM_RANK[permission] ?? 0;
160
+ return TOOLS.filter((t) => (PERM_RANK[t.minPerm] ?? 0) <= rank);
161
+ }
162
+
163
+ // 툴 1개 실행 → { ok, content }. 권한 부족/에러는 ok:false 문자열로.
164
+ function runTool(name, args, ctx) {
165
+ const tool = BY_NAME[name];
166
+ if (!tool) return { ok: false, content: `unknown tool: ${name}` };
167
+ const rank = PERM_RANK[ctx.permission] ?? 0;
168
+ if ((PERM_RANK[tool.minPerm] ?? 0) > rank) {
169
+ return {
170
+ ok: false,
171
+ content: `permission denied: '${name}' requires '${tool.minPerm}' but current is '${ctx.permission}'. Ask the user to run /permission ${tool.minPerm}.`,
172
+ };
173
+ }
174
+ try {
175
+ return { ok: true, content: String(tool.run(args || {}, ctx)) };
176
+ } catch (e) {
177
+ return { ok: false, content: `${name} error: ${e && e.message ? e.message : String(e)}` };
178
+ }
179
+ }
180
+
181
+ // ── provider별 tool 선언 포맷 ─────────────────────────────
182
+ function anthropicTools(permission) {
183
+ return allowedTools(permission).map((t) => ({
184
+ name: t.name,
185
+ description: t.description,
186
+ input_schema: t.parameters,
187
+ }));
188
+ }
189
+ function openaiTools(permission) {
190
+ return allowedTools(permission).map((t) => ({
191
+ type: "function",
192
+ function: { name: t.name, description: t.description, parameters: t.parameters },
193
+ }));
194
+ }
195
+
196
+ module.exports = { TOOLS, BY_NAME, allowedTools, runTool, anthropicTools, openaiTools, PERM_RANK };
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ /*
3
+ * Agentlas terminal UI primitives — self-contained, zero-dependency (CJS).
4
+ *
5
+ * Electron-as-Node로 실행되므로 외부 컬러 라이브러리(chalk v5 ESM 등)에 의존하지 않는다.
6
+ * 24-bit truecolor ANSI를 직접 쓰고, NO_COLOR / 비-TTY 환경에서는 평문으로 폴백한다.
7
+ * 브랜드 팔레트는 보스턴테리어 paw 마크(크림슨) + agentlas-desktop-banner.svg(그린/틸 액센트)에서 가져왔다.
8
+ */
9
+
10
+ const i18n = require("./agentlas-i18n.cjs");
11
+
12
+ const RESET = "\x1b[0m";
13
+
14
+ function colorEnabled() {
15
+ if (process.env.NO_COLOR != null && process.env.NO_COLOR !== "") return false;
16
+ if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") return true;
17
+ if (process.env.AGENTLAS_NO_COLOR === "1") return false;
18
+ return !!process.stdout.isTTY;
19
+ }
20
+
21
+ // 브랜드 색 (R,G,B). banner.svg / paw mark 기준.
22
+ const BRAND = {
23
+ paw: [214, 69, 58], // 크림슨 (보스턴테리어 발바닥)
24
+ pawDim: [138, 45, 38],
25
+ emerald: [110, 231, 183], // #6EE7B7
26
+ green: [52, 211, 153], // #34D399
27
+ lime: [217, 249, 157], // #D9F99D
28
+ blue: [147, 197, 253], // #93C5FD
29
+ amber: [251, 191, 36], // #FBBF24
30
+ pink: [244, 114, 182], // #F472B6
31
+ text: [229, 231, 235], // #E5E7EB
32
+ dim: [107, 114, 128], // #6B7280
33
+ faint: [75, 85, 99],
34
+ };
35
+
36
+ function makePalette(enabled) {
37
+ const fg = (rgb) => (s) => (enabled ? `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}${RESET}` : String(s));
38
+ const sgr = (code) => (s) => (enabled ? `\x1b[${code}m${s}${RESET}` : String(s));
39
+ return {
40
+ paw: fg(BRAND.paw),
41
+ pawDim: fg(BRAND.pawDim),
42
+ emerald: fg(BRAND.emerald),
43
+ green: fg(BRAND.green),
44
+ lime: fg(BRAND.lime),
45
+ blue: fg(BRAND.blue),
46
+ amber: fg(BRAND.amber),
47
+ pink: fg(BRAND.pink),
48
+ text: fg(BRAND.text),
49
+ dim: fg(BRAND.dim),
50
+ faint: fg(BRAND.faint),
51
+ bold: sgr("1"),
52
+ italic: sgr("3"),
53
+ underline: sgr("4"),
54
+ inverse: sgr("7"),
55
+ };
56
+ }
57
+
58
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
59
+
60
+ // ANSI 시퀀스를 제거한 가시 폭 (대략) — wide char는 단순 1로 계산(충분).
61
+ function visibleWidth(s) {
62
+ return stripAnsi(s).length;
63
+ }
64
+ function stripAnsi(s) {
65
+ // eslint-disable-next-line no-control-regex
66
+ return String(s).replace(/\x1b\[[0-9;]*m/g, "");
67
+ }
68
+
69
+ class Ui {
70
+ constructor(opts = {}) {
71
+ this.enabled = opts.color != null ? opts.color : colorEnabled();
72
+ this.c = makePalette(this.enabled);
73
+ this.out = opts.stream || process.stdout;
74
+ this.lang = opts.lang || "en";
75
+ this.t = (key, ...args) => i18n.t(this.lang, key, ...args);
76
+ this._spinTimer = null;
77
+ this._spinText = "";
78
+ this._spinFrame = 0;
79
+ this._spinStart = 0;
80
+ this._turnStart = null; // set by beginTurn() so the spinner shows total-turn elapsed
81
+ this._streaming = false;
82
+ this._atLineStart = true;
83
+ this._lastUsage = null; // last per-turn usage (for session /cost ledger)
84
+ }
85
+
86
+ write(s) {
87
+ this.out.write(s);
88
+ if (s.length) this._atLineStart = s.endsWith("\n");
89
+ }
90
+ line(s = "") {
91
+ this.stopSpinner();
92
+ this.write(s + "\n");
93
+ }
94
+ // 줄 시작이 아니면 개행을 보장 (스트리밍/스피너 뒤 깔끔한 블록 시작용).
95
+ ensureNl() {
96
+ if (!this._atLineStart) this.write("\n");
97
+ }
98
+
99
+ rule(label) {
100
+ const cols = (this.out.columns || 80);
101
+ if (label) {
102
+ const text = ` ${label} `;
103
+ const dashes = Math.max(0, cols - visibleWidth(text) - 1);
104
+ this.line(this.c.faint("─") + this.c.dim(text) + this.c.faint("─".repeat(dashes)));
105
+ } else {
106
+ this.line(this.c.faint("─".repeat(Math.max(0, cols - 1))));
107
+ }
108
+ }
109
+
110
+ // ── 스피너 (stderr가 아닌 메인 스트림에, 같은 줄을 갱신) ──
111
+ startSpinner(text) {
112
+ if (!this.enabled || !this.out.isTTY) {
113
+ // 폴백: 한 번만 상태 출력
114
+ if (text && text !== this._spinText) this.line(this.c.dim(" " + text));
115
+ this._spinText = text || "";
116
+ return;
117
+ }
118
+ this._spinText = text || "";
119
+ if (this._spinTimer) return;
120
+ this._spinStart = Date.now();
121
+ const tick = () => {
122
+ const frame = SPINNER_FRAMES[this._spinFrame % SPINNER_FRAMES.length];
123
+ this._spinFrame++;
124
+ const start = this._turnStart || this._spinStart;
125
+ const secs = Math.floor((Date.now() - start) / 1000);
126
+ // Claude Code 스타일 라이브 메타: 경과초 + 중단 힌트 (1초 이상부터)
127
+ const meta = secs >= 1 ? this.c.faint(` (${secs}s · ${this.t ? this.t("spinnerStop") : "ctrl-c to stop"})`) : "";
128
+ this.out.write("\r\x1b[2K" + this.c.emerald(frame) + " " + this.c.dim(this._spinText) + meta);
129
+ this._atLineStart = false;
130
+ };
131
+ tick();
132
+ this._spinTimer = setInterval(tick, 120);
133
+ if (this._spinTimer.unref) this._spinTimer.unref();
134
+ }
135
+
136
+ // 턴 시작/끝 — 스피너가 (툴 사이에 멈췄다 다시 떠도) 총 턴 경과시간을 보여주도록.
137
+ beginTurn() {
138
+ this._turnStart = Date.now();
139
+ }
140
+ endTurn() {
141
+ this._turnStart = null;
142
+ }
143
+ updateSpinner(text) {
144
+ this._spinText = text || "";
145
+ if (!this._spinTimer && this.enabled && this.out.isTTY) this.startSpinner(text);
146
+ }
147
+ stopSpinner() {
148
+ if (this._spinTimer) {
149
+ clearInterval(this._spinTimer);
150
+ this._spinTimer = null;
151
+ this.out.write("\r\x1b[2K");
152
+ this._atLineStart = true;
153
+ }
154
+ }
155
+
156
+ // ── 사용자/에이전트 라벨 ──
157
+ promptLabel(name) {
158
+ return this.c.paw("▌") + this.c.emerald(" › ");
159
+ }
160
+ agentHeader(name) {
161
+ this.ensureNl();
162
+ this.line("");
163
+ this.line(this.c.paw("> ") + this.c.bold(this.c.text(name)));
164
+ }
165
+
166
+ // ── 스트리밍 텍스트 ──
167
+ streamStart() {
168
+ this.stopSpinner();
169
+ this.ensureNl();
170
+ this._streaming = true;
171
+ }
172
+ streamDelta(text) {
173
+ if (!text) return;
174
+ this.stopSpinner();
175
+ this.write(this.c.text(text));
176
+ this._streaming = true;
177
+ }
178
+ streamEnd() {
179
+ if (this._streaming) {
180
+ this.ensureNl();
181
+ this._streaming = false;
182
+ }
183
+ }
184
+
185
+ // ── 툴 호출/결과 라인 (claude/codex 스타일) ──
186
+ tool(name, arg) {
187
+ this.stopSpinner();
188
+ this.ensureNl();
189
+ const head = this.c.green("⏺ ") + this.c.bold(this.c.text(name));
190
+ this.line(arg ? head + " " + this.c.dim(truncate(String(arg), 200)) : head);
191
+ }
192
+ toolResult(text, ok = true) {
193
+ this.stopSpinner();
194
+ const body = truncate(String(text || "").trim(), 600);
195
+ if (!body) {
196
+ this.line(" " + (ok ? this.c.dim("✓ done") : this.c.paw("✗ error")));
197
+ return;
198
+ }
199
+ const lines = body.split("\n");
200
+ const marker = ok ? this.c.dim(" └ ") : this.c.paw(" └ ");
201
+ for (let i = 0; i < lines.length; i++) {
202
+ this.line((i === 0 ? marker : " ") + this.c.dim(lines[i]));
203
+ }
204
+ }
205
+
206
+ status(msg) {
207
+ this.updateSpinner(msg);
208
+ }
209
+ info(msg) {
210
+ this.line(this.c.dim(" " + msg));
211
+ }
212
+ ok(msg) {
213
+ this.stopSpinner();
214
+ this.line(this.c.green("✓ ") + this.c.text(msg));
215
+ }
216
+ warn(msg) {
217
+ this.stopSpinner();
218
+ this.line(this.c.amber("! ") + this.c.text(msg));
219
+ }
220
+ error(msg) {
221
+ this.stopSpinner();
222
+ this.line(this.c.paw("✗ ") + this.c.text(msg));
223
+ }
224
+
225
+ // 최종 텍스트(비스트리밍 경로)에 가벼운 마크다운 강조 적용 후 출력.
226
+ markdown(text) {
227
+ this.stopSpinner();
228
+ this.ensureNl();
229
+ for (const raw of String(text).split("\n")) {
230
+ this.line(this.renderInline(raw));
231
+ }
232
+ }
233
+ renderInline(line) {
234
+ if (!this.enabled) return line;
235
+ let s = line;
236
+ // 헤딩
237
+ const h = s.match(/^(#{1,6})\s+(.*)$/);
238
+ if (h) return this.c.bold(this.c.emerald(h[2]));
239
+ // 인라인 코드 `x`
240
+ s = s.replace(/`([^`]+)`/g, (_m, g) => this.c.amber(g));
241
+ // 굵게 **x**
242
+ s = s.replace(/\*\*([^*]+)\*\*/g, (_m, g) => this.c.bold(g));
243
+ // 불릿
244
+ s = s.replace(/^(\s*)([-*])\s+/, (_m, sp) => sp + this.c.emerald("• "));
245
+ return this.c.text(s);
246
+ }
247
+
248
+ cost(usage) {
249
+ this._lastUsage = usage || null;
250
+ if (!usage) return;
251
+ const bits = [];
252
+ if (usage.input_tokens != null || usage.output_tokens != null) {
253
+ bits.push(`${usage.input_tokens ?? "?"}→${usage.output_tokens ?? "?"} tok`);
254
+ }
255
+ if (usage.cost_usd != null) bits.push(`$${Number(usage.cost_usd).toFixed(4)}`);
256
+ if (usage.duration_ms != null) bits.push(`${(usage.duration_ms / 1000).toFixed(1)}s`);
257
+ if (bits.length) this.line(this.c.faint(" " + bits.join(" · ")));
258
+ }
259
+ }
260
+
261
+ function truncate(s, n) {
262
+ if (s.length <= n) return s;
263
+ return s.slice(0, n) + "…";
264
+ }
265
+
266
+ module.exports = { Ui, colorEnabled, BRAND, stripAnsi, visibleWidth, truncate, SPINNER_FRAMES };