@este.systems/dsc 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/dist/index.js DELETED
@@ -1,916 +0,0 @@
1
- import * as readline from "node:readline/promises";
2
- import { stdin as input, stdout as output } from "node:process";
3
- import { promises as fsp } from "node:fs";
4
- import { openEditor } from "./editor.js";
5
- import { completeSlash } from "./slash.js";
6
- import { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, configPath, hasApiKey, recordUsage, } from "./api.js";
7
- import { runAgent, formatCost, formatStatus, estimateContextTokens } from "./agent.js";
8
- import * as history from "./history.js";
9
- import * as approval from "./approval.js";
10
- import * as replHistory from "./repl_history.js";
11
- import * as audit from "./audit.js";
12
- import { compactSession } from "./compact.js";
13
- import { Spinner, StatusBar } from "./ui.js";
14
- import { formatVersionInfo } from "./version.js";
15
- const RESET = "\x1b[0m";
16
- const DIM = "\x1b[2m";
17
- const BOLD = "\x1b[1m";
18
- const RED = "\x1b[31m";
19
- function parseArgs(argv) {
20
- const out = { model: DEFAULT_MODEL, yolo: false, resume: true, modelExplicit: false };
21
- const positional = [];
22
- for (let i = 0; i < argv.length; i++) {
23
- const a = argv[i];
24
- if (a === "--yolo" || a === "-y") {
25
- out.yolo = true;
26
- }
27
- else if (a === "--model" || a === "-m") {
28
- const v = argv[++i];
29
- if (!AVAILABLE_MODELS.includes(v)) {
30
- throw new Error(`unknown model: ${v} (available: ${AVAILABLE_MODELS.join(", ")})`);
31
- }
32
- out.model = v;
33
- out.modelExplicit = true;
34
- }
35
- else if (a === "--no-resume") {
36
- out.resume = false;
37
- }
38
- else if (a === "--resume") {
39
- const v = argv[i + 1];
40
- if (v && !v.startsWith("-")) {
41
- out.resumeId = v;
42
- i++;
43
- }
44
- out.resume = true;
45
- }
46
- else if (a === "--help" || a === "-h") {
47
- out.help = true;
48
- }
49
- else if (a === "--version" || a === "-v") {
50
- out.version = true;
51
- }
52
- else if (a.startsWith("-")) {
53
- throw new Error(`unknown flag: ${a}`);
54
- }
55
- else {
56
- positional.push(a);
57
- }
58
- }
59
- if (positional.length)
60
- out.prompt = positional.join(" ");
61
- return out;
62
- }
63
- function printHelp() {
64
- process.stdout.write(`dsc — CLI coding agent for DeepSeek
65
-
66
- Usage:
67
- dsc Start interactive REPL
68
- dsc "your prompt here" One-shot mode: run agent on prompt and exit
69
-
70
- Flags:
71
- -m, --model <name> Model: ${AVAILABLE_MODELS.join(" | ")} (default: ${DEFAULT_MODEL})
72
- -y, --yolo Skip approval prompts for write/edit/bash
73
- --no-resume Start a fresh session instead of resuming
74
- --resume [id] Resume a session (default: most recent for this cwd)
75
- -v, --version Print version (dsc, Node, platform/arch) and exit
76
- -h, --help Show this help
77
-
78
- API key (in priority order):
79
- $DEEPSEEK_API_KEY Env var, takes precedence if set
80
- ${configPath()}
81
- JSON file. Accepted shapes:
82
- {"api_key": "sk-..."}
83
- {"env": {"DEEPSEEK_API_KEY": "sk-..."}}
84
- {"env": {"ANTHROPIC_AUTH_TOKEN": "sk-..."}} (claude-switcher compat)
85
-
86
- REPL commands:
87
- /clear Start a new session (current one stays on disk)
88
- /cost Show token usage and estimated cost
89
- /model Show or switch model (e.g. /model deepseek-v4-flash)
90
- /yolo Toggle approval mode
91
- /reasoning [on|off]
92
- Show or hide reasoning_content streamed by thinking models
93
- (toggle when no arg)
94
- /lang [name|off]
95
- Force the model to reply exclusively in <name> (e.g. en,
96
- ro, fr, Chinese, "formal English"). No arg prints the
97
- current setting; 'off' clears the directive.
98
- /auto-continue [N|off]
99
- When the agent hits MAX_TOOL_DEPTH without finishing, auto-grant
100
- up to N more budgets instead of stopping. No arg prints the
101
- current setting. Initial value comes from DSC_AUTO_CONTINUE
102
- env var; default 0 (manual continue).
103
- /list List sessions in this cwd
104
- /save <name> Give the current session a friendly name (used by /resume
105
- and shown in /list).
106
- /rename <text> Replace the "assistant:" prefix on streamed turns with
107
- <text> (a name, glyph, or anything you like). No arg
108
- prints the current label; --reset / default restores it.
109
- /resume <ref> Resume a session by index from /list, by /save'd name,
110
- by id, or 'last' for the most recent.
111
- /audit [path|show [N]]
112
- Default / 'path': print the JSONL audit log path.
113
- 'show [N]': render the last N entries inline (default 10).
114
- /queue [clear] Show or clear the type-ahead queue (lines you typed while
115
- a turn was running).
116
- /version Print dsc + Node + platform versions (useful for bug reports).
117
- /transcript Print the full conversation, including any messages that
118
- /compact previously archived (kept on disk, not sent to
119
- the API).
120
- /compact [N] Summarize older turns into a synthetic block (kept in the
121
- system prompt) and move them to the archive (visible via
122
- /transcript). Keeps the last N user turns verbatim
123
- (default N=4). Cumulative across re-runs.
124
- /edit [text] Compose the next prompt in $EDITOR (good for paste-heavy
125
- or multi-line input). Optional initial text seeds the buffer.
126
- /exit Quit
127
-
128
- Multi-line input:
129
- End a line with a single \\ to continue on the next line (bash-style).
130
- An even number of trailing backslashes is treated as literal.
131
- For paste-heavy or longer drafts, use /edit.
132
-
133
- Audit log:
134
- Every tool call (bash, edits, reads, fetches, rejections) is recorded
135
- as one JSON line at ${audit.auditLogPath()}.
136
- Disable with DSC_NO_AUDIT=1.
137
-
138
- Auto-compact:
139
- When estimated context tokens exceed DSC_AUTO_COMPACT_AT (default 50000;
140
- set to 0 / off / false to disable), dsc runs /compact 4 automatically
141
- after the current turn. Manual /compact still works regardless.
142
-
143
- Auto-continue:
144
- When the agent hits MAX_TOOL_DEPTH=24 tool calls in a turn without
145
- finishing, dsc stops and asks you to type 'continue' to grant another
146
- budget. Set DSC_AUTO_CONTINUE=N to silently grant up to N extra budgets
147
- (≈24×N more tool calls) before stopping. Same toggle via /auto-continue.
148
- `);
149
- }
150
- async function main() {
151
- let cli;
152
- try {
153
- cli = parseArgs(process.argv.slice(2));
154
- }
155
- catch (e) {
156
- process.stderr.write(`${RED}${e.message}${RESET}\n`);
157
- process.exit(2);
158
- }
159
- if (cli.help) {
160
- printHelp();
161
- return;
162
- }
163
- if (cli.version) {
164
- process.stdout.write(formatVersionInfo() + "\n");
165
- return;
166
- }
167
- // Auto-compact threshold (token-count of estimated context). Set to 0 to
168
- // disable. Default 50K — well below the 1M model limit, but tuned to keep
169
- // per-turn input cost from creeping. Override via DSC_AUTO_COMPACT_AT.
170
- const AUTO_COMPACT_AT_TOKENS = (() => {
171
- const raw = process.env.DSC_AUTO_COMPACT_AT;
172
- if (!raw)
173
- return 50_000;
174
- if (raw === "0" || raw === "off" || raw === "false")
175
- return 0;
176
- const n = parseInt(raw, 10);
177
- return Number.isFinite(n) && n > 0 ? n : 50_000;
178
- })();
179
- const AUTO_COMPACT_KEEP = 4;
180
- // Auto-continue: how many times to silently grant another tool-call
181
- // budget when the agent hits MAX_TOOL_DEPTH without converging. 0 (default)
182
- // keeps today's behavior — stop and ask the user to type 'continue'.
183
- const initialAutoContinue = (() => {
184
- const raw = process.env.DSC_AUTO_CONTINUE;
185
- if (!raw)
186
- return 0;
187
- if (raw === "0" || raw === "off" || raw === "false")
188
- return 0;
189
- const n = parseInt(raw, 10);
190
- return Number.isFinite(n) && n > 0 ? n : 0;
191
- })();
192
- if (!hasApiKey()) {
193
- process.stderr.write(`${RED}No DeepSeek API key found.${RESET}\n`);
194
- process.stderr.write(`Either export DEEPSEEK_API_KEY, or create ${configPath()} containing:\n`);
195
- process.stderr.write(` {"api_key": "sk-..."}\n`);
196
- process.stderr.write(`(also accepts {"env": {"ANTHROPIC_AUTH_TOKEN": "sk-..."}} for claude-switcher compat)\n`);
197
- process.exit(1);
198
- }
199
- const cwd = process.cwd();
200
- // Migrate old per-cwd file to the new sessions dir if present.
201
- await history.migrateLegacyIfPresent(cwd, cli.model);
202
- let session = history.newSession(cwd, cli.model);
203
- // The system prompt is rebuilt per turn inside runAgent so cwd/date/status
204
- // are always current — we no longer persist a stale copy at messages[0].
205
- let restoredTurns = 0;
206
- if (cli.resume) {
207
- let target = null;
208
- if (cli.resumeId) {
209
- const loaded = await history.loadSession(cli.resumeId);
210
- if (loaded) {
211
- session = loaded;
212
- target = { id: loaded.id };
213
- }
214
- else {
215
- process.stderr.write(`${RED}session not found: ${cli.resumeId}${RESET}\n`);
216
- process.exit(1);
217
- }
218
- }
219
- else {
220
- target = await history.mostRecentForCwd(cwd);
221
- if (target) {
222
- const loaded = await history.loadSession(target.id);
223
- if (loaded)
224
- session = loaded;
225
- }
226
- }
227
- restoredTurns = session.messages.filter((m) => m.role === "user").length;
228
- }
229
- let messages = session.messages;
230
- let stats = session.stats;
231
- let model = cli.modelExplicit ? cli.model : session.model;
232
- const toolCtx = {
233
- cwd,
234
- yolo: cli.yolo,
235
- filesTouched: stats.files_touched,
236
- sessionId: session.id,
237
- };
238
- // Single-in-flight, coalescing save. Multiple persist() calls during one
239
- // save's RTT collapse into one re-save at the end with the latest state.
240
- // Awaiting persist() returns when *all* queued state is on disk.
241
- let savePromise = null;
242
- let savePending = false;
243
- const persist = () => {
244
- savePending = true;
245
- if (savePromise)
246
- return savePromise;
247
- savePromise = (async () => {
248
- while (savePending) {
249
- savePending = false;
250
- try {
251
- session.model = model;
252
- session.messages = messages;
253
- session.stats = stats;
254
- await history.saveSession(session);
255
- }
256
- catch (e) {
257
- process.stderr.write(`${DIM}(history save failed: ${e.message})${RESET}\n`);
258
- }
259
- }
260
- savePromise = null;
261
- })();
262
- return savePromise;
263
- };
264
- const statusBar = new StatusBar();
265
- const sessionStart = Date.now();
266
- let showReasoning = true;
267
- let autoContinue = initialAutoContinue;
268
- // Declared up here (before currentStatusLine references it) so the early
269
- // refreshStatus() right after statusBar.enable doesn't hit the TDZ.
270
- const promptQueue = [];
271
- const currentStatusLine = () => formatStatus(stats, model, {
272
- yolo: toolCtx.yolo,
273
- reasoning: showReasoning,
274
- contextTokens: estimateContextTokens(messages),
275
- sessionSeconds: Math.floor((Date.now() - sessionStart) / 1000),
276
- compacted: !!session.compaction,
277
- queued: promptQueue.length,
278
- });
279
- const refreshStatus = () => statusBar.render(currentStatusLine());
280
- let pendingAbort = null;
281
- const formatApiError = (e) => {
282
- if (e.status === 401) {
283
- return `API key rejected (401). Check $DEEPSEEK_API_KEY or ${configPath()}.`;
284
- }
285
- if (e.status === 429)
286
- return "Rate-limited (429). Try again in a moment.";
287
- if (e.status === 400 && e.body) {
288
- let detail = e.body;
289
- try {
290
- const parsed = JSON.parse(e.body);
291
- detail = parsed?.error?.message ?? detail;
292
- }
293
- catch { }
294
- return `Bad request (400): ${detail}`;
295
- }
296
- return e.message;
297
- };
298
- const runTurn = async (userText) => {
299
- messages.push({ role: "user", content: userText });
300
- pendingAbort = new AbortController();
301
- try {
302
- await runAgent({
303
- model,
304
- stats,
305
- toolCtx,
306
- messages,
307
- signal: pendingAbort.signal,
308
- onTurn: () => {
309
- refreshStatus();
310
- // Make every committed message durable. Coalesces if a save is in
311
- // flight, so back-to-back tool turns don't queue N saves.
312
- void persist();
313
- },
314
- showReasoning,
315
- getSummary: () => session.compaction?.summary,
316
- assistantLabel: session.assistantLabel,
317
- maxAutoContinue: autoContinue,
318
- language: session.language,
319
- });
320
- }
321
- catch (e) {
322
- if (e.name === "AbortError" || pendingAbort?.signal.aborted) {
323
- process.stderr.write(`\n${DIM}(interrupted)${RESET}\n`);
324
- }
325
- else if (e instanceof DeepSeekError) {
326
- process.stderr.write(`\n${RED}${formatApiError(e)}${RESET}\n`);
327
- if (e.body && e.status !== 400) {
328
- process.stderr.write(`${DIM}${e.body.slice(0, 1000)}${RESET}\n`);
329
- }
330
- }
331
- else {
332
- process.stderr.write(`\n${RED}${e.message}${RESET}\n`);
333
- }
334
- }
335
- finally {
336
- pendingAbort = null;
337
- }
338
- // No final refreshStatus here — the last onTurn inside runAgent already
339
- // printed the post-push status; re-printing would just double the bar.
340
- await persist();
341
- };
342
- // One-shot mode
343
- if (cli.prompt) {
344
- await runTurn(cli.prompt);
345
- process.stdout.write(`\n${DIM}${formatCost(stats, model)}${RESET}\n`);
346
- return;
347
- }
348
- // REPL
349
- process.stdout.write(`${BOLD}dsc${RESET} ${DIM}(${model}${cli.yolo ? ", yolo" : ""})${RESET} `);
350
- process.stdout.write(`${DIM}type /help for commands, ESC to interrupt a turn, Ctrl+D to exit${RESET}\n`);
351
- if (restoredTurns > 0) {
352
- process.stdout.write(`${DIM}restored ${restoredTurns}-turn history (use /clear to reset)${RESET}\n`);
353
- }
354
- statusBar.enable();
355
- refreshStatus();
356
- const cleanup = () => statusBar.disable();
357
- process.on("exit", cleanup);
358
- let lastSigintMs = 0;
359
- process.on("SIGINT", () => {
360
- if (pendingAbort && !pendingAbort.signal.aborted) {
361
- pendingAbort.abort();
362
- return;
363
- }
364
- const now = Date.now();
365
- if (now - lastSigintMs < 1000) {
366
- cleanup();
367
- process.exit(130);
368
- }
369
- lastSigintMs = now;
370
- process.stdout.write(`\n${DIM}(press Ctrl+C again within 1s to exit)${RESET}\n`);
371
- });
372
- const rl = readline.createInterface({
373
- input,
374
- output,
375
- historySize: 1000,
376
- completer: completeSlashCommand,
377
- });
378
- // Approval calls rl.question — while one is pending, lines that come back
379
- // are the user's y/N answer, not new prompts. The queue listener checks this
380
- // flag and stays out of the way so the answer doesn't get queued.
381
- let approvalActive = false;
382
- approval.setAsker(async (q) => {
383
- approvalActive = true;
384
- try {
385
- return await rl.question(q);
386
- }
387
- finally {
388
- approvalActive = false;
389
- }
390
- });
391
- // Type-ahead queue listener: while a turn is running, captured 'line'
392
- // events get pushed into promptQueue (declared earlier in this function).
393
- const bufferDuringTurn = (line) => {
394
- if (approvalActive)
395
- return;
396
- const trimmed = line.trim();
397
- if (!trimmed)
398
- return;
399
- promptQueue.push(trimmed);
400
- process.stdout.write(`${DIM}(queued — ${promptQueue.length} pending)${RESET}\n`);
401
- refreshStatus();
402
- };
403
- // ESC interrupts the current turn — more intuitive than Ctrl+C for "stop
404
- // what the agent is doing right now". readline already puts stdin in raw
405
- // mode and emits 'keypress' events for terminal input, so we just listen.
406
- // Standalone ESC fires after a short disambiguation delay; ESC-prefixed
407
- // sequences (arrow keys etc) come through as their named keys instead.
408
- const onKeypress = (_str, key) => {
409
- if (key?.name === "escape" &&
410
- pendingAbort &&
411
- !pendingAbort.signal.aborted) {
412
- pendingAbort.abort();
413
- }
414
- };
415
- process.stdin.on("keypress", onKeypress);
416
- // Also catch readline's own SIGINT path for Ctrl+C — defends against the
417
- // case where readline intercepts the signal before our process.on handler.
418
- rl.on("SIGINT", () => {
419
- if (pendingAbort && !pendingAbort.signal.aborted) {
420
- pendingAbort.abort();
421
- }
422
- });
423
- // Seed up/down history from disk (newest first per readline's convention).
424
- void replHistory.compact();
425
- const past = await replHistory.load();
426
- const rlAny = rl;
427
- rlAny.history.length = 0;
428
- rlAny.history.push(...past.slice().reverse());
429
- while (true) {
430
- let line;
431
- if (promptQueue.length) {
432
- // Drain the type-ahead queue before prompting again. Show what we're
433
- // about to run so the user can tell it's not a fresh prompt.
434
- line = promptQueue.shift();
435
- process.stdout.write(`${DIM}── from queue:${RESET} ${line}\n`);
436
- refreshStatus();
437
- }
438
- else {
439
- try {
440
- line = await readPromptInput(rl);
441
- }
442
- catch {
443
- break; // Ctrl+D
444
- }
445
- }
446
- const trimmed = line.trim();
447
- if (!trimmed)
448
- continue;
449
- // Persist the user's submitted line to the on-disk history file. Slash
450
- // commands are recorded too — recalling "/resume 3" is useful.
451
- void replHistory.append(trimmed);
452
- if (trimmed.startsWith("/")) {
453
- const [cmd, ...rest] = trimmed.slice(1).split(/\s+/);
454
- const arg = rest.join(" ");
455
- if (cmd === "exit" || cmd === "quit") {
456
- break;
457
- }
458
- else if (cmd === "help") {
459
- printHelp();
460
- }
461
- else if (cmd === "clear") {
462
- // Start a brand-new session; old session stays on disk. System prompt
463
- // is rebuilt per turn so we don't seed messages with one.
464
- session = history.newSession(cwd, model);
465
- messages = session.messages;
466
- stats = session.stats;
467
- toolCtx.filesTouched = stats.files_touched;
468
- toolCtx.sessionId = session.id;
469
- refreshStatus();
470
- process.stdout.write(`${DIM}new session started (${session.id})${RESET}\n`);
471
- }
472
- else if (cmd === "list") {
473
- const all = await history.listSessions(cwd);
474
- if (!all.length) {
475
- process.stdout.write(`${DIM}no sessions for ${cwd}${RESET}\n`);
476
- }
477
- else {
478
- all.forEach((s, i) => {
479
- const ago = formatRelative(s.updated_at);
480
- const here = s.id === session.id ? `${BOLD}*${RESET} ` : " ";
481
- const label = s.name ? `${BOLD}${s.name}${RESET} (${s.model})` : s.model;
482
- process.stdout.write(`${here}${String(i + 1).padStart(2, " ")}. ${label} ${ago} (${s.message_count} msgs) ${DIM}${s.first_user_message || "—"}${RESET}\n`);
483
- });
484
- }
485
- }
486
- else if (cmd === "save") {
487
- const name = arg.trim();
488
- if (!name) {
489
- process.stdout.write(`${RED}usage: /save <name>${RESET}\n`);
490
- }
491
- else {
492
- session.name = name;
493
- await persist();
494
- process.stdout.write(`${DIM}session saved as "${name}" (id ${session.id})${RESET}\n`);
495
- }
496
- }
497
- else if (cmd === "rename") {
498
- const text = arg.trim();
499
- if (!text) {
500
- const cur = session.assistantLabel ?? "assistant:";
501
- process.stdout.write(`${DIM}assistant label: "${cur}"${RESET}\n`);
502
- }
503
- else if (text === "--reset" || text === "default") {
504
- delete session.assistantLabel;
505
- await persist();
506
- process.stdout.write(`${DIM}assistant label reset to default${RESET}\n`);
507
- }
508
- else {
509
- session.assistantLabel = text;
510
- await persist();
511
- process.stdout.write(`${DIM}assistant label → "${text}"${RESET}\n`);
512
- }
513
- }
514
- else if (cmd === "resume") {
515
- const all = await history.listSessions(cwd);
516
- if (!all.length) {
517
- process.stdout.write(`${DIM}no sessions to resume${RESET}\n`);
518
- }
519
- else {
520
- let target = null;
521
- if (!arg || arg === "last") {
522
- target = all[0];
523
- }
524
- else if (/^\d+$/.test(arg)) {
525
- const idx = parseInt(arg, 10) - 1;
526
- target = all[idx] ?? null;
527
- if (!target)
528
- process.stdout.write(`${RED}no session at index ${arg} (have ${all.length})${RESET}\n`);
529
- }
530
- else {
531
- // Match by name first (most-recent wins on tie since `all` is
532
- // sorted desc by updated_at), then fall back to id.
533
- target =
534
- all.find((s) => s.name === arg) ??
535
- all.find((s) => s.id === arg) ??
536
- null;
537
- if (!target)
538
- process.stdout.write(`${RED}no session with name or id ${arg}${RESET}\n`);
539
- }
540
- if (target) {
541
- const loaded = await history.loadSession(target.id);
542
- if (!loaded) {
543
- process.stdout.write(`${RED}failed to load session ${target.id}${RESET}\n`);
544
- }
545
- else {
546
- session = loaded;
547
- messages = session.messages;
548
- stats = session.stats;
549
- model = session.model;
550
- toolCtx.filesTouched = stats.files_touched;
551
- toolCtx.sessionId = session.id;
552
- refreshStatus();
553
- const userTurns = messages.filter((m) => m.role === "user").length;
554
- process.stdout.write(`${DIM}resumed ${session.id} (${userTurns} turns, model ${model})${RESET}\n`);
555
- }
556
- }
557
- }
558
- }
559
- else if (cmd === "cost") {
560
- process.stdout.write(`${DIM}${formatCost(stats, model)}${RESET}\n`);
561
- }
562
- else if (cmd === "model") {
563
- if (!arg) {
564
- process.stdout.write(`${DIM}current model: ${model}${RESET}\n`);
565
- }
566
- else if (!AVAILABLE_MODELS.includes(arg)) {
567
- process.stdout.write(`${RED}unknown model: ${arg} (available: ${AVAILABLE_MODELS.join(", ")})${RESET}\n`);
568
- }
569
- else {
570
- model = arg;
571
- refreshStatus();
572
- process.stdout.write(`${DIM}model -> ${model}${RESET}\n`);
573
- await persist();
574
- }
575
- }
576
- else if (cmd === "yolo") {
577
- toolCtx.yolo = !toolCtx.yolo;
578
- refreshStatus();
579
- process.stdout.write(`${DIM}yolo: ${toolCtx.yolo}${RESET}\n`);
580
- }
581
- else if (cmd === "reasoning") {
582
- if (arg === "on")
583
- showReasoning = true;
584
- else if (arg === "off")
585
- showReasoning = false;
586
- else
587
- showReasoning = !showReasoning; // toggle when no arg
588
- refreshStatus();
589
- process.stdout.write(`${DIM}reasoning: ${showReasoning ? "on" : "off"}${RESET}\n`);
590
- }
591
- else if (cmd === "queue") {
592
- const sub = arg.trim().toLowerCase();
593
- if (sub === "clear" || sub === "drop") {
594
- const n = promptQueue.length;
595
- promptQueue.length = 0;
596
- refreshStatus();
597
- process.stdout.write(`${DIM}cleared ${n} queued prompt(s)${RESET}\n`);
598
- }
599
- else if (promptQueue.length === 0) {
600
- process.stdout.write(`${DIM}queue is empty${RESET}\n`);
601
- }
602
- else {
603
- promptQueue.forEach((p, i) => process.stdout.write(`${DIM}${String(i + 1).padStart(2, " ")}.${RESET} ${p}\n`));
604
- }
605
- }
606
- else if (cmd === "lang") {
607
- const text = arg.trim();
608
- if (!text) {
609
- process.stdout.write(`${DIM}language: ${session.language ? `"${session.language}"` : "off (any language)"}${RESET}\n`);
610
- }
611
- else if (text === "off" || text === "default" || text === "any") {
612
- delete session.language;
613
- await persist();
614
- process.stdout.write(`${DIM}language directive cleared${RESET}\n`);
615
- }
616
- else {
617
- session.language = text;
618
- await persist();
619
- process.stdout.write(`${DIM}language → "${text}" (replies will be exclusively in this language)${RESET}\n`);
620
- }
621
- }
622
- else if (cmd === "auto-continue") {
623
- const trimmed = arg.trim();
624
- if (!trimmed) {
625
- process.stdout.write(`${DIM}auto-continue: ${autoContinue === 0 ? "off" : `up to ${autoContinue} extra budget(s)`}${RESET}\n`);
626
- }
627
- else if (trimmed === "off" || trimmed === "0" || trimmed === "false") {
628
- autoContinue = 0;
629
- process.stdout.write(`${DIM}auto-continue: off${RESET}\n`);
630
- }
631
- else {
632
- const n = parseInt(trimmed, 10);
633
- if (!Number.isFinite(n) || n < 0) {
634
- process.stdout.write(`${RED}usage: /auto-continue [N|off]${RESET}\n`);
635
- }
636
- else {
637
- autoContinue = n;
638
- process.stdout.write(`${DIM}auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}${RESET}\n`);
639
- }
640
- }
641
- }
642
- else if (cmd === "version") {
643
- process.stdout.write(`${DIM}${formatVersionInfo()}${RESET}\n`);
644
- }
645
- else if (cmd === "audit") {
646
- const sub = arg.trim();
647
- if (sub.startsWith("show")) {
648
- const nRaw = sub.replace(/^show\s*/, "").trim();
649
- const n = nRaw ? parseInt(nRaw, 10) : NaN;
650
- const limit = Number.isFinite(n) && n > 0 ? n : 10;
651
- await renderAuditEntries(limit);
652
- }
653
- else if (!sub || sub === "path") {
654
- process.stdout.write(`${DIM}${audit.auditLogPath()}${RESET}\n`);
655
- }
656
- else {
657
- process.stdout.write(`${RED}usage: /audit | /audit path | /audit show [N]${RESET}\n`);
658
- }
659
- }
660
- else if (cmd === "transcript") {
661
- const archived = session.archivedMessages ?? [];
662
- if (archived.length === 0 && messages.length === 0) {
663
- process.stdout.write(`${DIM}(no messages)${RESET}\n`);
664
- }
665
- else {
666
- if (archived.length > 0) {
667
- process.stdout.write(`${DIM}── archived (${archived.length} messages)${RESET}\n`);
668
- for (const m of archived)
669
- renderTranscriptMessage(m, true);
670
- }
671
- if (messages.length > 0) {
672
- process.stdout.write(`${DIM}── active (${messages.length} messages)${RESET}\n`);
673
- for (const m of messages)
674
- renderTranscriptMessage(m, false);
675
- }
676
- }
677
- }
678
- else if (cmd === "compact") {
679
- const keepRaw = arg ? parseInt(arg, 10) : NaN;
680
- const keep = Number.isFinite(keepRaw) ? Math.max(0, keepRaw) : 4;
681
- await runCompaction(keep, false);
682
- }
683
- else if (cmd === "edit") {
684
- const draft = openEditor(arg ? arg + "\n" : "");
685
- if (draft === null) {
686
- process.stdout.write(`${RED}editor failed${RESET}\n`);
687
- }
688
- else if (!draft.trim()) {
689
- process.stdout.write(`${DIM}(empty draft, not sent)${RESET}\n`);
690
- }
691
- else {
692
- process.stdout.write(`${DIM}── editor draft (${draft.length} chars):${RESET}\n`);
693
- process.stdout.write(draft.replace(/\n/g, "\n ") + "\n");
694
- process.stdout.write(`${DIM}──${RESET}\n`);
695
- void replHistory.append(draft);
696
- await runTurnWithHistorySnapshot(draft);
697
- }
698
- }
699
- else {
700
- process.stdout.write(`${RED}unknown command: /${cmd}${RESET}\n`);
701
- }
702
- continue;
703
- }
704
- await runTurnWithHistorySnapshot(trimmed);
705
- }
706
- async function runTurnWithHistorySnapshot(text) {
707
- // Snapshot rl.history so approval y/N answers (which readline auto-adds)
708
- // don't leak into up-arrow recall.
709
- const histSnapshot = rlAny.history.slice();
710
- // Listen for 'line' events while the turn runs so type-ahead lands in
711
- // promptQueue instead of being lost.
712
- rl.on("line", bufferDuringTurn);
713
- try {
714
- await runTurn(text);
715
- }
716
- finally {
717
- rl.off("line", bufferDuringTurn);
718
- rlAny.history.length = 0;
719
- rlAny.history.push(...histSnapshot);
720
- }
721
- // After every turn, kick off an auto-compaction if ctx is over budget.
722
- if (AUTO_COMPACT_AT_TOKENS > 0) {
723
- const ctx = estimateContextTokens(messages);
724
- if (ctx > AUTO_COMPACT_AT_TOKENS) {
725
- await runCompaction(AUTO_COMPACT_KEEP, true);
726
- }
727
- }
728
- }
729
- async function runCompaction(keep, auto) {
730
- const beforeMessages = messages.length;
731
- const beforeChars = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
732
- if (auto) {
733
- process.stdout.write(`${DIM}── auto-compact (ctx > ${AUTO_COMPACT_AT_TOKENS} tokens)${RESET}\n`);
734
- }
735
- const spinner = new Spinner("compacting");
736
- spinner.start();
737
- try {
738
- const result = await compactSession(session, keep, model);
739
- spinner.stop();
740
- if (!result) {
741
- if (!auto) {
742
- process.stdout.write(`${DIM}nothing to compact (need more than ${keep} user turns)${RESET}\n`);
743
- }
744
- return;
745
- }
746
- // Move the summarized messages into the on-disk archive so /transcript
747
- // can still show them. They're no longer sent to the API.
748
- session.archivedMessages = [
749
- ...(session.archivedMessages ?? []),
750
- ...result.droppedMessages,
751
- ];
752
- session.messages = result.remainingMessages;
753
- messages = session.messages;
754
- session.compaction = {
755
- summary: result.summary,
756
- compacted_at: Date.now(),
757
- turns_removed: (session.compaction?.turns_removed ?? 0) + result.turnsRemoved,
758
- };
759
- stats.prompts += 1;
760
- recordUsage(stats, result.usage);
761
- const afterChars = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
762
- const summaryChars = result.summary.length;
763
- process.stdout.write(`${DIM}compacted ${result.turnsRemoved} user turn(s); messages ${beforeMessages} → ${messages.length}; chars ${beforeChars} → ${afterChars} + ${summaryChars} summary${RESET}\n`);
764
- refreshStatus();
765
- await persist();
766
- }
767
- catch (e) {
768
- spinner.stop();
769
- if (e instanceof DeepSeekError) {
770
- process.stderr.write(`${RED}compaction failed: ${formatApiError(e)}${RESET}\n`);
771
- }
772
- else {
773
- process.stderr.write(`${RED}compaction failed: ${e.message}${RESET}\n`);
774
- }
775
- }
776
- }
777
- approval.setAsker(null);
778
- process.stdin.removeListener("keypress", onKeypress);
779
- rl.close();
780
- statusBar.disable();
781
- process.stdout.write(`\n${DIM}${formatCost(stats, model)}${RESET}\n`);
782
- }
783
- async function renderAuditEntries(limit) {
784
- let text;
785
- try {
786
- text = await fsp.readFile(audit.auditLogPath(), "utf8");
787
- }
788
- catch {
789
- process.stdout.write(`${DIM}(no audit log yet)${RESET}\n`);
790
- return;
791
- }
792
- const lines = text.split("\n").filter((l) => l.length > 0).slice(-limit);
793
- if (lines.length === 0) {
794
- process.stdout.write(`${DIM}(empty)${RESET}\n`);
795
- return;
796
- }
797
- for (const line of lines) {
798
- let entry;
799
- try {
800
- entry = JSON.parse(line);
801
- }
802
- catch {
803
- continue;
804
- }
805
- const ts = typeof entry.ts === "string" ? entry.ts.slice(11, 19) : "";
806
- const ok = entry.approved === false
807
- ? `${RED}✗${RESET}`
808
- : entry.error
809
- ? `${RED}!${RESET}`
810
- : `${DIM}✓${RESET}`;
811
- const tool = String(entry.tool ?? "?");
812
- const summary = summarizeAuditEntry(entry);
813
- process.stdout.write(`${DIM}${ts}${RESET} ${ok} ${BOLD}${tool.padEnd(11)}${RESET} ${summary}\n`);
814
- }
815
- }
816
- function summarizeAuditEntry(e) {
817
- const trim = (v, n = 80) => {
818
- const s = typeof v === "string" ? v : "";
819
- return s.length <= n ? s : s.slice(0, n) + "…";
820
- };
821
- switch (e.tool) {
822
- case "bash":
823
- return trim(e.command, 100);
824
- case "write_file":
825
- case "edit_file":
826
- case "read_file":
827
- return trim(e.path);
828
- case "grep":
829
- return `"${trim(e.pattern, 40)}" in ${trim(e.path, 40)}`;
830
- case "glob":
831
- return trim(e.pattern);
832
- case "web_search":
833
- return `"${trim(e.query, 80)}"`;
834
- case "web_fetch":
835
- return trim(e.url, 100);
836
- default:
837
- return "";
838
- }
839
- }
840
- // Slash commands the REPL recognizes. Used both for tab completion and as
841
- // a single source of truth for what's accepted (kept in sync with the if/
842
- // else chain in main()).
843
- // readline-style completer: returns [matches, substringMatched]. Backs into
844
- // the shared completeSlash helper so the REPL and TUI complete the same set.
845
- function completeSlashCommand(line) {
846
- const { matches } = completeSlash(line);
847
- return [matches, line];
848
- }
849
- function renderTranscriptMessage(m, archived) {
850
- const tag = archived ? `${DIM}archived${RESET} ` : "";
851
- const role = m.role;
852
- const color = role === "user" ? "\x1b[36m" : role === "assistant" ? "\x1b[35m" : DIM;
853
- const content = typeof m.content === "string" ? m.content : "";
854
- process.stdout.write(`\n${tag}${BOLD}${color}${role}${RESET}\n`);
855
- if (m.tool_calls && m.tool_calls.length) {
856
- for (const tc of m.tool_calls) {
857
- process.stdout.write(`${DIM} → ${tc.function.name}(${truncateForTranscript(tc.function.arguments, 200)})${RESET}\n`);
858
- }
859
- }
860
- if (m.tool_call_id) {
861
- process.stdout.write(`${DIM} ← tool_call_id: ${m.tool_call_id}${RESET}\n`);
862
- }
863
- if (content)
864
- process.stdout.write(content + "\n");
865
- }
866
- function truncateForTranscript(s, n) {
867
- return s.length <= n ? s : s.slice(0, n) + "…";
868
- }
869
- function formatRelative(ts) {
870
- const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
871
- if (s < 60)
872
- return `${s}s ago`;
873
- const m = Math.floor(s / 60);
874
- if (m < 60)
875
- return `${m}m ago`;
876
- const h = Math.floor(m / 60);
877
- if (h < 24)
878
- return `${h}h ago`;
879
- const d = Math.floor(h / 24);
880
- return `${d}d ago`;
881
- }
882
- /**
883
- * Read one logical user prompt, supporting bash-style backslash continuation:
884
- * an odd number of trailing backslashes on a submitted line means "continue
885
- * on the next line"; the last backslash is consumed and replaced with a
886
- * literal newline. An even count (e.g. \\) is treated as literal trailing
887
- * backslashes and the line submits.
888
- */
889
- async function readPromptInput(rl) {
890
- const FIRST = `${BOLD}> ${RESET}`;
891
- const CONT = `${DIM}… ${RESET}`;
892
- let buf = "";
893
- let prompt = FIRST;
894
- while (true) {
895
- const line = await rl.question(prompt);
896
- const trailing = (line.match(/\\+$/) || [""])[0].length;
897
- if (trailing % 2 === 1) {
898
- buf += line.slice(0, -1) + "\n";
899
- prompt = CONT;
900
- continue;
901
- }
902
- buf += line;
903
- return buf;
904
- }
905
- }
906
- /**
907
- * Open $EDITOR (preferring $VISUAL) on a temp .md file seeded with `initial`.
908
- * Returns the file's contents on save, or null if the editor failed to launch.
909
- * Empty/whitespace-only results are returned as "" so the caller can decide
910
- * whether to skip the turn.
911
- */
912
- main().catch((e) => {
913
- process.stderr.write(`${RED}fatal: ${e.message}${RESET}\n`);
914
- process.exit(1);
915
- });
916
- //# sourceMappingURL=index.js.map