@este.systems/dsc 1.1.0 → 1.2.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/tui.js CHANGED
@@ -2,15 +2,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { App } from "./tui/App.js";
4
4
  import { getState, setState } from "./store.js";
5
- import { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, apiKeySource, computeCostUsd, configPath, consumeConfigMigrationNotice, hasApiKey, recordUsage, saveApiKey, saveSearchKey, saveSearchProvider, } from "./api.js";
6
- import { getProvider, getProviderKey } from "./search.js";
5
+ import { availableModels, DEFAULT_MODEL, DeepSeekError, computeCostUsd, configPath, consumeConfigMigrationNotice, hasApiKey, isKnownModel, recordUsage, } from "./api.js";
7
6
  import { loadInstructions } from "./instructions.js";
8
- import { clearPreferences, preferencesPath, readPreferences, savePreferences, } from "./preferences.js";
7
+ import { readPreferences } from "./preferences.js";
9
8
  import { callMCPTool, connectAll as connectMCP, } from "./mcp.js";
10
9
  import { runAgent, formatCost, estimateContextTokens, } from "./agent.js";
11
10
  import * as history from "./history.js";
12
11
  import * as approval from "./approval.js";
13
- import * as audit from "./audit.js";
14
12
  import * as replHistory from "./repl_history.js";
15
13
  import { promises as fsp } from "node:fs";
16
14
  import { homedir } from "node:os";
@@ -18,8 +16,8 @@ import * as path from "node:path";
18
16
  import { compactSession } from "./compact.js";
19
17
  import { formatVersionInfo } from "./version.js";
20
18
  import { openEditor } from "./editor.js";
21
- import { addLocalBinToShellRc, checkForUpdate, localBinOnPath, runUpdate, setUserNpmPrefix, shellRcPath, } from "./update.js";
22
- import { copyToClipboard } from "./clipboard.js";
19
+ import { checkForUpdate } from "./update.js";
20
+ import { dispatchSlash } from "./slash_dispatch.js";
23
21
  // Auto-compact threshold (token-count of estimated context). Default
24
22
  // 50K — well below the 1M model limit, but tuned to keep per-turn input
25
23
  // cost from creeping. Override via DSC_AUTO_COMPACT_AT; set to "0" /
@@ -65,8 +63,8 @@ function parseArgs(argv) {
65
63
  }
66
64
  else if (a === "--model" || a === "-m") {
67
65
  const v = argv[++i];
68
- if (!AVAILABLE_MODELS.includes(v)) {
69
- throw new CliError(`unknown model: ${v} (available: ${AVAILABLE_MODELS.join(", ")})`);
66
+ if (!isKnownModel(v)) {
67
+ throw new CliError(`unknown model: ${v} (available: ${availableModels().join(", ")})`);
70
68
  }
71
69
  out.model = v;
72
70
  out.modelExplicit = true;
@@ -104,9 +102,10 @@ async function main() {
104
102
  "Usage:",
105
103
  " dsc Start the TUI",
106
104
  " dsc \"your prompt here\" One-shot: run and exit",
105
+ " dsc serve [--port <n>] Headless WebSocket daemon (default 127.0.0.1:9090)",
107
106
  "",
108
107
  "Flags:",
109
- " -m, --model <name> " + AVAILABLE_MODELS.join(" | "),
108
+ " -m, --model <name> " + availableModels().join(" | "),
110
109
  " -y, --yolo Skip approval prompts",
111
110
  " --no-resume Don't auto-resume the latest session",
112
111
  " --resume [id] Resume the most recent (or by id)",
@@ -540,807 +539,84 @@ async function main() {
540
539
  // Returns true if the input was a recognized slash command and got handled
541
540
  // (or rejected) — caller should not forward it to the agent. Returns false
542
541
  // for non-slash input and unknown commands (caller decides what to do).
543
- const handleSlash = async (line) => {
544
- if (!line.startsWith("/"))
545
- return false;
546
- const [cmd, ...rest] = line.slice(1).split(/\s+/);
547
- const arg = rest.join(" ");
548
- switch (cmd) {
549
- case "exit":
550
- case "quit":
551
- clearInterval(timerId);
552
- process.exit(0);
553
- return true;
554
- case "help":
555
- info([
556
- "/help show this help",
557
- "/clear start a new session",
558
- "/list list sessions for this cwd",
559
- "/resume [n|name|id] resume a session",
560
- "/save <name> name the current session",
561
- "/rename <text> set assistant label for this session",
562
- "/model [name] show or switch model",
563
- "/yolo toggle approval mode",
564
- "/reasoning [on|off] toggle reasoning display",
565
- "/lang [name|off] force the model to reply in a language",
566
- "/auto-continue [N|off] auto-grant N extra MAX_TOOL_DEPTH budgets",
567
- "/cost show token usage and cost",
568
- "/budget [usd|off] set per-session USD ceiling (warn at 80%, abort at 100%)",
569
- "/copy copy last assistant response to clipboard",
570
- "/version show version info",
571
- "/instructions list active per-project / per-user instruction overlays",
572
- "/mcp list connected MCP servers and their tools",
573
- "/preferences [reset] show or clear persisted slash-set settings",
574
- "/compact [keep] summarize old turns (default keep=4)",
575
- "/transcript dump full message log",
576
- "/audit [path|show N] audit log info",
577
- "/queue [clear] list or clear queued prompts",
578
- "/export [path] write current session JSON for transfer",
579
- "/import <path> load session JSON; rebinds cwd here (--keep-cwd to skip)",
580
- "/api-key [key] show source / save api key to config file",
581
- "/search [use|key] … show / switch search provider / save brave|tavily key",
582
- "/update check npm for a newer dsc and install it",
583
- "/edit [text] open $EDITOR; the saved buffer becomes the next prompt",
584
- "/exit exit",
585
- ].join("\n"));
586
- return true;
587
- case "clear":
588
- session = history.newSession(cwd, model);
589
- messages = session.messages;
590
- stats = session.stats;
591
- toolCtx.filesTouched = stats.files_touched;
592
- toolCtx.sessionId = session.id;
593
- // Per-tool always-approval is conversation-scoped, so /clear has
594
- // to drop it — otherwise the user would carry over implicit trust
595
- // they made to a session they just walked away from.
542
+ // Slash-command dispatch lives in src/slash_dispatch.ts now (shared with
543
+ // `dsc serve`). We wire the dispatcher's SlashContext to this front-end's
544
+ // imperative locals: getters read the (reassignable) session state, actions
545
+ // mutate it + drive ink. `emit` is the `info()` system-line sink.
546
+ const slashContext = {
547
+ cwd,
548
+ emit: info,
549
+ getModel: () => model,
550
+ getStats: () => stats,
551
+ getSession: () => session,
552
+ getMessages: () => messages,
553
+ getToolCtx: () => toolCtx,
554
+ getQueue: () => promptQueue,
555
+ getBudget: () => ({ usd: budgetUsd, warned: budgetWarned }),
556
+ getMcpConnections: () => mcpConnections,
557
+ setModel: (m) => {
558
+ model = m;
559
+ },
560
+ setBudget: (usd, warned) => {
561
+ budgetUsd = usd;
562
+ budgetWarned = warned;
563
+ },
564
+ persist,
565
+ syncStatus,
566
+ compact: runCompaction,
567
+ submit: (text) => handleSubmit(text),
568
+ applySession: (next, opts) => {
569
+ session = next;
570
+ messages = next.messages;
571
+ stats = next.stats;
572
+ model = next.model;
573
+ toolCtx.filesTouched = stats.files_touched;
574
+ toolCtx.sessionId = next.id;
575
+ // Per-tool always-approval is conversation-scoped, so /clear drops it —
576
+ // otherwise the user carries implicit trust into a session they walked
577
+ // away from.
578
+ if (opts.resetApprovals)
596
579
  toolCtx.sessionApprovals = new Set();
597
- setState({ history: [], current: null });
598
- info(`new session started (${session.id})`);
599
- syncStatus();
600
- return true;
601
- case "list": {
602
- const all = await history.listSessions(cwd);
603
- if (!all.length) {
604
- info(`no sessions for ${cwd}`);
605
- }
606
- else {
607
- info(all
608
- .map((s, i) => {
609
- const here = s.id === session.id ? "* " : " ";
610
- const label = s.name ? `${s.name} (${s.model})` : s.model;
611
- return `${here}${String(i + 1).padStart(2, " ")}. ${label} ${formatRelative(s.updated_at)} (${s.message_count} msgs) ${s.first_user_message || "—"}`;
612
- })
613
- .join("\n"));
614
- }
615
- return true;
616
- }
617
- case "save":
618
- if (!arg.trim()) {
619
- info("error: usage: /save <name>");
620
- }
621
- else {
622
- session.name = arg.trim();
623
- await persist();
624
- info(`session saved as "${session.name}" (id ${session.id})`);
625
- }
626
- return true;
627
- case "rename": {
628
- const text = arg.trim();
629
- if (!text) {
630
- info(`assistant label: "${session.assistantLabel ?? "assistant:"}"`);
631
- }
632
- else if (text === "--reset" || text === "default") {
633
- delete session.assistantLabel;
634
- setState({ assistantLabel: "assistant:" });
635
- await persist();
636
- info("assistant label reset to default");
637
- }
638
- else {
639
- session.assistantLabel = text;
640
- setState({ assistantLabel: text });
641
- await persist();
642
- info(`assistant label → "${text}"`);
643
- }
644
- return true;
645
- }
646
- case "resume": {
647
- const all = await history.listSessions(cwd);
648
- if (!all.length) {
649
- info("no sessions to resume");
650
- return true;
651
- }
652
- let target = null;
653
- if (!arg || arg === "last") {
654
- target = all[0];
655
- }
656
- else if (/^\d+$/.test(arg)) {
657
- target = all[parseInt(arg, 10) - 1] ?? null;
658
- if (!target)
659
- info(`error: no session at index ${arg} (have ${all.length})`);
660
- }
661
- else {
662
- target =
663
- all.find((s) => s.name === arg) ??
664
- all.find((s) => s.id === arg) ??
665
- null;
666
- if (!target)
667
- info(`error: no session with name or id ${arg}`);
668
- }
669
- if (target) {
670
- const loaded = await history.loadSession(target.id);
671
- if (!loaded) {
672
- info(`error: failed to load session ${target.id}`);
673
- }
674
- else {
675
- session = loaded;
676
- messages = session.messages;
677
- stats = session.stats;
678
- model = session.model;
679
- toolCtx.filesTouched = stats.files_touched;
680
- toolCtx.sessionId = session.id;
681
- const userTurns = messages.filter((m) => m.role === "user").length;
682
- // Rebuild history view from the resumed session.
683
- const restored = [];
684
- for (const m of messages) {
685
- if (m.role === "system")
686
- continue;
687
- restored.push({
688
- id: `r-${restored.length}`,
689
- role: m.role,
690
- content: typeof m.content === "string" ? m.content : "",
691
- tool_call_id: m.tool_call_id,
692
- });
693
- }
694
- setState({ history: restored, current: null, model });
695
- syncStatus();
696
- info(`resumed ${session.id} (${userTurns} turns, model ${model})`);
697
- }
698
- }
699
- return true;
700
- }
701
- case "cost":
702
- info(formatCost(stats, model));
703
- return true;
704
- case "budget": {
705
- // /budget -> show current limit + spend
706
- // /budget <usd> -> set a session ceiling
707
- // /budget off -> clear
708
- const text = arg.trim().toLowerCase();
709
- if (!text) {
710
- const cost = computeCostUsd(stats, model);
711
- if (budgetUsd === null) {
712
- info(`budget: not set (spent: $${cost.toFixed(4)})\n/budget <usd> to set a limit, /budget off to clear`);
713
- }
714
- else {
715
- const pct = budgetUsd > 0 ? Math.round((cost / budgetUsd) * 100) : 0;
716
- info(`budget: $${budgetUsd.toFixed(2)} spent: $${cost.toFixed(4)} (${pct}%) warned: ${budgetWarned ? "yes" : "no"}`);
717
- }
718
- return true;
719
- }
720
- if (text === "off" || text === "none" || text === "0") {
721
- budgetUsd = null;
722
- budgetWarned = false;
723
- void savePreferences({ budgetUsd: null });
724
- info("budget cleared (saved)");
725
- return true;
726
- }
727
- const n = parseFloat(text);
728
- if (!Number.isFinite(n) || n <= 0) {
729
- info("error: usage: /budget <amount-usd> | off");
730
- return true;
731
- }
732
- budgetUsd = n;
733
- // Reset the warning flag so the user gets a fresh 80% notice
734
- // against the new limit even if they're already over it.
735
- budgetWarned = false;
736
- void savePreferences({ budgetUsd: n });
737
- info(`budget: $${n.toFixed(2)} (warn at 80%, abort the next turn at 100%) (saved)`);
738
- return true;
739
- }
740
- case "copy": {
741
- // Copy the most recent assistant response to the OS clipboard. The
742
- // last *assistant* (not tool, not system, not user) is what the
743
- // user usually wants — the actual answer text.
744
- const state = getState();
745
- let target;
746
- for (let i = state.history.length - 1; i >= 0; i--) {
747
- const m = state.history[i];
748
- if (m.role === "assistant" && m.content) {
749
- target = m;
750
- break;
751
- }
752
- }
753
- if (!target) {
754
- info("no assistant message to copy");
755
- return true;
756
- }
757
- try {
758
- await copyToClipboard(target.content);
759
- info(`copied ${target.content.length} chars to clipboard`);
760
- }
761
- catch (e) {
762
- info(`error: ${e.message}`);
763
- }
764
- return true;
765
- }
766
- case "model":
767
- if (!arg) {
768
- info(`current model: ${model}`);
769
- }
770
- else if (!AVAILABLE_MODELS.includes(arg)) {
771
- info(`error: unknown model: ${arg} (available: ${AVAILABLE_MODELS.join(", ")})`);
772
- }
773
- else {
774
- model = arg;
775
- syncStatus();
776
- info(`model → ${model}`);
777
- await persist();
778
- }
779
- return true;
780
- case "yolo":
781
- toolCtx.yolo = !toolCtx.yolo;
782
- setState({ yolo: toolCtx.yolo });
783
- // Persist so the next launch starts the same way. Saving `false`
784
- // explicitly (rather than deleting the key) means a user who
785
- // *turned* yolo off doesn't get re-prompted next time if we ever
786
- // add a "default-on" path; today both are equivalent.
787
- void savePreferences({ yolo: toolCtx.yolo });
788
- info(`yolo: ${toolCtx.yolo} (saved to ${preferencesPath()})`);
789
- return true;
790
- case "reasoning": {
791
- const cur = getState().reasoning;
792
- const next = arg === "on" ? true : arg === "off" ? false : !cur;
793
- setState({ reasoning: next });
794
- void savePreferences({ reasoning: next });
795
- info(`reasoning: ${next ? "on" : "off"} (saved)`);
796
- return true;
797
- }
798
- case "queue": {
799
- const sub = arg.trim().toLowerCase();
800
- if (sub === "clear" || sub === "drop") {
801
- const n = promptQueue.length;
802
- promptQueue.length = 0;
803
- syncStatus();
804
- info(`cleared ${n} queued prompt(s)`);
805
- }
806
- else if (promptQueue.length === 0) {
807
- info("queue is empty");
808
- }
809
- else {
810
- info(promptQueue
811
- .map((p, i) => `${String(i + 1).padStart(2, " ")}. ${p}`)
812
- .join("\n"));
813
- }
814
- return true;
815
- }
816
- case "lang": {
817
- const text = arg.trim();
818
- if (!text) {
819
- info(`language: ${session.language ? `"${session.language}"` : "off (any language)"}`);
820
- }
821
- else if (text === "off" || text === "default" || text === "any") {
822
- delete session.language;
823
- setState({ language: undefined });
824
- await persist();
825
- info("language directive cleared");
826
- }
827
- else {
828
- session.language = text;
829
- setState({ language: text });
830
- await persist();
831
- info(`language → "${text}" (replies will be exclusively in this language)`);
832
- }
833
- return true;
834
- }
835
- case "auto-continue": {
836
- const t = arg.trim();
837
- if (!t) {
838
- const n = getState().autoContinue;
839
- info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
840
- }
841
- else if (t === "off" || t === "0" || t === "false") {
842
- setState({ autoContinue: 0 });
843
- // Saved as 0 (or absent) — clear via savePreferences with null
844
- // so the key is removed from the file when value is "off".
845
- void savePreferences({ autoContinue: null });
846
- info("auto-continue: off (saved)");
847
- }
848
- else {
849
- const n = parseInt(t, 10);
850
- if (!Number.isFinite(n) || n < 0) {
851
- info("error: usage: /auto-continue [N|off]");
852
- }
853
- else {
854
- setState({ autoContinue: n });
855
- void savePreferences({ autoContinue: n });
856
- info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`} (saved)`);
857
- }
858
- }
859
- return true;
860
- }
861
- case "version":
862
- info(formatVersionInfo());
863
- return true;
864
- case "preferences": {
865
- const sub = arg.trim().toLowerCase();
866
- if (sub === "reset") {
867
- await clearPreferences();
868
- info(`preferences file deleted (${preferencesPath()}). Current session keeps its in-memory settings; next launch starts with defaults.`);
869
- return true;
870
- }
871
- if (sub && sub !== "show") {
872
- info("error: usage: /preferences [show|reset]");
873
- return true;
874
- }
875
- const saved = await readPreferences();
876
- const lines = [`preferences: ${preferencesPath()}`];
877
- const keys = [
878
- "yolo",
879
- "reasoning",
880
- "autoContinue",
881
- "budgetUsd",
882
- ];
883
- const present = keys.filter((k) => saved[k] !== undefined);
884
- if (present.length === 0) {
885
- lines.push(" (no preferences saved; defaults apply)");
886
- }
887
- else {
888
- for (const k of present) {
889
- lines.push(` ${k}: ${JSON.stringify(saved[k])}`);
890
- }
891
- }
892
- info(lines.join("\n"));
893
- return true;
894
- }
895
- case "mcp": {
896
- // Status / inspection only for now — connections are established
897
- // at boot. Future: subcommands for reconnect, disable, etc.
898
- if (mcpConnections.length === 0) {
899
- info([
900
- "No MCP servers connected.",
901
- "",
902
- "To wire one in, add an `mcp.servers` block to your config:",
903
- ` ${configPath()}`,
904
- "",
905
- "Example (Tavily remote MCP):",
906
- ' "mcp": {',
907
- ' "servers": {',
908
- ' "tavily": {',
909
- ' "url": "https://mcp.tavily.com/mcp/",',
910
- ' "headers": { "Authorization": "Bearer ${TAVILY_API_KEY}" }',
911
- " }",
912
- " }",
913
- " }",
914
- "",
915
- "Restart dsc after editing — connections are made at boot.",
916
- ].join("\n"));
917
- return true;
918
- }
919
- const lines = [];
920
- for (const c of mcpConnections) {
921
- lines.push(`── ${c.name} ──`);
922
- for (const t of c.tools) {
923
- // t.function.name is namespaced (mcp_<server>_<tool>); strip
924
- // the prefix for readability.
925
- const short = t.function.name.replace(/^mcp_[^_]+_/, "");
926
- lines.push(` ${short}: ${t.function.description ?? ""}`);
927
- }
928
- }
929
- info(lines.join("\n"));
930
- return true;
931
- }
932
- case "instructions": {
933
- // Show every overlay the agent currently sees, in the order they
934
- // appear in the system prompt. Resolved fresh per call so edits
935
- // since launch are reflected without restarting dsc.
936
- const overlays = loadInstructions(cwd);
937
- if (overlays.length === 0) {
938
- info([
939
- "No instruction overlays found. dsc looks for:",
940
- " ~/.config/dsc/instructions.md (user-global)",
941
- " AGENTS.md (project, walked up from cwd)",
942
- " .dsc/instructions.md (project, dsc-specific)",
943
- "Create any of these to teach the agent project conventions.",
944
- ].join("\n"));
945
- return true;
946
- }
947
- const sections = overlays.map((ov) => {
948
- const label = ov.kind === "user"
949
- ? "user"
950
- : ov.kind === "agents"
951
- ? "AGENTS.md"
952
- : ".dsc/instructions.md";
953
- const lines = ov.content.split("\n");
954
- const head = lines.slice(0, 20);
955
- const tail = lines.length > 20
956
- ? `\n… (${lines.length - 20} more lines)`
957
- : "";
958
- return `── ${label} @ ${ov.path} ──\n${head.join("\n")}${tail}`;
959
- });
960
- info(sections.join("\n\n"));
961
- return true;
962
- }
963
- case "update": {
964
- // Two-phase: check first so we don't run npm install when we're
965
- // already current. Force-fetches the latest (ignores the 24h cache
966
- // because the user explicitly asked) and reports either way.
967
- info("checking npm for a newer version…");
968
- try {
969
- const check = await checkForUpdate({ force: true });
970
- if (!check.newerAvailable) {
971
- info(`up to date (${check.current})`);
972
- return true;
973
- }
974
- info(`installing ${check.latest} (currently ${check.current}) via npm…`);
975
- let r = await runUpdate();
976
- // ETARGET = npm's registry has the new version's metadata but
977
- // the tarball hasn't propagated to all CDN edges yet (common
978
- // for the first minute or two after `npm publish`). Wait
979
- // briefly and retry once before surfacing as a failure.
980
- if (!r.ok && /ETARGET/i.test(r.output)) {
981
- info("tarball not on all CDN edges yet (ETARGET); retrying in 8s…");
982
- await new Promise((res) => setTimeout(res, 8000));
983
- r = await runUpdate();
984
- }
985
- // Auto-recover from EACCES on Linux/macOS: offer to set
986
- // npm's prefix under ~/.local so the install doesn't need
987
- // sudo, optionally append the PATH export to the user's
988
- // shell rc, then retry. The user approves each step.
989
- const isPermError = /EACCES|EPERM|permission/.test(r.output);
990
- if (!r.ok &&
991
- isPermError &&
992
- process.platform !== "win32") {
993
- const ans = await approval.confirm({
994
- title: "Permission error installing dsc",
995
- body: `npm install -g hit a permission error (likely because npm's prefix is system-owned).` +
996
- `\n\nDurable fix: set npm's global prefix to ~/.local. After this, all` +
997
- `\n\`npm install -g\` and \`/update\` runs work without sudo.` +
998
- `\n\nDsc will:` +
999
- `\n 1. mkdir -p ~/.local/{bin,lib}` +
1000
- `\n 2. npm config set prefix ~/.local` +
1001
- `\n 3. retry the install`,
1002
- question: "Configure user prefix and retry? [y]es / [n]o (Esc rejects) ",
1003
- });
1004
- if (ans !== "no") {
1005
- try {
1006
- const dir = await setUserNpmPrefix();
1007
- info(`set npm prefix to ${dir}; retrying install…`);
1008
- r = await runUpdate();
1009
- if (r.ok && !localBinOnPath()) {
1010
- const rc = shellRcPath();
1011
- if (rc) {
1012
- const pathAns = await approval.confirm({
1013
- title: "Add ~/.local/bin to PATH",
1014
- body: `dsc now lives at ~/.local/bin/dsc but that directory isn't on your PATH.` +
1015
- `\n\nDsc can append the export line to:` +
1016
- `\n ${rc}` +
1017
- `\n\nYou'll need to open a new terminal (or \`source\` the file) for it to take effect.`,
1018
- question: `Append PATH export to ${rc}? [y]es / [n]o (Esc rejects) `,
1019
- });
1020
- if (pathAns !== "no") {
1021
- try {
1022
- const edited = await addLocalBinToShellRc();
1023
- if (edited) {
1024
- info(`appended PATH export to ${edited}. Open a new terminal or run:` +
1025
- `\n source ${edited}`);
1026
- }
1027
- else {
1028
- info(`(${rc} already has ~/.local/bin in PATH; no edit needed)`);
1029
- }
1030
- }
1031
- catch (e) {
1032
- info(`error appending to ${rc}: ${e.message}`);
1033
- }
1034
- }
1035
- else {
1036
- info(`Add this line to your shell rc manually:\n export PATH="$HOME/.local/bin:$PATH"`);
1037
- }
1038
- }
1039
- else {
1040
- info(`Couldn't autodetect your shell rc. Add this line manually:` +
1041
- `\n export PATH="$HOME/.local/bin:$PATH"`);
1042
- }
1043
- }
1044
- }
1045
- catch (e) {
1046
- info(`error: prefix setup failed: ${e.message}`);
1047
- }
1048
- }
1049
- }
1050
- if (!r.ok) {
1051
- // Either non-permission failure, or the user declined the
1052
- // auto-recovery, or the retry itself failed. Surface npm's
1053
- // own output verbatim — it usually points at the cause.
1054
- const tail = isPermError && process.platform !== "win32"
1055
- ? `\n\nQuick one-off if you'd rather: sudo npm install -g @este.systems/dsc@latest`
1056
- : "";
1057
- info(`error: update failed\n${r.output.slice(-1500)}${tail}`);
1058
- return true;
1059
- }
1060
- info(`installed ${check.latest}. Exit and re-run \`dsc\` to pick up the new version.`);
1061
- }
1062
- catch (e) {
1063
- info(`error: update failed: ${e.message}`);
1064
- }
1065
- return true;
1066
- }
1067
- case "api-key": {
1068
- const text = arg.trim();
1069
- if (!text) {
1070
- // No arg: show where the key is configured (don't print the key
1071
- // itself; that's not the kind of thing /command output should
1072
- // splash to scrollback).
1073
- const src = apiKeySource();
1074
- if (src === "env") {
1075
- info("api key: set via $DEEPSEEK_API_KEY (env)");
1076
- }
1077
- else if (src === "file") {
1078
- info(`api key: stored in ${configPath()}`);
1079
- }
1080
- else {
1081
- info(`api key: not set\nGet one at https://platform.deepseek.com/api_keys, then run /api-key <key> (or export DEEPSEEK_API_KEY in your shell).`);
1082
- }
1083
- return true;
1084
- }
1085
- try {
1086
- const written = await saveApiKey(text);
1087
- info(`api key saved to ${written}`);
1088
- }
1089
- catch (e) {
1090
- info(`error: ${e.message}`);
1091
- }
1092
- return true;
1093
- }
1094
- case "search": {
1095
- // /search -> full status
1096
- // /search use <provider> -> set active provider in config
1097
- // /search key <provider> -> show that provider's status + URL
1098
- // /search key <provider> <key> -> save it to config
1099
- const SIGNUP = {
1100
- brave: "https://api-dashboard.search.brave.com/app/keys",
1101
- tavily: "https://app.tavily.com/",
1102
- };
1103
- const ENV_KEY = {
1104
- brave: "BRAVE_API_KEY",
1105
- tavily: "TAVILY_API_KEY",
1106
- };
1107
- const keySourceFor = (p) => {
1108
- if (process.env[ENV_KEY[p]])
1109
- return "env";
1110
- return getProviderKey(p) ? "file" : null;
1111
- };
1112
- const keyStatusLine = (p) => {
1113
- const src = keySourceFor(p);
1114
- const status = src === "env"
1115
- ? `set via $${ENV_KEY[p]}`
1116
- : src === "file"
1117
- ? "stored in config"
1118
- : "not set";
1119
- return ` ${p}: ${status} — ${SIGNUP[p]}`;
1120
- };
1121
- const tokens = arg.trim().split(/\s+/).filter(Boolean);
1122
- const sub = tokens[0];
1123
- // No subcommand: full status.
1124
- if (!sub) {
1125
- const active = getProvider();
1126
- const activeSrc = process.env.DSC_SEARCH_PROVIDER
1127
- ? "set via $DSC_SEARCH_PROVIDER"
1128
- : "from config";
1129
- info([
1130
- `active provider: ${active} (${activeSrc})`,
1131
- "",
1132
- "keys:",
1133
- keyStatusLine("brave"),
1134
- keyStatusLine("tavily"),
1135
- " ddg: no key needed (DuckDuckGo HTML scrape)",
1136
- "",
1137
- "Subcommands:",
1138
- " /search use <brave|tavily|ddg> switch active provider",
1139
- " /search key <provider> [key] show or save api key",
1140
- ].join("\n"));
1141
- return true;
1142
- }
1143
- if (sub === "use") {
1144
- const name = tokens[1];
1145
- if (!name || (name !== "brave" && name !== "tavily" && name !== "ddg")) {
1146
- info("error: usage: /search use <brave|tavily|ddg>");
1147
- return true;
1148
- }
1149
- try {
1150
- const written = await saveSearchProvider(name);
1151
- // Warn the user if they just selected a provider whose key
1152
- // isn't configured — they'll otherwise hit a 401 on the next
1153
- // web_search and wonder why.
1154
- const needsKey = name === "brave" || name === "tavily";
1155
- const haveKey = needsKey ? !!keySourceFor(name) : true;
1156
- const warn = needsKey && !haveKey
1157
- ? `\n(warning) ${name} has no key set. Run: /search key ${name} <key>`
1158
- : "";
1159
- info(`active search provider → ${name} (saved to ${written})${warn}`);
1160
- }
1161
- catch (e) {
1162
- info(`error: ${e.message}`);
1163
- }
1164
- return true;
1165
- }
1166
- if (sub === "key") {
1167
- const name = tokens[1];
1168
- const keyValue = tokens.slice(2).join(" ");
1169
- if (!name || (name !== "brave" && name !== "tavily")) {
1170
- info("error: usage: /search key <brave|tavily> [key]");
1171
- return true;
1172
- }
1173
- if (!keyValue) {
1174
- info(`${name}: ${keySourceFor(name) ?? "not set"}\nsignup: ${SIGNUP[name]}`);
1175
- return true;
1176
- }
1177
- try {
1178
- const written = await saveSearchKey(name, keyValue);
1179
- info(`${name} key saved to ${written}`);
1180
- }
1181
- catch (e) {
1182
- info(`error: ${e.message}`);
1183
- }
1184
- return true;
1185
- }
1186
- info(`error: unknown subcommand '${sub}' (expected: use | key, or no arg for status)`);
1187
- return true;
1188
- }
1189
- case "audit": {
1190
- const sub = arg.trim();
1191
- if (!sub || sub === "path") {
1192
- info(audit.auditLogPath());
1193
- }
1194
- else if (sub.startsWith("show")) {
1195
- const nRaw = sub.replace(/^show\s*/, "").trim();
1196
- const limit = (() => {
1197
- const n = nRaw ? parseInt(nRaw, 10) : NaN;
1198
- return Number.isFinite(n) && n > 0 ? n : 10;
1199
- })();
1200
- try {
1201
- const text = await fsp.readFile(audit.auditLogPath(), "utf8");
1202
- const lines = text.split("\n").filter((l) => l.length > 0).slice(-limit);
1203
- info(lines.length ? lines.join("\n") : "(empty)");
1204
- }
1205
- catch {
1206
- info("(no audit log yet)");
1207
- }
1208
- }
1209
- else {
1210
- info("error: usage: /audit | /audit path | /audit show [N]");
1211
- }
1212
- return true;
1213
- }
1214
- case "transcript": {
1215
- const archived = session.archivedMessages ?? [];
1216
- if (archived.length === 0 && messages.length === 0) {
1217
- info("(no messages)");
1218
- return true;
1219
- }
1220
- const lines = [];
1221
- const renderMsg = (m, isArchived) => {
1222
- const tag = isArchived ? "archived " : "";
1223
- lines.push(`\n${tag}${m.role}`);
1224
- if (m.tool_calls && m.tool_calls.length) {
1225
- for (const tc of m.tool_calls) {
1226
- lines.push(` → ${tc.function.name}(${truncate(tc.function.arguments, 200)})`);
1227
- }
1228
- }
1229
- if (m.tool_call_id)
1230
- lines.push(` ← tool_call_id: ${m.tool_call_id}`);
1231
- if (typeof m.content === "string" && m.content)
1232
- lines.push(m.content);
1233
- };
1234
- if (archived.length) {
1235
- lines.push(`── archived (${archived.length} messages)`);
1236
- for (const m of archived)
1237
- renderMsg(m, true);
1238
- }
1239
- if (messages.length) {
1240
- lines.push(`── active (${messages.length} messages)`);
1241
- for (const m of messages)
1242
- renderMsg(m, false);
1243
- }
1244
- info(lines.join("\n"));
1245
- return true;
1246
- }
1247
- case "compact": {
1248
- const keepRaw = arg ? parseInt(arg, 10) : NaN;
1249
- const keep = Number.isFinite(keepRaw) ? Math.max(0, keepRaw) : 4;
1250
- await runCompaction(keep, false);
1251
- return true;
1252
- }
1253
- case "export": {
1254
- // `/export [path]` — writes the current session JSON. Relative
1255
- // paths resolve against the launch cwd, not against the session's
1256
- // recorded cwd, so it lands where the user actually is. Default
1257
- // target is the cwd, named after /save'd name (or id) and date.
1258
- const dest = arg.trim() || ".";
1259
- const absDest = path.isAbsolute(dest) ? dest : path.resolve(cwd, dest);
1260
- try {
1261
- await persist(); // flush any pending writes before export
1262
- const written = await history.exportSession(session.id, absDest);
1263
- info(`exported to ${written}`);
1264
- }
1265
- catch (e) {
1266
- info(`error: export failed: ${e.message}`);
1267
- }
1268
- return true;
1269
- }
1270
- case "import": {
1271
- // `/import <path>` — reads a session JSON, rebinds its cwd to the
1272
- // current directory so auto-resume picks it up here, and loads it
1273
- // as the active session immediately. Keep the previous cwd with
1274
- // `--keep-cwd` (positional flag, kept simple).
1275
- const tokens = arg.trim().split(/\s+/).filter(Boolean);
1276
- const keepCwd = tokens.includes("--keep-cwd");
1277
- const file = tokens.find((t) => !t.startsWith("--"));
1278
- if (!file) {
1279
- info("error: usage: /import <path> [--keep-cwd]");
1280
- return true;
1281
- }
1282
- const absFile = path.isAbsolute(file) ? file : path.resolve(cwd, file);
1283
- try {
1284
- const loaded = await history.importSession(absFile, {
1285
- rebindCwd: keepCwd ? undefined : cwd,
580
+ if (opts.rebuildView) {
581
+ // Rebuild the visible history from the (resumed/imported) session.
582
+ const restored = [];
583
+ for (const m of messages) {
584
+ if (m.role === "system")
585
+ continue;
586
+ restored.push({
587
+ id: `r-${restored.length}`,
588
+ role: m.role,
589
+ content: typeof m.content === "string" ? m.content : "",
590
+ tool_call_id: m.tool_call_id,
1286
591
  });
1287
- session = loaded;
1288
- messages = session.messages;
1289
- stats = session.stats;
1290
- model = session.model;
1291
- toolCtx.filesTouched = stats.files_touched;
1292
- toolCtx.sessionId = session.id;
1293
- // Refill the visible history so the user sees the imported
1294
- // conversation right away (same logic the /resume path uses).
1295
- const restored = [];
1296
- for (const m of messages) {
1297
- if (m.role === "system")
1298
- continue;
1299
- restored.push({
1300
- id: `r-${restored.length}`,
1301
- role: m.role,
1302
- content: typeof m.content === "string" ? m.content : "",
1303
- tool_call_id: m.tool_call_id,
1304
- });
1305
- }
1306
- setState({ history: restored, current: null, model });
1307
- syncStatus();
1308
- const userTurns = messages.filter((m) => m.role === "user").length;
1309
- const cwdNote = keepCwd ? "" : " (cwd rebound to here)";
1310
- info(`imported ${session.id} (${userTurns} turns, model ${model})${cwdNote}`);
1311
592
  }
1312
- catch (e) {
1313
- info(`error: import failed: ${e.message}`);
1314
- }
1315
- return true;
593
+ setState({ history: restored, current: null, model });
1316
594
  }
1317
- case "edit": {
1318
- // ink owns the terminal unmount before spawning $EDITOR so the
1319
- // editor gets a clean screen. We also clear `history` before the
1320
- // unmount, otherwise the freshly-rendered <Static> after remount
1321
- // would emit all prior turns to scrollback a second time. The
1322
- // already-printed scrollback above stays untouched.
1323
- const initial = arg ? arg + "\n" : "";
1324
- setState({ history: [] });
1325
- inkInstance?.unmount();
1326
- const draft = openEditor(initial);
1327
- mountApp();
1328
- if (draft === null) {
1329
- info("error: editor failed");
1330
- }
1331
- else if (!draft.trim()) {
1332
- info("(empty draft, not sent)");
1333
- }
1334
- else {
1335
- handleSubmit(draft);
1336
- }
1337
- return true;
595
+ else {
596
+ setState({ history: [], current: null, model });
1338
597
  }
1339
- default:
1340
- info(`error: unknown command: /${cmd}`);
1341
- return true;
1342
- }
598
+ syncStatus();
599
+ },
600
+ runEditor: (initial) => {
601
+ // ink owns the terminal — unmount before spawning $EDITOR so the editor
602
+ // gets a clean screen. Clear `history` first so the post-remount
603
+ // <Static> doesn't re-emit prior turns to scrollback. Already-printed
604
+ // scrollback above stays untouched.
605
+ setState({ history: [] });
606
+ inkInstance?.unmount();
607
+ const draft = openEditor(initial);
608
+ mountApp();
609
+ return draft;
610
+ },
611
+ exit: () => {
612
+ clearInterval(timerId);
613
+ process.exit(0);
614
+ },
1343
615
  };
616
+ // Returns true if the input was a recognized slash command and got handled
617
+ // (or rejected) — caller should not forward it to the agent. Returns false
618
+ // for non-slash input and unknown commands (caller decides what to do).
619
+ const handleSlash = (line) => dispatchSlash(line, slashContext);
1344
620
  const handleSubmit = (text) => {
1345
621
  // Persist every submitted line — slash commands included, since
1346
622
  // recalling "/resume 3" via arrow-up is useful.
@@ -1577,19 +853,6 @@ function formatTaskLabel(name, argsJson) {
1577
853
  return name;
1578
854
  return `${name}: ${truncate(primary, 60)}`;
1579
855
  }
1580
- function formatRelative(ts) {
1581
- const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
1582
- if (s < 60)
1583
- return `${s}s ago`;
1584
- const m = Math.floor(s / 60);
1585
- if (m < 60)
1586
- return `${m}m ago`;
1587
- const h = Math.floor(m / 60);
1588
- if (h < 24)
1589
- return `${h}h ago`;
1590
- const d = Math.floor(h / 24);
1591
- return `${d}d ago`;
1592
- }
1593
856
  main().catch((e) => {
1594
857
  process.stderr.write(`fatal: ${e.message ?? e}\n`);
1595
858
  process.exit(1);