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