@vextlabs/theron-cli 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.
Files changed (111) hide show
  1. package/LICENSE +15 -0
  2. package/LICENSE.txt +190 -0
  3. package/README.md +98 -0
  4. package/bin/theron +22 -0
  5. package/bin/theron.js +25 -0
  6. package/dist/api.d.ts +111 -0
  7. package/dist/api.js +328 -0
  8. package/dist/api.js.map +1 -0
  9. package/dist/auth.d.ts +15 -0
  10. package/dist/auth.js +92 -0
  11. package/dist/auth.js.map +1 -0
  12. package/dist/banner.d.ts +29 -0
  13. package/dist/banner.js +191 -0
  14. package/dist/banner.js.map +1 -0
  15. package/dist/cap_config.d.ts +28 -0
  16. package/dist/cap_config.js +83 -0
  17. package/dist/cap_config.js.map +1 -0
  18. package/dist/config.d.ts +18 -0
  19. package/dist/config.js +65 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/connections.d.ts +3 -0
  22. package/dist/connections.js +105 -0
  23. package/dist/connections.js.map +1 -0
  24. package/dist/import_claude.d.ts +3 -0
  25. package/dist/import_claude.js +268 -0
  26. package/dist/import_claude.js.map +1 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.js +237 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/onboard.d.ts +3 -0
  31. package/dist/onboard.js +234 -0
  32. package/dist/onboard.js.map +1 -0
  33. package/dist/profile_match.d.ts +15 -0
  34. package/dist/profile_match.js +107 -0
  35. package/dist/profile_match.js.map +1 -0
  36. package/dist/profiles/index.d.ts +20 -0
  37. package/dist/profiles/index.js +56 -0
  38. package/dist/profiles/index.js.map +1 -0
  39. package/dist/profiles/seeds.d.ts +4 -0
  40. package/dist/profiles/seeds.js +500 -0
  41. package/dist/profiles/seeds.js.map +1 -0
  42. package/dist/profiles/types.d.ts +35 -0
  43. package/dist/profiles/types.js +18 -0
  44. package/dist/profiles/types.js.map +1 -0
  45. package/dist/render.d.ts +18 -0
  46. package/dist/render.js +82 -0
  47. package/dist/render.js.map +1 -0
  48. package/dist/repl.d.ts +13 -0
  49. package/dist/repl.js +821 -0
  50. package/dist/repl.js.map +1 -0
  51. package/dist/streaming.d.ts +28 -0
  52. package/dist/streaming.js +118 -0
  53. package/dist/streaming.js.map +1 -0
  54. package/dist/tools/bash.d.ts +8 -0
  55. package/dist/tools/bash.js +57 -0
  56. package/dist/tools/bash.js.map +1 -0
  57. package/dist/tools/edit.d.ts +9 -0
  58. package/dist/tools/edit.js +42 -0
  59. package/dist/tools/edit.js.map +1 -0
  60. package/dist/tools/glob.d.ts +7 -0
  61. package/dist/tools/glob.js +40 -0
  62. package/dist/tools/glob.js.map +1 -0
  63. package/dist/tools/grep.d.ts +9 -0
  64. package/dist/tools/grep.js +73 -0
  65. package/dist/tools/grep.js.map +1 -0
  66. package/dist/tools/index.d.ts +31 -0
  67. package/dist/tools/index.js +180 -0
  68. package/dist/tools/index.js.map +1 -0
  69. package/dist/tools/ls.d.ts +6 -0
  70. package/dist/tools/ls.js +25 -0
  71. package/dist/tools/ls.js.map +1 -0
  72. package/dist/tools/read.d.ts +8 -0
  73. package/dist/tools/read.js +43 -0
  74. package/dist/tools/read.js.map +1 -0
  75. package/dist/tools/stoa.d.ts +34 -0
  76. package/dist/tools/stoa.js +103 -0
  77. package/dist/tools/stoa.js.map +1 -0
  78. package/dist/tools/write.d.ts +7 -0
  79. package/dist/tools/write.js +15 -0
  80. package/dist/tools/write.js.map +1 -0
  81. package/dist/verifiers/ai_ism_check.d.ts +2 -0
  82. package/dist/verifiers/ai_ism_check.js +48 -0
  83. package/dist/verifiers/ai_ism_check.js.map +1 -0
  84. package/dist/verifiers/arithmetic_recheck.d.ts +2 -0
  85. package/dist/verifiers/arithmetic_recheck.js +74 -0
  86. package/dist/verifiers/arithmetic_recheck.js.map +1 -0
  87. package/dist/verifiers/citation_presence.d.ts +2 -0
  88. package/dist/verifiers/citation_presence.js +42 -0
  89. package/dist/verifiers/citation_presence.js.map +1 -0
  90. package/dist/verifiers/em_dash_check.d.ts +2 -0
  91. package/dist/verifiers/em_dash_check.js +23 -0
  92. package/dist/verifiers/em_dash_check.js.map +1 -0
  93. package/dist/verifiers/index.d.ts +16 -0
  94. package/dist/verifiers/index.js +123 -0
  95. package/dist/verifiers/index.js.map +1 -0
  96. package/dist/verifiers/lint.d.ts +2 -0
  97. package/dist/verifiers/lint.js +90 -0
  98. package/dist/verifiers/lint.js.map +1 -0
  99. package/dist/verifiers/style_lint.d.ts +2 -0
  100. package/dist/verifiers/style_lint.js +115 -0
  101. package/dist/verifiers/style_lint.js.map +1 -0
  102. package/dist/verifiers/test_smoke.d.ts +2 -0
  103. package/dist/verifiers/test_smoke.js +94 -0
  104. package/dist/verifiers/test_smoke.js.map +1 -0
  105. package/dist/verifiers/typecheck.d.ts +2 -0
  106. package/dist/verifiers/typecheck.js +98 -0
  107. package/dist/verifiers/typecheck.js.map +1 -0
  108. package/dist/verifiers/types.d.ts +33 -0
  109. package/dist/verifiers/types.js +23 -0
  110. package/dist/verifiers/types.js.map +1 -0
  111. package/package.json +60 -0
package/dist/repl.js ADDED
@@ -0,0 +1,821 @@
1
+ // REPL — the chat loop. Reads a user prompt, streams a council
2
+ // response, executes any tool_calls locally, sends results back, loops
3
+ // until the model emits end_turn (no more tool calls) or the user
4
+ // stops the stream.
5
+ import readline from "node:readline";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+ import chalk from "chalk";
9
+ import { spawnSync } from "node:child_process";
10
+ import { streamChat, fetchInteractionPlan } from "./api.js";
11
+ import { loadCapConfig, resolveCapPolicy } from "./cap_config.js";
12
+ import { rankProfilesForPrompt } from "./profile_match.js";
13
+ import { TOOL_REGISTRY, TOOL_SCHEMAS } from "./tools/index.js";
14
+ import { renderMarkdown, ui } from "./render.js";
15
+ import { getProfileOrDefault, listProfiles, DEFAULT_PROFILE_SLUG } from "./profiles/index.js";
16
+ import { runVerifiers, summarizeIssues, formatForNextTurn } from "./verifiers/index.js";
17
+ import { connectionsCommand } from "./connections.js";
18
+ import { bannerTheron, welcomePill, renderNotes, renderStatus, renderSlashHelp, } from "./banner.js";
19
+ import { Spinner, announceTool, announcePin, announceError, announceWarn, } from "./streaming.js";
20
+ export async function runRepl(opts) {
21
+ const ctx = {
22
+ cwd: opts.cwd,
23
+ maxBytes: 64 * 1024,
24
+ yolo: opts.yolo,
25
+ };
26
+ const messages = [];
27
+ let pendingActions = [];
28
+ const rl = opts.oneShot
29
+ ? null
30
+ : readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY === true });
31
+ // Track whether stdin has ended (happens when piping input — stdin
32
+ // EOFs after the last line is consumed). Without this guard we hit
33
+ // ERR_USE_AFTER_CLOSE on the next question() call. Listening for
34
+ // 'close' lets the loop bail gracefully instead of throwing.
35
+ let rlClosed = false;
36
+ rl?.on("close", () => { rlClosed = true; });
37
+ // Mutable session state — slash commands rewrite these, REPL reads.
38
+ const session = {
39
+ cwd: opts.cwd,
40
+ yolo: opts.yolo,
41
+ pinnedSpecs: [],
42
+ profile: getProfileOrDefault(opts.profile ?? DEFAULT_PROFILE_SLUG),
43
+ /** Verifier issues to prepend to the NEXT user message — surfaces
44
+ * after the model self-corrects. Cleared once consumed. */
45
+ pendingVerifierBlock: "",
46
+ };
47
+ const updateCtx = () => {
48
+ ctx.cwd = session.cwd;
49
+ ctx.yolo = session.yolo;
50
+ };
51
+ if (!opts.oneShot) {
52
+ // Branded welcome — block-letter THERON banner + pill + numbered
53
+ // security notes + quickstart status line. Same flow Claude Code
54
+ // uses on first launch, in our amber-on-paper palette.
55
+ const notes = [
56
+ {
57
+ title: "Theron is currently in research preview",
58
+ body: [
59
+ "Built on Theron-Base + 30 LoRA specialists hosted by Vext Labs.",
60
+ "Run /bug at any time to flag issues — feedback ships nightly.",
61
+ ],
62
+ },
63
+ {
64
+ title: "Theron can make mistakes",
65
+ body: [
66
+ "Always review responses, especially when running code.",
67
+ "Write / Edit / Bash ask for confirmation unless you start with --yes.",
68
+ ],
69
+ },
70
+ {
71
+ title: "Only use it with code you trust",
72
+ body: [
73
+ "Tool outputs are passed back to the model as context — prompt",
74
+ "injection is possible if you're reviewing untrusted source.",
75
+ ],
76
+ },
77
+ ];
78
+ process.stdout.write("\n");
79
+ process.stdout.write(welcomePill("Welcome to " + chalkBoldThronWord() + " research preview!") + "\n\n");
80
+ process.stdout.write(bannerTheron() + "\n\n");
81
+ process.stdout.write(renderNotes(notes) + "\n\n");
82
+ process.stdout.write(renderStatus({
83
+ cwd: session.cwd,
84
+ apiUrl: opts.apiUrl,
85
+ loggedIn: !!opts.apiKey,
86
+ yolo: session.yolo,
87
+ profileLabel: session.profile.label,
88
+ }) + "\n\n");
89
+ // Profile welcome — one-line mode-specific framing so the user
90
+ // feels the mode immediately. Different from sampleSuggestion()
91
+ // (which is the same on every launch).
92
+ process.stdout.write(ui.info(`◉ ${session.profile.welcome}\n`));
93
+ if (session.profile.promptStarters && session.profile.promptStarters.length > 0) {
94
+ process.stdout.write(ui.info(` try: "${session.profile.promptStarters[0]}"\n`));
95
+ }
96
+ process.stdout.write("\n");
97
+ process.stdout.write(ui.info("type a message · /help for commands · /mode list to see all 33 · Ctrl-C to quit\n\n"));
98
+ }
99
+ const promptOnce = () => new Promise((resolve) => {
100
+ if (opts.oneShot && messages.length === 0) {
101
+ resolve(opts.oneShot);
102
+ return;
103
+ }
104
+ if (!rl || rlClosed) {
105
+ resolve(null);
106
+ return;
107
+ }
108
+ try {
109
+ rl.question(ui.prompt(), (answer) => resolve(answer));
110
+ }
111
+ catch (err) {
112
+ // ERR_USE_AFTER_CLOSE — stdin EOFed (typical when piping
113
+ // input). Bail the loop cleanly instead of throwing.
114
+ if (err?.code === "ERR_USE_AFTER_CLOSE") {
115
+ resolve(null);
116
+ return;
117
+ }
118
+ throw err;
119
+ }
120
+ });
121
+ while (true) {
122
+ const input = await promptOnce();
123
+ if (input == null)
124
+ break;
125
+ let trimmed = input.trim();
126
+ if (!trimmed)
127
+ continue;
128
+ if (trimmed === "/quit" || trimmed === "/exit")
129
+ break;
130
+ if (trimmed === "/help") {
131
+ process.stdout.write("\n" + renderSlashHelp() + "\n\n");
132
+ continue;
133
+ }
134
+ if (trimmed === "/status") {
135
+ process.stdout.write("\n" + renderStatus({
136
+ cwd: session.cwd,
137
+ apiUrl: opts.apiUrl,
138
+ loggedIn: !!opts.apiKey,
139
+ yolo: session.yolo,
140
+ model: undefined,
141
+ profileLabel: session.profile.label,
142
+ }) + "\n");
143
+ if (session.pinnedSpecs.length > 0) {
144
+ process.stdout.write(ui.info(`pinned: ${session.pinnedSpecs.map((s) => "@" + s).join(", ")}\n`));
145
+ }
146
+ process.stdout.write("\n");
147
+ continue;
148
+ }
149
+ if (trimmed === "/mode" || trimmed === "/mode list") {
150
+ // Render the full profile registry. Grouped two columns for
151
+ // readability — 33 entries is enough to want layout.
152
+ const profiles = listProfiles();
153
+ process.stdout.write("\n" + ui.info(`Theron profiles (${profiles.length} total) — use /mode <slug> to switch:`) + "\n");
154
+ for (const p of profiles) {
155
+ const active = p.slug === session.profile.slug ? "◉ " : " ";
156
+ const label = (p.slug.padEnd(11));
157
+ process.stdout.write(` ${active}${ui.toolLabel(label, "")} ${ui.info(p.description)}\n`);
158
+ }
159
+ process.stdout.write("\n");
160
+ continue;
161
+ }
162
+ if (trimmed.startsWith("/mode ")) {
163
+ const slug = trimmed.slice(6).trim().toLowerCase();
164
+ const next = getProfileOrDefault(slug);
165
+ // getProfileOrDefault returns default when the slug doesn't
166
+ // exist — detect that case so we can show a friendlier error.
167
+ const exists = listProfiles().some((p) => p.slug === slug);
168
+ if (!exists) {
169
+ process.stdout.write(ui.error(`unknown profile: ${slug}. /mode list to see all.\n\n`));
170
+ continue;
171
+ }
172
+ session.profile = next;
173
+ process.stdout.write("\n" + ui.info(`◉ ${next.welcome}`) + "\n");
174
+ if (next.promptStarters?.[0]) {
175
+ process.stdout.write(ui.info(` try: "${next.promptStarters[0]}"\n`));
176
+ }
177
+ // Surface the active verifier set so the user knows what
178
+ // safety net is now in play.
179
+ if (next.verifiers && next.verifiers.length > 0) {
180
+ process.stdout.write(ui.info(` verifiers: ${next.verifiers.join(", ")}\n`));
181
+ }
182
+ process.stdout.write("\n");
183
+ continue;
184
+ }
185
+ if (trimmed === "/clear") {
186
+ messages.length = 0;
187
+ pendingActions = [];
188
+ process.stdout.write(ui.info("conversation cleared\n\n"));
189
+ continue;
190
+ }
191
+ if (trimmed === "/yolo") {
192
+ session.yolo = !session.yolo;
193
+ updateCtx();
194
+ process.stdout.write(session.yolo
195
+ ? ui.warn("yolo on — tool calls auto-approve. Be careful.\n\n")
196
+ : ui.info("yolo off — Write / Edit / Bash will ask for confirmation.\n\n"));
197
+ continue;
198
+ }
199
+ if (trimmed === "/cwd") {
200
+ process.stdout.write(ui.info(session.cwd + "\n\n"));
201
+ continue;
202
+ }
203
+ if (trimmed.startsWith("/cd ")) {
204
+ const target = trimmed.slice(4).trim().replace(/^~/, process.env.HOME ?? "~");
205
+ const next = path.resolve(session.cwd, target);
206
+ try {
207
+ const fs = await import("node:fs");
208
+ const st = fs.statSync(next);
209
+ if (!st.isDirectory())
210
+ throw new Error("not a directory");
211
+ session.cwd = next;
212
+ updateCtx();
213
+ process.stdout.write(ui.info(`cwd → ${session.cwd}\n\n`));
214
+ }
215
+ catch (err) {
216
+ process.stdout.write(ui.error(`cd failed: ${err instanceof Error ? err.message : String(err)}\n\n`));
217
+ }
218
+ continue;
219
+ }
220
+ if (trimmed === "/cap" || trimmed === "/cap list") {
221
+ // Live-fetch the Stoa cap registry. The CLI doesn't ship a
222
+ // hardcoded list — caps come and go and we don't want a stale
223
+ // baked-in registry. Falls back to a friendly hint if the
224
+ // fetch fails (e.g. offline).
225
+ const { config: capConfig, sources: capSources } = loadCapConfig(session.cwd);
226
+ try {
227
+ const r = await fetch(`${opts.apiUrl.replace(/\/$/, "")}/v1/cap`, {
228
+ method: "GET",
229
+ headers: { accept: "application/json" },
230
+ });
231
+ if (r.ok) {
232
+ const data = (await r.json());
233
+ const caps = data.caps ?? [];
234
+ process.stdout.write("\n" + ui.info(`Stoa capabilities (${caps.length} registered) — invoke via the Stoa tool:`) + "\n");
235
+ for (const c of caps.slice(0, 40)) {
236
+ const short = c.urn.replace(/^urn:stoa:cap:/, "");
237
+ const policy = resolveCapPolicy(c.urn, capConfig);
238
+ const policyLabel = policy === "auto" ? "auto" : policy === "deny" ? "deny" : "ask";
239
+ process.stdout.write(` ${ui.toolLabel(short, c.domain ?? "")} ${ui.info(`(${policyLabel})`)} ${ui.info(c.description ?? "")}\n`);
240
+ }
241
+ if (caps.length === 0) {
242
+ process.stdout.write(ui.info(" (registry is empty — drop a spec at /stoa/spec to register one)\n"));
243
+ }
244
+ // Config sources — two-tier (global + project) per jcode pattern.
245
+ process.stdout.write("\n" + ui.info(`policy default: ${capConfig.default_policy}`));
246
+ if (capSources.length > 0) {
247
+ process.stdout.write(ui.info(` · config: ${capSources.join(" → ")}`));
248
+ }
249
+ else {
250
+ process.stdout.write(ui.info(` · no config (~/.theron/caps.json or ./.theron/caps.json)`));
251
+ }
252
+ process.stdout.write("\n\n");
253
+ }
254
+ else {
255
+ process.stdout.write(ui.info(`(Stoa registry unavailable — HTTP ${r.status})\n\n`));
256
+ }
257
+ }
258
+ catch {
259
+ process.stdout.write(ui.info("(Stoa registry unavailable — offline?)\n\n"));
260
+ }
261
+ continue;
262
+ }
263
+ if (trimmed === "/tools") {
264
+ process.stdout.write("\n" + ui.info("Available tools (Theron can call these on your repo):") + "\n");
265
+ for (const name of Object.keys(TOOL_REGISTRY)) {
266
+ const tool = TOOL_REGISTRY[name];
267
+ const policy = tool.confirmPolicy === "never" ? "auto" : "ask";
268
+ process.stdout.write(` ${ui.toolLabel(name, "")} ${ui.info(`(${policy})`)}\n`);
269
+ }
270
+ process.stdout.write("\n");
271
+ continue;
272
+ }
273
+ // /suggest <prompt> — rank profiles by similarity to a prompt and
274
+ // show the top 3 candidates so the user can switch with /mode <slug>
275
+ // without scrolling /mode list. Embedding-keyed injector pattern
276
+ // borrowed from jcode (MIT).
277
+ if (trimmed.startsWith("/suggest")) {
278
+ const promptText = trimmed.slice("/suggest".length).trim();
279
+ if (promptText.length < 12) {
280
+ process.stdout.write(ui.error("usage: /suggest <a sentence describing what you want to do>\n\n"));
281
+ continue;
282
+ }
283
+ const matches = rankProfilesForPrompt(promptText, 3);
284
+ if (matches.length === 0) {
285
+ process.stdout.write(ui.info("no clear match — your current mode is probably right.\n\n"));
286
+ continue;
287
+ }
288
+ process.stdout.write("\n" + ui.info(`Top profile matches for: "${promptText.slice(0, 80)}"`) + "\n");
289
+ for (const m of matches) {
290
+ const slugPad = m.profile.slug.padEnd(11);
291
+ const scorePad = m.score.toFixed(3);
292
+ const active = m.profile.slug === session.profile.slug ? "◉ " : " ";
293
+ process.stdout.write(` ${active}${ui.toolLabel(slugPad, "")} ${ui.info(`(${scorePad}) ${m.profile.description}`)}\n`);
294
+ }
295
+ process.stdout.write("\n" + ui.info(`switch with: /mode <slug>`) + "\n\n");
296
+ continue;
297
+ }
298
+ if (trimmed.startsWith("/pin ")) {
299
+ const slug = trimmed.slice(5).trim().replace(/^@/, "").toLowerCase();
300
+ if (!slug) {
301
+ process.stdout.write(ui.error("usage: /pin <specialist-slug> (e.g. /pin legal)\n\n"));
302
+ continue;
303
+ }
304
+ if (!session.pinnedSpecs.includes(slug))
305
+ session.pinnedSpecs.push(slug);
306
+ process.stdout.write(ui.info(`pinned ${session.pinnedSpecs.map((s) => "@" + s).join(", ")} for the next turn\n\n`));
307
+ continue;
308
+ }
309
+ if (trimmed === "/unpin") {
310
+ session.pinnedSpecs = [];
311
+ process.stdout.write(ui.info("cleared pinned specialists\n\n"));
312
+ continue;
313
+ }
314
+ if (trimmed === "/login") {
315
+ // Two readline.Interface instances sharing stdin is undefined —
316
+ // we don't try to do auth mid-REPL. Quitting cleanly + telling
317
+ // the user the one-liner is the safer path. The conversation is
318
+ // lost, but new credentials only matter for fresh sessions.
319
+ process.stdout.write(ui.info("exit and run `theron login` to save credentials. `/quit` to leave now.\n\n"));
320
+ continue;
321
+ }
322
+ if (trimmed === "/logout") {
323
+ try {
324
+ const fs = await import("node:fs");
325
+ const credsPath = (process.env.HOME ?? "") + "/.theron/credentials";
326
+ if (fs.existsSync(credsPath))
327
+ fs.unlinkSync(credsPath);
328
+ process.stdout.write(ui.info("credentials cleared. You're now anonymous.\n\n"));
329
+ }
330
+ catch (err) {
331
+ process.stdout.write(ui.error(`logout failed: ${err instanceof Error ? err.message : String(err)}\n\n`));
332
+ }
333
+ continue;
334
+ }
335
+ if (trimmed === "/connections" || trimmed === "/integrations") {
336
+ // Mirror of `theron connections` subcommand from inside the REPL.
337
+ // We invoke the same function the subcommand uses so the rendering
338
+ // stays in lockstep; connectionsCommand writes directly to stdout.
339
+ await connectionsCommand({ apiUrl: opts.apiUrl });
340
+ continue;
341
+ }
342
+ if (trimmed === "/model") {
343
+ process.stdout.write(ui.info("Server-side default model picks per-prompt via the council router. Override with THERON_API_URL + a custom backend if you want a fixed model.\n\n"));
344
+ continue;
345
+ }
346
+ // /readiness — print prod substrate health
347
+ if (trimmed === "/readiness" || trimmed === "/status --prod") {
348
+ try {
349
+ const r = await fetch(`${opts.apiUrl.replace(/\/$/, "")}/api/diag/readiness`, {
350
+ headers: { accept: "application/json" },
351
+ });
352
+ const data = (await r.json());
353
+ process.stdout.write("\n" + ui.info(`Theron prod readiness: ${data.status.toUpperCase()}`) + "\n");
354
+ for (const c of data.checks) {
355
+ const marker = c.status === "ready" ? "◉" : c.status === "degraded" ? "◑" : "○";
356
+ process.stdout.write(` ${marker} ${ui.toolLabel(c.system.padEnd(14), "")} ${ui.info(c.detail)}\n`);
357
+ }
358
+ process.stdout.write("\n" + ui.info(`self-innovation loop ready: ${data.self_innovation_loop_ready ? "YES (paradigm 3)" : "NO — fix the down systems above first"}`) + "\n\n");
359
+ }
360
+ catch (err) {
361
+ process.stdout.write(ui.error(`/readiness failed: ${err instanceof Error ? err.message : String(err)}\n\n`));
362
+ }
363
+ continue;
364
+ }
365
+ // /innovate — fire one tick of the Theron-Meta driver loop on
366
+ // demand. The loop normally heartbeats from a Vercel Cron, but
367
+ // this lets the user force a tick from the CLI and see the loop's
368
+ // current observation. Force=true biases it toward action.
369
+ if (trimmed === "/innovate" || trimmed.startsWith("/innovate ")) {
370
+ // SECURITY: gate on THERON_ADMIN_TOKEN only — do NOT fall back to
371
+ // THERON_INTERNAL_TOKEN. The internal token has broader scope and
372
+ // commit 435845f5 ("fix(v10): gate driver loop with dedicated
373
+ // THERON_ADMIN_TOKEN, not INTERNAL_TOKEN") was the server-side fix
374
+ // for that cross-scope elevation; the CLI must not re-open it.
375
+ const adminToken = process.env.THERON_ADMIN_TOKEN || "";
376
+ if (!adminToken) {
377
+ process.stdout.write(ui.error("/innovate requires THERON_ADMIN_TOKEN env var (the driver loop is admin-gated separately from THERON_API_KEY and THERON_INTERNAL_TOKEN — see commit 435845f5).\n\n"));
378
+ continue;
379
+ }
380
+ try {
381
+ const r = await fetch(`${opts.apiUrl.replace(/\/$/, "")}/api/driver/tick`, {
382
+ method: "POST",
383
+ headers: {
384
+ "content-type": "application/json",
385
+ "x-internal-token": adminToken,
386
+ },
387
+ body: JSON.stringify({ force: true }),
388
+ });
389
+ if (!r.ok) {
390
+ process.stdout.write(ui.error(`/innovate failed: HTTP ${r.status}\n\n`));
391
+ continue;
392
+ }
393
+ const data = (await r.json());
394
+ const o = data.observation;
395
+ process.stdout.write("\n" + ui.info(`Theron-Meta tick → ${o.action.toUpperCase()}`) + "\n");
396
+ process.stdout.write(` ${ui.info(o.action_detail)}\n`);
397
+ process.stdout.write(` ${ui.info(`signals: ${JSON.stringify(o.signals)}`)}\n`);
398
+ if (o.fired_job_id) {
399
+ process.stdout.write(` ${ui.info(`fired self-deliverable → job ${o.fired_job_id}`)}\n`);
400
+ }
401
+ if (o.fired_directive) {
402
+ process.stdout.write(` ${ui.info(`recorded directive → ${o.fired_directive}`)}\n`);
403
+ }
404
+ process.stdout.write("\n");
405
+ }
406
+ catch (err) {
407
+ process.stdout.write(ui.error(`/innovate failed: ${err instanceof Error ? err.message : String(err)}\n\n`));
408
+ }
409
+ continue;
410
+ }
411
+ // /design <prompt>, /deck <prompt>, /draft <prompt>, /research <prompt>
412
+ // Hand off a deliverable brief to AE OS — opens the browser with a
413
+ // deep-link URL that pre-fills the composer and auto-routes to the
414
+ // Council's design / deck / draft / research pipeline.
415
+ {
416
+ const handoffMatch = /^\/(design|deck|draft|research|proposal)(?:\s+([\s\S]+))?$/.exec(trimmed);
417
+ if (handoffMatch) {
418
+ const intent = handoffMatch[1];
419
+ const promptText = (handoffMatch[2] || "").trim();
420
+ if (!promptText) {
421
+ process.stdout.write(ui.error(`usage: /${intent} <prompt> (e.g. /${intent} a board deck for our $3M seed round)\n\n`));
422
+ continue;
423
+ }
424
+ const base = process.env.THERON_AE_OS_URL || "https://os.tryvext.com";
425
+ const url = new URL(base);
426
+ url.searchParams.set("intent", intent);
427
+ url.searchParams.set("prompt", promptText);
428
+ const finalUrl = url.toString();
429
+ const opener = process.platform === "darwin" ? "open" :
430
+ process.platform === "win32" ? "start" :
431
+ "xdg-open";
432
+ let opened = false;
433
+ try {
434
+ const r = spawnSync(opener, [finalUrl], { stdio: "ignore" });
435
+ opened = r.status === 0;
436
+ }
437
+ catch { /* opener missing — handled below */ }
438
+ process.stdout.write(opened
439
+ ? ui.info(`opened in browser → ${finalUrl}\n\n`)
440
+ : ui.info(`open in browser: ${finalUrl}\n\n`));
441
+ continue;
442
+ }
443
+ }
444
+ if (trimmed === "/bug") {
445
+ // Try to open the issue tracker in the user's browser. We fall
446
+ // back to printing the URL when the platform opener is missing
447
+ // (e.g. headless CI) — either way the user sees the link.
448
+ const issueUrl = "https://github.com/Vext-Labs-Inc/sucrityflash/issues/new?labels=cli&title=Theron+CLI+bug";
449
+ const opener = process.platform === "darwin" ? "open" :
450
+ process.platform === "win32" ? "start" :
451
+ "xdg-open";
452
+ let opened = false;
453
+ try {
454
+ const r = spawnSync(opener, [issueUrl], { stdio: "ignore" });
455
+ opened = r.status === 0;
456
+ }
457
+ catch { /* opener missing — handled below */ }
458
+ if (opened) {
459
+ process.stdout.write(ui.info(`opened ${issueUrl}\n`));
460
+ }
461
+ else {
462
+ process.stdout.write(ui.info(`open: ${issueUrl}\n`));
463
+ }
464
+ process.stdout.write(ui.info("include `theron --version` output + the prompt that broke.\n\n"));
465
+ continue;
466
+ }
467
+ // Unknown slash → friendly nudge.
468
+ if (trimmed.startsWith("/")) {
469
+ process.stdout.write(ui.error(`unknown command: ${trimmed.split(/\s/)[0]}. type /help for the list.\n\n`));
470
+ continue;
471
+ }
472
+ // If the user typed a bare 1-4 and we just rendered action chips,
473
+ // expand it into the action's prompt — terminal analogue of
474
+ // clicking an amber chip on web.
475
+ const pickMatch = /^[1-4]$/.exec(trimmed);
476
+ if (pickMatch && pendingActions.length > 0) {
477
+ const idx = Number(pickMatch[0]) - 1;
478
+ const action = pendingActions[idx];
479
+ if (action) {
480
+ const expanded = expandActionToPrompt(action);
481
+ if (expanded) {
482
+ process.stdout.write(ui.info(`▸ ${action.label}\n`));
483
+ trimmed = expanded;
484
+ }
485
+ }
486
+ }
487
+ pendingActions = [];
488
+ // Inline-pin pinned specialists into the prompt so the backend's
489
+ // forced-spec extractor (interaction.ts) picks them up. Cleared
490
+ // after one turn — same shape the web composer uses.
491
+ let toSend = trimmed;
492
+ let activePins = [];
493
+ if (session.pinnedSpecs.length > 0) {
494
+ activePins = [...session.pinnedSpecs];
495
+ // Avoid duplicate pinning if user already typed @spec inline.
496
+ const already = new Set(Array.from(toSend.matchAll(/@([a-z]+)\b/g)).map((m) => m[1].toLowerCase()));
497
+ const need = session.pinnedSpecs.filter((s) => !already.has(s));
498
+ if (need.length > 0) {
499
+ toSend = need.map((s) => "@" + s).join(" ") + " " + toSend;
500
+ }
501
+ // Pins are per-turn — clear after attaching.
502
+ session.pinnedSpecs = [];
503
+ }
504
+ // If the previous turn's verifiers found blocking issues, prepend
505
+ // their summary to this user message so the model can self-correct
506
+ // without the user typing "fix it." Empty string when there's
507
+ // nothing pending.
508
+ if (session.pendingVerifierBlock) {
509
+ toSend = session.pendingVerifierBlock + "\n\n" + toSend;
510
+ session.pendingVerifierBlock = "";
511
+ }
512
+ // If the active profile has a hiveSpecs list and the user hasn't
513
+ // explicitly pinned anything, attach the profile's default council
514
+ // members to the prompt so the @-mention router picks them up.
515
+ if (activePins.length === 0 && session.profile.hiveSpecs && session.profile.hiveSpecs.length > 0) {
516
+ activePins = [...session.profile.hiveSpecs];
517
+ const already = new Set(Array.from(toSend.matchAll(/@([a-z]+)\b/g)).map((m) => m[1].toLowerCase()));
518
+ const need = activePins.filter((s) => !already.has(s));
519
+ if (need.length > 0) {
520
+ toSend = need.map((s) => "@" + s).join(" ") + " " + toSend;
521
+ }
522
+ }
523
+ messages.push({ role: "user", content: toSend });
524
+ // Fire the interaction-plan classifier in parallel with the first
525
+ // model turn. The plan is shared across web/CLI/IDE — if it wins
526
+ // the race we print the amber headline above the streaming text
527
+ // (same affordance as the web bubble). Failures return null so
528
+ // the chat never blocks on it.
529
+ const planPromise = fetchInteractionPlan({
530
+ apiUrl: opts.apiUrl,
531
+ prompt: trimmed,
532
+ history: messages.slice(0, -1).slice(-6).map((m) => ({
533
+ role: m.role === "tool" ? "system" : m.role,
534
+ content: m.content,
535
+ })),
536
+ });
537
+ let planPrinted = false;
538
+ void planPromise.then((p) => {
539
+ if (p && !planPrinted) {
540
+ planPrinted = true;
541
+ process.stdout.write("\n" + ui.planHeadline(p.headline) + "\n");
542
+ }
543
+ });
544
+ // Inner loop: run turn, execute tool calls, repeat until end_turn.
545
+ // We also accumulate the files the model touched (Write/Edit args)
546
+ // so the verifier pass can scope itself to just this turn's edits.
547
+ let turnGuard = 0;
548
+ const touchedFiles = new Set();
549
+ let lastAssistantText = "";
550
+ while (turnGuard < 20) {
551
+ turnGuard += 1;
552
+ const res = await runOneTurn({
553
+ apiUrl: opts.apiUrl,
554
+ apiKey: opts.apiKey,
555
+ messages,
556
+ ctx,
557
+ rl,
558
+ // Show pin header only on the FIRST inner-loop iteration of a
559
+ // turn (turnGuard === 1) — subsequent tool-loop turns share the
560
+ // same pin context, no need to re-announce.
561
+ pinnedSpecs: turnGuard === 1 ? activePins : undefined,
562
+ profile: session.profile.slug,
563
+ touchedFilesSink: touchedFiles,
564
+ });
565
+ if (res.kind === "error") {
566
+ process.stdout.write(ui.error(res.message) + "\n\n");
567
+ break;
568
+ }
569
+ if (res.kind === "end_turn") {
570
+ lastAssistantText = res.assistantText ?? "";
571
+ break;
572
+ }
573
+ // tool_use — keep looping
574
+ }
575
+ // ── Verifier pass ─────────────────────────────────────────────
576
+ // After the turn settles, run the active profile's verifier kernels
577
+ // against the assistant output + the files it touched. Blocking
578
+ // issues get fed back into the NEXT user message so the model can
579
+ // self-correct. Warnings + info surface inline as a chip.
580
+ if (session.profile.verifiers && session.profile.verifiers.length > 0) {
581
+ const issues = await runVerifiers(session.profile.verifiers, {
582
+ cwd: session.cwd,
583
+ assistantText: lastAssistantText,
584
+ touchedFiles: Array.from(touchedFiles),
585
+ profile: session.profile.slug,
586
+ });
587
+ const sum = summarizeIssues(issues);
588
+ if (issues.length === 0) {
589
+ process.stdout.write(ui.info(`✓ verifiers (${session.profile.verifiers.join(", ")}) green\n\n`));
590
+ }
591
+ else {
592
+ const head = sum.ok
593
+ ? ui.info(`verifiers · ${sum.summary}\n`)
594
+ : ui.error(`verifiers · ${sum.summary}\n`);
595
+ process.stdout.write("\n" + head);
596
+ for (const line of sum.details)
597
+ process.stdout.write(ui.info(line) + "\n");
598
+ process.stdout.write("\n");
599
+ // Stage blocking issues for the next turn — model self-corrects
600
+ // on the user's next prompt.
601
+ session.pendingVerifierBlock = formatForNextTurn(issues);
602
+ }
603
+ }
604
+ // After the turn settles, surface suggested actions if the plan
605
+ // came back. They render as numbered chips; on the next prompt
606
+ // the user can type "1" / "2" / "3" to fire one.
607
+ const plan = await planPromise.catch(() => null);
608
+ if (plan && plan.suggested_actions.length > 0) {
609
+ renderSuggestedActions(plan);
610
+ pendingActions = plan.suggested_actions.slice(0, 4);
611
+ }
612
+ if (opts.oneShot)
613
+ break;
614
+ }
615
+ rl?.close();
616
+ // Final newline so the user's shell prompt lands on a clean line
617
+ // instead of the readline `> ` getting % -terminated by zsh.
618
+ process.stdout.write("\n");
619
+ return 0;
620
+ }
621
+ async function runOneTurn(args) {
622
+ let assistantText = "";
623
+ const toolCalls = [];
624
+ let stopReason = null;
625
+ let firstDelta = true;
626
+ // Show the pin header BEFORE thinking spinner so the user knows
627
+ // immediately that their /pin took effect.
628
+ if (args.pinnedSpecs && args.pinnedSpecs.length > 0) {
629
+ process.stdout.write(announcePin(args.pinnedSpecs) + "\n");
630
+ }
631
+ // "thinking…" spinner — fires immediately, clears the moment the
632
+ // first text delta lands. Removes the awkward silent gap between
633
+ // prompt submission and first token.
634
+ const spinner = new Spinner("thinking…");
635
+ spinner.start();
636
+ await streamChat({
637
+ apiUrl: args.apiUrl,
638
+ apiKey: args.apiKey,
639
+ messages: args.messages,
640
+ tools: TOOL_SCHEMAS,
641
+ profile: args.profile,
642
+ }, {
643
+ onTextDelta: (d) => {
644
+ if (firstDelta) {
645
+ spinner.stop();
646
+ process.stdout.write("\n");
647
+ firstDelta = false;
648
+ }
649
+ assistantText += d;
650
+ process.stdout.write(d);
651
+ },
652
+ onToolCall: (call) => {
653
+ toolCalls.push(call);
654
+ // Update the spinner label so the user sees what's queued.
655
+ if (firstDelta)
656
+ spinner.setLabel(`${call.name}…`);
657
+ },
658
+ onTurnEnd: (reason) => { stopReason = reason; },
659
+ onError: (msg) => {
660
+ stopReason = "error";
661
+ spinner.stop();
662
+ process.stdout.write("\n" + announceError(msg) + "\n");
663
+ },
664
+ });
665
+ // Always stop the spinner in case neither delta nor error fired
666
+ // (e.g. immediate turn_end with no content — empty model response).
667
+ spinner.stop();
668
+ if (assistantText)
669
+ process.stdout.write("\n\n");
670
+ args.messages.push({ role: "assistant", content: assistantText, tool_calls: toolCalls });
671
+ if (stopReason === "error")
672
+ return { kind: "error", message: "stream error" };
673
+ if (toolCalls.length === 0)
674
+ return { kind: "end_turn", assistantText };
675
+ // Execute each tool in order, push results.
676
+ for (const call of toolCalls) {
677
+ const tool = TOOL_REGISTRY[call.name];
678
+ if (!tool) {
679
+ args.messages.push({
680
+ role: "tool",
681
+ tool_call_id: call.id,
682
+ content: `[error] Unknown tool: ${call.name}`,
683
+ });
684
+ continue;
685
+ }
686
+ // Record Write/Edit paths so the post-turn verifier pass can
687
+ // scope itself to just the files this turn touched.
688
+ if (args.touchedFilesSink && (call.name === "Write" || call.name === "Edit")) {
689
+ const path = call.args?.file_path
690
+ ?? call.args?.path;
691
+ if (typeof path === "string" && path.length > 0) {
692
+ args.touchedFilesSink.add(path);
693
+ }
694
+ }
695
+ // Tool announcement — bullet style matches a list of actions
696
+ // rather than CLI chrome. Single line, brand-amber name + dim
697
+ // detail.
698
+ process.stdout.write(announceTool(call.name, tool.describe(call.args)) + "\n");
699
+ if (!args.ctx.yolo && tool.confirmPolicy !== "never") {
700
+ const ok = await confirm(` Allow ${call.name}?`, args.rl);
701
+ if (!ok) {
702
+ args.messages.push({
703
+ role: "tool",
704
+ tool_call_id: call.id,
705
+ content: `[user denied] User declined to run ${call.name}.`,
706
+ });
707
+ process.stdout.write(announceWarn("denied") + "\n\n");
708
+ continue;
709
+ }
710
+ }
711
+ // Spinner during tool execution — Bash/Read on a large file can
712
+ // take seconds. Without this the user stares at silence.
713
+ const toolSpin = new Spinner(`running ${call.name}…`);
714
+ toolSpin.start();
715
+ let result;
716
+ try {
717
+ result = await tool.execute(call.args, args.ctx);
718
+ toolSpin.stop();
719
+ }
720
+ catch (err) {
721
+ toolSpin.stop();
722
+ // Hardened: malformed args / tool throws / fs errors all turn
723
+ // into a structured tool-result string the model can RECOVER
724
+ // from instead of crashing the REPL. The error gets fed back in
725
+ // the conversation so the model can fix its call and try again.
726
+ const errMsg = err instanceof Error ? err.message : String(err);
727
+ result = `[error] ${call.name} failed: ${errMsg}\n\nThe tool call was rejected. Common causes: missing required args, invalid path, file too large, command refused. You can retry with corrected args.`;
728
+ process.stdout.write(announceError(`${call.name} failed: ${errMsg}`) + "\n");
729
+ }
730
+ // Show more of each tool's output in the local CLI preview. The
731
+ // model always sees the full output server-side; the truncation
732
+ // only affects what the user sees in their terminal.
733
+ process.stdout.write(ui.info(truncatePreview(result, 4000)) + "\n\n");
734
+ args.messages.push({ role: "tool", tool_call_id: call.id, content: result });
735
+ }
736
+ return { kind: "tool_use" };
737
+ }
738
+ async function confirm(question, rl) {
739
+ // CRITICAL: reuse the OUTER REPL's readline.Interface. Creating a
740
+ // new Interface + closing it would close stdin under the outer rl
741
+ // on some terminals (zsh observed) — the REPL would die after the
742
+ // first tool call. Pass through the existing rl, share the same
743
+ // input stream.
744
+ if (rl) {
745
+ return await new Promise((resolve) => {
746
+ try {
747
+ rl.question(`${question} ${ui.info("(y/N) ")}`, (a) => {
748
+ const yes = a.trim().toLowerCase();
749
+ resolve(yes === "y" || yes === "yes");
750
+ });
751
+ }
752
+ catch (err) {
753
+ // rl was closed (stdin EOF) — deny by default rather than
754
+ // throw, so the caller can record [user denied] and exit.
755
+ if (err?.code === "ERR_USE_AFTER_CLOSE") {
756
+ resolve(false);
757
+ return;
758
+ }
759
+ throw err;
760
+ }
761
+ });
762
+ }
763
+ // Fallback for one-shot mode where rl is null — read stdin once.
764
+ return await new Promise((resolve) => {
765
+ process.stdout.write(`${question} ${ui.info("(y/N) ")}`);
766
+ let buf = "";
767
+ const onData = (chunk) => {
768
+ buf += chunk.toString("utf8");
769
+ const nl = buf.indexOf("\n");
770
+ if (nl >= 0) {
771
+ process.stdin.off("data", onData);
772
+ const yes = buf.slice(0, nl).trim().toLowerCase();
773
+ resolve(yes === "y" || yes === "yes");
774
+ }
775
+ };
776
+ process.stdin.on("data", onData);
777
+ });
778
+ }
779
+ function truncatePreview(s, max) {
780
+ if (s.length <= max)
781
+ return s;
782
+ return s.slice(0, max) + `\n[+${s.length - max} bytes truncated in CLI preview; the model received the full output]`;
783
+ }
784
+ function renderSuggestedActions(plan) {
785
+ const chips = plan.suggested_actions.slice(0, 4);
786
+ if (chips.length === 0)
787
+ return;
788
+ process.stdout.write("\n");
789
+ chips.forEach((a, i) => {
790
+ process.stdout.write(ui.actionChip(i + 1, a.label) + "\n");
791
+ });
792
+ process.stdout.write(ui.info(`type 1-${chips.length} on the next prompt to run, or just keep chatting\n\n`));
793
+ }
794
+ /** Resolve an action chip into the prompt text we'll send as the next
795
+ * user message. Most actions just have an explicit `prompt` field;
796
+ * the `mention_specialist` shape gets expanded into "@<spec> ..." so
797
+ * the council's @-mention router fires the right specialist swarm. */
798
+ function expandActionToPrompt(a) {
799
+ if (a.prompt && typeof a.prompt === "string")
800
+ return a.prompt;
801
+ if (a.action === "mention_specialist" && a.specialist) {
802
+ return `@${a.specialist} ${a.label.replace(/^run by\s+/i, "").trim() || "weigh in on the previous message"}`;
803
+ }
804
+ if (a.action === "make_deliverable") {
805
+ const kindHint = a.kind ? ` as a ${a.kind}` : "";
806
+ return `Make this${kindHint}: ${a.label}`;
807
+ }
808
+ if (a.action === "open_workspace")
809
+ return null;
810
+ return a.label;
811
+ }
812
+ /** Small helper used in the welcome pill to bold the word "Theron"
813
+ * inline. */
814
+ function chalkBoldThronWord() {
815
+ return chalk.bold.hex("#FFAE00")("Theron");
816
+ }
817
+ // Expose renderMarkdown so `theron --markdown` mode can pretty-print
818
+ // after the stream finishes if someone wants that flow. Currently the
819
+ // REPL streams raw deltas to keep latency snappy.
820
+ export { renderMarkdown };
821
+ //# sourceMappingURL=repl.js.map