@este.systems/dsc 1.1.1 → 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/CHANGELOG.md +18 -1
- package/README.md +53 -4
- package/dist/agent.js +3 -2
- package/dist/agent.js.map +1 -1
- package/dist/api.js +508 -14
- package/dist/api.js.map +1 -1
- package/dist/history.js +15 -0
- package/dist/history.js.map +1 -1
- package/dist/prompt.js +2 -0
- package/dist/prompt.js.map +1 -1
- package/dist/slash_dispatch.js +742 -0
- package/dist/slash_dispatch.js.map +1 -0
- package/dist/tools.js +184 -2
- package/dist/tools.js.map +1 -1
- package/dist/tui.js +79 -817
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
// Slash-command dispatcher, lifted out of tui.tsx so it's (a) unit-testable
|
|
2
|
+
// and (b) reusable by `dsc serve` over the wire (Phase 2 of the headless
|
|
3
|
+
// plan). The dispatcher owns all parsing, routing, and user-facing message
|
|
4
|
+
// formatting; it reaches the front-end only through the injected SlashContext
|
|
5
|
+
// — `emit` (the output sink, was tui's `info()`), a handful of getters for
|
|
6
|
+
// session-level state, and a few action callbacks for things that are
|
|
7
|
+
// genuinely front-end-specific (swapping the active session + rebuilding the
|
|
8
|
+
// view, spawning $EDITOR, exiting). Everything else (preferences, history,
|
|
9
|
+
// search/api-key config, update, audit, instructions) is plain module calls
|
|
10
|
+
// shared by every front-end.
|
|
11
|
+
import { availableModels, computeCostUsd, configPath, isKnownModel, modelAvailable, modelSpec, PROVIDER_KEY_INFO, providerKeySource, saveProviderKey, saveSearchKey, saveSearchProvider, } from "./api.js";
|
|
12
|
+
import { formatCost } from "./agent.js";
|
|
13
|
+
import { getProvider, getProviderKey } from "./search.js";
|
|
14
|
+
import { loadInstructions } from "./instructions.js";
|
|
15
|
+
import { clearPreferences, preferencesPath, readPreferences, savePreferences, } from "./preferences.js";
|
|
16
|
+
import * as history from "./history.js";
|
|
17
|
+
import * as approval from "./approval.js";
|
|
18
|
+
import * as audit from "./audit.js";
|
|
19
|
+
import { getState, setState } from "./store.js";
|
|
20
|
+
import { formatVersionInfo } from "./version.js";
|
|
21
|
+
import { addLocalBinToShellRc, checkForUpdate, localBinOnPath, runUpdate, setUserNpmPrefix, shellRcPath, } from "./update.js";
|
|
22
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
23
|
+
import { formatRelative } from "./history.js";
|
|
24
|
+
import { promises as fsp } from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
const HELP_LINES = [
|
|
27
|
+
"/help show this help",
|
|
28
|
+
"/clear start a new session",
|
|
29
|
+
"/list list sessions for this cwd",
|
|
30
|
+
"/resume [n|name|id] resume a session",
|
|
31
|
+
"/save <name> name the current session",
|
|
32
|
+
"/rename <text> set assistant label for this session",
|
|
33
|
+
"/model [name] show available models or switch (routes to its provider)",
|
|
34
|
+
"/yolo toggle approval mode",
|
|
35
|
+
"/reasoning [on|off] toggle reasoning display",
|
|
36
|
+
"/lang [name|off] force the model to reply in a language",
|
|
37
|
+
"/auto-continue [N|off] auto-grant N extra MAX_TOOL_DEPTH budgets",
|
|
38
|
+
"/cost show token usage and cost",
|
|
39
|
+
"/budget [usd|off] set per-session USD ceiling (warn at 80%, abort at 100%)",
|
|
40
|
+
"/copy copy last assistant response to clipboard",
|
|
41
|
+
"/version show version info",
|
|
42
|
+
"/instructions list active per-project / per-user instruction overlays",
|
|
43
|
+
"/mcp list connected MCP servers and their tools",
|
|
44
|
+
"/preferences [reset] show or clear persisted slash-set settings",
|
|
45
|
+
"/compact [keep] summarize old turns (default keep=4)",
|
|
46
|
+
"/transcript dump full message log",
|
|
47
|
+
"/audit [path|show N] audit log info",
|
|
48
|
+
"/queue [clear] list or clear queued prompts",
|
|
49
|
+
"/export [path] write current session JSON for transfer",
|
|
50
|
+
"/import <path> load session JSON; rebinds cwd here (--keep-cwd to skip)",
|
|
51
|
+
"/api-key [provider] [key] show key status / save a provider's api key",
|
|
52
|
+
"/search [use|key] … show / switch search provider / save brave|tavily key",
|
|
53
|
+
"/update check npm for a newer dsc and install it",
|
|
54
|
+
"/edit [text] open $EDITOR; the saved buffer becomes the next prompt",
|
|
55
|
+
"/exit exit",
|
|
56
|
+
];
|
|
57
|
+
function truncate(s, n) {
|
|
58
|
+
return s.length <= n ? s : s.slice(0, n) + "…";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Dispatch one input line. Returns true when the line was a recognized slash
|
|
62
|
+
* command and got handled (or rejected) — the caller should not forward it to
|
|
63
|
+
* the agent. Returns false for non-slash input and lets the caller decide.
|
|
64
|
+
*/
|
|
65
|
+
export async function dispatchSlash(line, ctx) {
|
|
66
|
+
if (!line.startsWith("/"))
|
|
67
|
+
return false;
|
|
68
|
+
const [cmd, ...rest] = line.slice(1).split(/\s+/);
|
|
69
|
+
const arg = rest.join(" ");
|
|
70
|
+
const { emit } = ctx;
|
|
71
|
+
switch (cmd) {
|
|
72
|
+
case "exit":
|
|
73
|
+
case "quit":
|
|
74
|
+
ctx.exit();
|
|
75
|
+
return true;
|
|
76
|
+
case "help":
|
|
77
|
+
emit(HELP_LINES.join("\n"));
|
|
78
|
+
return true;
|
|
79
|
+
case "clear": {
|
|
80
|
+
const next = history.newSession(ctx.cwd, ctx.getModel());
|
|
81
|
+
ctx.applySession(next, { resetApprovals: true, rebuildView: false });
|
|
82
|
+
emit(`new session started (${next.id})`);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
case "list": {
|
|
86
|
+
const all = await history.listSessions(ctx.cwd);
|
|
87
|
+
if (!all.length) {
|
|
88
|
+
emit(`no sessions for ${ctx.cwd}`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const activeId = ctx.getSession().id;
|
|
92
|
+
emit(all
|
|
93
|
+
.map((s, i) => {
|
|
94
|
+
const here = s.id === activeId ? "* " : " ";
|
|
95
|
+
const label = s.name ? `${s.name} (${s.model})` : s.model;
|
|
96
|
+
return `${here}${String(i + 1).padStart(2, " ")}. ${label} ${formatRelative(s.updated_at)} (${s.message_count} msgs) ${s.first_user_message || "—"}`;
|
|
97
|
+
})
|
|
98
|
+
.join("\n"));
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
case "save":
|
|
103
|
+
if (!arg.trim()) {
|
|
104
|
+
emit("error: usage: /save <name>");
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const session = ctx.getSession();
|
|
108
|
+
session.name = arg.trim();
|
|
109
|
+
await ctx.persist();
|
|
110
|
+
emit(`session saved as "${session.name}" (id ${session.id})`);
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
case "rename": {
|
|
114
|
+
const text = arg.trim();
|
|
115
|
+
const session = ctx.getSession();
|
|
116
|
+
if (!text) {
|
|
117
|
+
emit(`assistant label: "${session.assistantLabel ?? "assistant:"}"`);
|
|
118
|
+
}
|
|
119
|
+
else if (text === "--reset" || text === "default") {
|
|
120
|
+
delete session.assistantLabel;
|
|
121
|
+
setState({ assistantLabel: "assistant:" });
|
|
122
|
+
await ctx.persist();
|
|
123
|
+
emit("assistant label reset to default");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
session.assistantLabel = text;
|
|
127
|
+
setState({ assistantLabel: text });
|
|
128
|
+
await ctx.persist();
|
|
129
|
+
emit(`assistant label → "${text}"`);
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
case "resume": {
|
|
134
|
+
const all = await history.listSessions(ctx.cwd);
|
|
135
|
+
if (!all.length) {
|
|
136
|
+
emit("no sessions to resume");
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
let target = null;
|
|
140
|
+
if (!arg || arg === "last") {
|
|
141
|
+
target = all[0];
|
|
142
|
+
}
|
|
143
|
+
else if (/^\d+$/.test(arg)) {
|
|
144
|
+
target = all[parseInt(arg, 10) - 1] ?? null;
|
|
145
|
+
if (!target)
|
|
146
|
+
emit(`error: no session at index ${arg} (have ${all.length})`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
target = all.find((s) => s.name === arg) ?? all.find((s) => s.id === arg) ?? null;
|
|
150
|
+
if (!target)
|
|
151
|
+
emit(`error: no session with name or id ${arg}`);
|
|
152
|
+
}
|
|
153
|
+
if (target) {
|
|
154
|
+
const loaded = await history.loadSession(target.id);
|
|
155
|
+
if (!loaded) {
|
|
156
|
+
emit(`error: failed to load session ${target.id}`);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
ctx.applySession(loaded, { rebuildView: true });
|
|
160
|
+
const userTurns = loaded.messages.filter((m) => m.role === "user").length;
|
|
161
|
+
emit(`resumed ${loaded.id} (${userTurns} turns, model ${loaded.model})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
case "cost":
|
|
167
|
+
emit(formatCost(ctx.getStats(), ctx.getModel()));
|
|
168
|
+
return true;
|
|
169
|
+
case "budget": {
|
|
170
|
+
const text = arg.trim().toLowerCase();
|
|
171
|
+
const { usd: budgetUsd, warned: budgetWarned } = ctx.getBudget();
|
|
172
|
+
if (!text) {
|
|
173
|
+
const cost = computeCostUsd(ctx.getStats(), ctx.getModel());
|
|
174
|
+
if (budgetUsd === null) {
|
|
175
|
+
emit(`budget: not set (spent: $${cost.toFixed(4)})\n/budget <usd> to set a limit, /budget off to clear`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const pct = budgetUsd > 0 ? Math.round((cost / budgetUsd) * 100) : 0;
|
|
179
|
+
emit(`budget: $${budgetUsd.toFixed(2)} spent: $${cost.toFixed(4)} (${pct}%) warned: ${budgetWarned ? "yes" : "no"}`);
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (text === "off" || text === "none" || text === "0") {
|
|
184
|
+
ctx.setBudget(null, false);
|
|
185
|
+
void savePreferences({ budgetUsd: null });
|
|
186
|
+
emit("budget cleared (saved)");
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
const n = parseFloat(text);
|
|
190
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
191
|
+
emit("error: usage: /budget <amount-usd> | off");
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
// Reset the warning flag so the user gets a fresh 80% notice against the
|
|
195
|
+
// new limit even if they're already over it.
|
|
196
|
+
ctx.setBudget(n, false);
|
|
197
|
+
void savePreferences({ budgetUsd: n });
|
|
198
|
+
emit(`budget: $${n.toFixed(2)} (warn at 80%, abort the next turn at 100%) (saved)`);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
case "copy": {
|
|
202
|
+
const state = getState();
|
|
203
|
+
let target;
|
|
204
|
+
for (let i = state.history.length - 1; i >= 0; i--) {
|
|
205
|
+
const m = state.history[i];
|
|
206
|
+
if (m.role === "assistant" && m.content) {
|
|
207
|
+
target = m;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!target) {
|
|
212
|
+
emit("no assistant message to copy");
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
await copyToClipboard(target.content);
|
|
217
|
+
emit(`copied ${target.content.length} chars to clipboard`);
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
emit(`error: ${e.message}`);
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
case "model":
|
|
225
|
+
if (!arg) {
|
|
226
|
+
emit(`current model: ${ctx.getModel()}\navailable: ${availableModels().join(", ")}`);
|
|
227
|
+
}
|
|
228
|
+
else if (!isKnownModel(arg)) {
|
|
229
|
+
emit(`error: unknown model: ${arg} (available: ${availableModels().join(", ")})`);
|
|
230
|
+
}
|
|
231
|
+
else if (!modelAvailable(arg)) {
|
|
232
|
+
// Registered but its provider has no key — point the user at /api-key.
|
|
233
|
+
const prov = modelSpec(arg).provider;
|
|
234
|
+
const info = PROVIDER_KEY_INFO[prov];
|
|
235
|
+
emit(`error: ${arg} needs the ${info?.label ?? prov} API key. ` +
|
|
236
|
+
`Set $${info?.envVar ?? "<KEY>"} or run /api-key ${prov} <key>.`);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
ctx.setModel(arg);
|
|
240
|
+
ctx.syncStatus();
|
|
241
|
+
emit(`model → ${arg}`);
|
|
242
|
+
await ctx.persist();
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
case "yolo": {
|
|
246
|
+
const toolCtx = ctx.getToolCtx();
|
|
247
|
+
toolCtx.yolo = !toolCtx.yolo;
|
|
248
|
+
setState({ yolo: toolCtx.yolo });
|
|
249
|
+
void savePreferences({ yolo: toolCtx.yolo });
|
|
250
|
+
emit(`yolo: ${toolCtx.yolo} (saved to ${preferencesPath()})`);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
case "reasoning": {
|
|
254
|
+
const cur = getState().reasoning;
|
|
255
|
+
const next = arg === "on" ? true : arg === "off" ? false : !cur;
|
|
256
|
+
setState({ reasoning: next });
|
|
257
|
+
void savePreferences({ reasoning: next });
|
|
258
|
+
emit(`reasoning: ${next ? "on" : "off"} (saved)`);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
case "queue": {
|
|
262
|
+
const sub = arg.trim().toLowerCase();
|
|
263
|
+
const queue = ctx.getQueue();
|
|
264
|
+
if (sub === "clear" || sub === "drop") {
|
|
265
|
+
const n = queue.length;
|
|
266
|
+
queue.length = 0;
|
|
267
|
+
ctx.syncStatus();
|
|
268
|
+
emit(`cleared ${n} queued prompt(s)`);
|
|
269
|
+
}
|
|
270
|
+
else if (queue.length === 0) {
|
|
271
|
+
emit("queue is empty");
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
emit(queue.map((p, i) => `${String(i + 1).padStart(2, " ")}. ${p}`).join("\n"));
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
case "lang": {
|
|
279
|
+
const text = arg.trim();
|
|
280
|
+
const session = ctx.getSession();
|
|
281
|
+
if (!text) {
|
|
282
|
+
emit(`language: ${session.language ? `"${session.language}"` : "off (any language)"}`);
|
|
283
|
+
}
|
|
284
|
+
else if (text === "off" || text === "default" || text === "any") {
|
|
285
|
+
delete session.language;
|
|
286
|
+
setState({ language: undefined });
|
|
287
|
+
await ctx.persist();
|
|
288
|
+
emit("language directive cleared");
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
session.language = text;
|
|
292
|
+
setState({ language: text });
|
|
293
|
+
await ctx.persist();
|
|
294
|
+
emit(`language → "${text}" (replies will be exclusively in this language)`);
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
case "auto-continue": {
|
|
299
|
+
const t = arg.trim();
|
|
300
|
+
if (!t) {
|
|
301
|
+
const n = getState().autoContinue;
|
|
302
|
+
emit(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
|
|
303
|
+
}
|
|
304
|
+
else if (t === "off" || t === "0" || t === "false") {
|
|
305
|
+
setState({ autoContinue: 0 });
|
|
306
|
+
void savePreferences({ autoContinue: null });
|
|
307
|
+
emit("auto-continue: off (saved)");
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const n = parseInt(t, 10);
|
|
311
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
312
|
+
emit("error: usage: /auto-continue [N|off]");
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
setState({ autoContinue: n });
|
|
316
|
+
void savePreferences({ autoContinue: n });
|
|
317
|
+
emit(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`} (saved)`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
case "version":
|
|
323
|
+
emit(formatVersionInfo());
|
|
324
|
+
return true;
|
|
325
|
+
case "preferences": {
|
|
326
|
+
const sub = arg.trim().toLowerCase();
|
|
327
|
+
if (sub === "reset") {
|
|
328
|
+
await clearPreferences();
|
|
329
|
+
emit(`preferences file deleted (${preferencesPath()}). Current session keeps its in-memory settings; next launch starts with defaults.`);
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
if (sub && sub !== "show") {
|
|
333
|
+
emit("error: usage: /preferences [show|reset]");
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
const saved = await readPreferences();
|
|
337
|
+
const lines = [`preferences: ${preferencesPath()}`];
|
|
338
|
+
const keys = ["yolo", "reasoning", "autoContinue", "budgetUsd"];
|
|
339
|
+
const present = keys.filter((k) => saved[k] !== undefined);
|
|
340
|
+
if (present.length === 0) {
|
|
341
|
+
lines.push(" (no preferences saved; defaults apply)");
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
for (const k of present) {
|
|
345
|
+
lines.push(` ${k}: ${JSON.stringify(saved[k])}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
emit(lines.join("\n"));
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
case "mcp": {
|
|
352
|
+
const conns = ctx.getMcpConnections();
|
|
353
|
+
if (conns.length === 0) {
|
|
354
|
+
emit([
|
|
355
|
+
"No MCP servers connected.",
|
|
356
|
+
"",
|
|
357
|
+
"To wire one in, add an `mcp.servers` block to your config:",
|
|
358
|
+
` ${configPath()}`,
|
|
359
|
+
"",
|
|
360
|
+
"Example (Tavily remote MCP):",
|
|
361
|
+
' "mcp": {',
|
|
362
|
+
' "servers": {',
|
|
363
|
+
' "tavily": {',
|
|
364
|
+
' "url": "https://mcp.tavily.com/mcp/",',
|
|
365
|
+
' "headers": { "Authorization": "Bearer ${TAVILY_API_KEY}" }',
|
|
366
|
+
" }",
|
|
367
|
+
" }",
|
|
368
|
+
" }",
|
|
369
|
+
"",
|
|
370
|
+
"Restart dsc after editing — connections are made at boot.",
|
|
371
|
+
].join("\n"));
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
const lines = [];
|
|
375
|
+
for (const c of conns) {
|
|
376
|
+
lines.push(`── ${c.name} ──`);
|
|
377
|
+
for (const t of c.tools) {
|
|
378
|
+
const short = t.function.name.replace(/^mcp_[^_]+_/, "");
|
|
379
|
+
lines.push(` ${short}: ${t.function.description ?? ""}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
emit(lines.join("\n"));
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
case "instructions": {
|
|
386
|
+
const overlays = loadInstructions(ctx.cwd);
|
|
387
|
+
if (overlays.length === 0) {
|
|
388
|
+
emit([
|
|
389
|
+
"No instruction overlays found. dsc looks for:",
|
|
390
|
+
" ~/.config/dsc/instructions.md (user-global)",
|
|
391
|
+
" AGENTS.md (project, walked up from cwd)",
|
|
392
|
+
" .dsc/instructions.md (project, dsc-specific)",
|
|
393
|
+
"Create any of these to teach the agent project conventions.",
|
|
394
|
+
].join("\n"));
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
const sections = overlays.map((ov) => {
|
|
398
|
+
const label = ov.kind === "user" ? "user" : ov.kind === "agents" ? "AGENTS.md" : ".dsc/instructions.md";
|
|
399
|
+
const lines = ov.content.split("\n");
|
|
400
|
+
const head = lines.slice(0, 20);
|
|
401
|
+
const tail = lines.length > 20 ? `\n… (${lines.length - 20} more lines)` : "";
|
|
402
|
+
return `── ${label} @ ${ov.path} ──\n${head.join("\n")}${tail}`;
|
|
403
|
+
});
|
|
404
|
+
emit(sections.join("\n\n"));
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
case "update": {
|
|
408
|
+
emit("checking npm for a newer version…");
|
|
409
|
+
try {
|
|
410
|
+
const check = await checkForUpdate({ force: true });
|
|
411
|
+
if (!check.newerAvailable) {
|
|
412
|
+
emit(`up to date (${check.current})`);
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
emit(`installing ${check.latest} (currently ${check.current}) via npm…`);
|
|
416
|
+
let r = await runUpdate();
|
|
417
|
+
if (!r.ok && /ETARGET/i.test(r.output)) {
|
|
418
|
+
emit("tarball not on all CDN edges yet (ETARGET); retrying in 8s…");
|
|
419
|
+
await new Promise((res) => setTimeout(res, 8000));
|
|
420
|
+
r = await runUpdate();
|
|
421
|
+
}
|
|
422
|
+
const isPermError = /EACCES|EPERM|permission/.test(r.output);
|
|
423
|
+
if (!r.ok && isPermError && process.platform !== "win32") {
|
|
424
|
+
const ans = await approval.confirm({
|
|
425
|
+
title: "Permission error installing dsc",
|
|
426
|
+
body: `npm install -g hit a permission error (likely because npm's prefix is system-owned).` +
|
|
427
|
+
`\n\nDurable fix: set npm's global prefix to ~/.local. After this, all` +
|
|
428
|
+
`\n\`npm install -g\` and \`/update\` runs work without sudo.` +
|
|
429
|
+
`\n\nDsc will:` +
|
|
430
|
+
`\n 1. mkdir -p ~/.local/{bin,lib}` +
|
|
431
|
+
`\n 2. npm config set prefix ~/.local` +
|
|
432
|
+
`\n 3. retry the install`,
|
|
433
|
+
question: "Configure user prefix and retry? [y]es / [n]o (Esc rejects) ",
|
|
434
|
+
});
|
|
435
|
+
if (ans !== "no") {
|
|
436
|
+
try {
|
|
437
|
+
const dir = await setUserNpmPrefix();
|
|
438
|
+
emit(`set npm prefix to ${dir}; retrying install…`);
|
|
439
|
+
r = await runUpdate();
|
|
440
|
+
if (r.ok && !localBinOnPath()) {
|
|
441
|
+
const rc = shellRcPath();
|
|
442
|
+
if (rc) {
|
|
443
|
+
const pathAns = await approval.confirm({
|
|
444
|
+
title: "Add ~/.local/bin to PATH",
|
|
445
|
+
body: `dsc now lives at ~/.local/bin/dsc but that directory isn't on your PATH.` +
|
|
446
|
+
`\n\nDsc can append the export line to:` +
|
|
447
|
+
`\n ${rc}` +
|
|
448
|
+
`\n\nYou'll need to open a new terminal (or \`source\` the file) for it to take effect.`,
|
|
449
|
+
question: `Append PATH export to ${rc}? [y]es / [n]o (Esc rejects) `,
|
|
450
|
+
});
|
|
451
|
+
if (pathAns !== "no") {
|
|
452
|
+
try {
|
|
453
|
+
const edited = await addLocalBinToShellRc();
|
|
454
|
+
if (edited) {
|
|
455
|
+
emit(`appended PATH export to ${edited}. Open a new terminal or run:` +
|
|
456
|
+
`\n source ${edited}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
emit(`(${rc} already has ~/.local/bin in PATH; no edit needed)`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
emit(`error appending to ${rc}: ${e.message}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
emit(`Add this line to your shell rc manually:\n export PATH="$HOME/.local/bin:$PATH"`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
emit(`Couldn't autodetect your shell rc. Add this line manually:` +
|
|
472
|
+
`\n export PATH="$HOME/.local/bin:$PATH"`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
emit(`error: prefix setup failed: ${e.message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (!r.ok) {
|
|
482
|
+
const tail = isPermError && process.platform !== "win32"
|
|
483
|
+
? `\n\nQuick one-off if you'd rather: sudo npm install -g @este.systems/dsc@latest`
|
|
484
|
+
: "";
|
|
485
|
+
emit(`error: update failed\n${r.output.slice(-1500)}${tail}`);
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
emit(`installed ${check.latest}. Exit and re-run \`dsc\` to pick up the new version.`);
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
emit(`error: update failed: ${e.message}`);
|
|
492
|
+
}
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
case "api-key": {
|
|
496
|
+
// /api-key → status for every provider
|
|
497
|
+
// /api-key <key> → save DeepSeek key (back-compat)
|
|
498
|
+
// /api-key <provider> → that provider's status + signup
|
|
499
|
+
// /api-key <provider> <key> → save that provider's key
|
|
500
|
+
const providerIds = Object.keys(PROVIDER_KEY_INFO);
|
|
501
|
+
const statusLine = (p) => {
|
|
502
|
+
const info = PROVIDER_KEY_INFO[p];
|
|
503
|
+
const src = providerKeySource(p);
|
|
504
|
+
const status = src === "env" ? `set via $${info.envVar}` : src === "file" ? "stored in config" : "not set";
|
|
505
|
+
return ` ${p}: ${status} — ${info.signup}`;
|
|
506
|
+
};
|
|
507
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
508
|
+
if (tokens.length === 0) {
|
|
509
|
+
emit([
|
|
510
|
+
`api keys (config: ${configPath()}):`,
|
|
511
|
+
...providerIds.map(statusLine),
|
|
512
|
+
"",
|
|
513
|
+
"Save: /api-key [provider] <key> (provider defaults to deepseek)",
|
|
514
|
+
].join("\n"));
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
const first = tokens[0];
|
|
518
|
+
const named = providerIds.includes(first);
|
|
519
|
+
const provider = named ? first : "deepseek";
|
|
520
|
+
const keyValue = (named ? tokens.slice(1) : tokens).join(" ");
|
|
521
|
+
if (!keyValue) {
|
|
522
|
+
// `/api-key <provider>` with no key — show its status + how to set it.
|
|
523
|
+
const info = PROVIDER_KEY_INFO[provider];
|
|
524
|
+
emit(providerKeySource(provider)
|
|
525
|
+
? statusLine(provider)
|
|
526
|
+
: `${provider}: not set\nsignup: ${info.signup}\nsave with: /api-key ${provider} <key> (or export $${info.envVar})`);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const written = await saveProviderKey(provider, keyValue);
|
|
531
|
+
emit(`${provider} key saved to ${written}`);
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
emit(`error: ${e.message}`);
|
|
535
|
+
}
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
case "search": {
|
|
539
|
+
const SIGNUP = {
|
|
540
|
+
brave: "https://api-dashboard.search.brave.com/app/keys",
|
|
541
|
+
tavily: "https://app.tavily.com/",
|
|
542
|
+
};
|
|
543
|
+
const ENV_KEY = {
|
|
544
|
+
brave: "BRAVE_API_KEY",
|
|
545
|
+
tavily: "TAVILY_API_KEY",
|
|
546
|
+
};
|
|
547
|
+
const keySourceFor = (p) => {
|
|
548
|
+
if (process.env[ENV_KEY[p]])
|
|
549
|
+
return "env";
|
|
550
|
+
return getProviderKey(p) ? "file" : null;
|
|
551
|
+
};
|
|
552
|
+
const keyStatusLine = (p) => {
|
|
553
|
+
const src = keySourceFor(p);
|
|
554
|
+
const status = src === "env" ? `set via $${ENV_KEY[p]}` : src === "file" ? "stored in config" : "not set";
|
|
555
|
+
return ` ${p}: ${status} — ${SIGNUP[p]}`;
|
|
556
|
+
};
|
|
557
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
558
|
+
const sub = tokens[0];
|
|
559
|
+
if (!sub) {
|
|
560
|
+
const active = getProvider();
|
|
561
|
+
const activeSrc = process.env.DSC_SEARCH_PROVIDER
|
|
562
|
+
? "set via $DSC_SEARCH_PROVIDER"
|
|
563
|
+
: "from config";
|
|
564
|
+
emit([
|
|
565
|
+
`active provider: ${active} (${activeSrc})`,
|
|
566
|
+
"",
|
|
567
|
+
"keys:",
|
|
568
|
+
keyStatusLine("brave"),
|
|
569
|
+
keyStatusLine("tavily"),
|
|
570
|
+
" ddg: no key needed (DuckDuckGo HTML scrape)",
|
|
571
|
+
"",
|
|
572
|
+
"Subcommands:",
|
|
573
|
+
" /search use <brave|tavily|ddg> switch active provider",
|
|
574
|
+
" /search key <provider> [key] show or save api key",
|
|
575
|
+
].join("\n"));
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
if (sub === "use") {
|
|
579
|
+
const name = tokens[1];
|
|
580
|
+
if (!name || (name !== "brave" && name !== "tavily" && name !== "ddg")) {
|
|
581
|
+
emit("error: usage: /search use <brave|tavily|ddg>");
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
const written = await saveSearchProvider(name);
|
|
586
|
+
const needsKey = name === "brave" || name === "tavily";
|
|
587
|
+
const haveKey = needsKey ? !!keySourceFor(name) : true;
|
|
588
|
+
const warn = needsKey && !haveKey
|
|
589
|
+
? `\n(warning) ${name} has no key set. Run: /search key ${name} <key>`
|
|
590
|
+
: "";
|
|
591
|
+
emit(`active search provider → ${name} (saved to ${written})${warn}`);
|
|
592
|
+
}
|
|
593
|
+
catch (e) {
|
|
594
|
+
emit(`error: ${e.message}`);
|
|
595
|
+
}
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
if (sub === "key") {
|
|
599
|
+
const name = tokens[1];
|
|
600
|
+
const keyValue = tokens.slice(2).join(" ");
|
|
601
|
+
if (!name || (name !== "brave" && name !== "tavily")) {
|
|
602
|
+
emit("error: usage: /search key <brave|tavily> [key]");
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
if (!keyValue) {
|
|
606
|
+
emit(`${name}: ${keySourceFor(name) ?? "not set"}\nsignup: ${SIGNUP[name]}`);
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
const written = await saveSearchKey(name, keyValue);
|
|
611
|
+
emit(`${name} key saved to ${written}`);
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
emit(`error: ${e.message}`);
|
|
615
|
+
}
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
emit(`error: unknown subcommand '${sub}' (expected: use | key, or no arg for status)`);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
case "audit": {
|
|
622
|
+
const sub = arg.trim();
|
|
623
|
+
if (!sub || sub === "path") {
|
|
624
|
+
emit(audit.auditLogPath());
|
|
625
|
+
}
|
|
626
|
+
else if (sub.startsWith("show")) {
|
|
627
|
+
const nRaw = sub.replace(/^show\s*/, "").trim();
|
|
628
|
+
const limit = (() => {
|
|
629
|
+
const n = nRaw ? parseInt(nRaw, 10) : NaN;
|
|
630
|
+
return Number.isFinite(n) && n > 0 ? n : 10;
|
|
631
|
+
})();
|
|
632
|
+
try {
|
|
633
|
+
const text = await fsp.readFile(audit.auditLogPath(), "utf8");
|
|
634
|
+
const lines = text.split("\n").filter((l) => l.length > 0).slice(-limit);
|
|
635
|
+
emit(lines.length ? lines.join("\n") : "(empty)");
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
emit("(no audit log yet)");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
emit("error: usage: /audit | /audit path | /audit show [N]");
|
|
643
|
+
}
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
case "transcript": {
|
|
647
|
+
const session = ctx.getSession();
|
|
648
|
+
const messages = ctx.getMessages();
|
|
649
|
+
const archived = session.archivedMessages ?? [];
|
|
650
|
+
if (archived.length === 0 && messages.length === 0) {
|
|
651
|
+
emit("(no messages)");
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
const lines = [];
|
|
655
|
+
const renderMsg = (m, isArchived) => {
|
|
656
|
+
const tag = isArchived ? "archived " : "";
|
|
657
|
+
lines.push(`\n${tag}${m.role}`);
|
|
658
|
+
if (m.tool_calls && m.tool_calls.length) {
|
|
659
|
+
for (const tc of m.tool_calls) {
|
|
660
|
+
lines.push(` → ${tc.function.name}(${truncate(tc.function.arguments, 200)})`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (m.tool_call_id)
|
|
664
|
+
lines.push(` ← tool_call_id: ${m.tool_call_id}`);
|
|
665
|
+
if (typeof m.content === "string" && m.content)
|
|
666
|
+
lines.push(m.content);
|
|
667
|
+
};
|
|
668
|
+
if (archived.length) {
|
|
669
|
+
lines.push(`── archived (${archived.length} messages)`);
|
|
670
|
+
for (const m of archived)
|
|
671
|
+
renderMsg(m, true);
|
|
672
|
+
}
|
|
673
|
+
if (messages.length) {
|
|
674
|
+
lines.push(`── active (${messages.length} messages)`);
|
|
675
|
+
for (const m of messages)
|
|
676
|
+
renderMsg(m, false);
|
|
677
|
+
}
|
|
678
|
+
emit(lines.join("\n"));
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
case "compact": {
|
|
682
|
+
const keepRaw = arg ? parseInt(arg, 10) : NaN;
|
|
683
|
+
const keep = Number.isFinite(keepRaw) ? Math.max(0, keepRaw) : 4;
|
|
684
|
+
await ctx.compact(keep, false);
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
case "export": {
|
|
688
|
+
const dest = arg.trim() || ".";
|
|
689
|
+
const absDest = path.isAbsolute(dest) ? dest : path.resolve(ctx.cwd, dest);
|
|
690
|
+
try {
|
|
691
|
+
await ctx.persist();
|
|
692
|
+
const written = await history.exportSession(ctx.getSession().id, absDest);
|
|
693
|
+
emit(`exported to ${written}`);
|
|
694
|
+
}
|
|
695
|
+
catch (e) {
|
|
696
|
+
emit(`error: export failed: ${e.message}`);
|
|
697
|
+
}
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
case "import": {
|
|
701
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
702
|
+
const keepCwd = tokens.includes("--keep-cwd");
|
|
703
|
+
const file = tokens.find((t) => !t.startsWith("--"));
|
|
704
|
+
if (!file) {
|
|
705
|
+
emit("error: usage: /import <path> [--keep-cwd]");
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
const absFile = path.isAbsolute(file) ? file : path.resolve(ctx.cwd, file);
|
|
709
|
+
try {
|
|
710
|
+
const loaded = await history.importSession(absFile, {
|
|
711
|
+
rebindCwd: keepCwd ? undefined : ctx.cwd,
|
|
712
|
+
});
|
|
713
|
+
ctx.applySession(loaded, { rebuildView: true });
|
|
714
|
+
const userTurns = loaded.messages.filter((m) => m.role === "user").length;
|
|
715
|
+
const cwdNote = keepCwd ? "" : " (cwd rebound to here)";
|
|
716
|
+
emit(`imported ${loaded.id} (${userTurns} turns, model ${loaded.model})${cwdNote}`);
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
emit(`error: import failed: ${e.message}`);
|
|
720
|
+
}
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
case "edit": {
|
|
724
|
+
const initial = arg ? arg + "\n" : "";
|
|
725
|
+
const draft = ctx.runEditor(initial);
|
|
726
|
+
if (draft === null) {
|
|
727
|
+
emit("error: editor failed");
|
|
728
|
+
}
|
|
729
|
+
else if (!draft.trim()) {
|
|
730
|
+
emit("(empty draft, not sent)");
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
ctx.submit(draft);
|
|
734
|
+
}
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
default:
|
|
738
|
+
emit(`error: unknown command: /${cmd}`);
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
//# sourceMappingURL=slash_dispatch.js.map
|