@wrongstack/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -234,933 +234,1261 @@ function hasDiff(input) {
234
234
  input && typeof input === "object" && "diff" in input
235
235
  );
236
236
  }
237
- var TerminalRenderer = class {
238
- out;
239
- err;
240
- lineStart = true;
241
- /**
242
- * When true, every stdout-bound method is a no-op. This is the only
243
- * safe state to be in while Ink owns the terminal (TUI mode):
244
- * raw writes to stdout interleave with Ink's cursor math and cause
245
- * the input + status bar to be reprinted as scrollback junk.
246
- * Stderr-bound methods (writeInfo/Warning/Error) still flow they
247
- * go to a different stream Ink does not manage.
248
- */
249
- silent = false;
250
- constructor(opts = {}) {
251
- this.out = opts.out ?? process.stdout;
252
- this.err = opts.err ?? process.stderr;
253
- }
254
- /**
255
- * Toggle stdout suppression. Call `setSilent(true)` right before
256
- * handing the terminal to Ink, and `setSilent(false)` after Ink
257
- * exits. Idempotent.
258
- */
259
- setSilent(silent) {
260
- this.silent = silent;
261
- }
262
- isSilent() {
263
- return this.silent;
264
- }
265
- write(input) {
266
- if (this.silent) return;
267
- const text = typeof input === "string" ? input : input.text;
268
- if (!text) return;
269
- const rendered = renderMarkdown(text);
270
- this.out.write(rendered);
271
- this.lineStart = rendered.endsWith("\n");
272
- }
273
- writeLine(text = "") {
274
- if (this.silent) return;
275
- if (!this.lineStart) this.out.write("\n");
276
- if (text) this.out.write(`${text}
237
+ function hasApiKey(provider, config) {
238
+ if (provider.envVars.some((v) => !!process.env[v])) return true;
239
+ const stored = config?.providers?.[provider.id]?.apiKey;
240
+ if (typeof stored === "string" && stored.length > 0) return true;
241
+ return false;
242
+ }
243
+ async function runPicker(deps) {
244
+ const { modelsRegistry, renderer, reader, config, defaultProvider, defaultModel } = deps;
245
+ renderer.write(`
246
+ ${color.bold(theme2.primary("WrongStack") + color.dim(" \u2014 Provider & Model Selection"))}
277
247
  `);
278
- else this.out.write("\n");
279
- this.lineStart = true;
280
- }
281
- writeBlock(block) {
282
- if (this.silent) return;
283
- if (block.type === "text") {
284
- this.write(block);
285
- } else if (block.type === "tool_use") {
286
- this.writeToolCall(block.name, block.input);
287
- } else if (block.type === "tool_result") {
288
- const text = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
289
- this.writeToolResult("result", text, !!block.is_error);
290
- }
248
+ renderer.write(color.dim("Loading provider catalog\u2026\n"));
249
+ let providers;
250
+ try {
251
+ providers = await modelsRegistry.listProviders();
252
+ } catch {
253
+ renderer.writeError("Failed to load provider catalog. Pass --provider and --model to skip the picker.");
254
+ return void 0;
291
255
  }
292
- writeToolCall(name, input) {
293
- if (this.silent) return;
294
- if (!this.lineStart) this.out.write("\n");
295
- const arrow = theme.primary("\u2192");
296
- const display = formatInputSummary(input);
297
- this.out.write(`${arrow} ${theme.bold(name)}${display ? color.dim(` ${display}`) : ""}
298
- `);
299
- this.lineStart = true;
256
+ const supported = providers.filter((p) => p.family !== "unsupported");
257
+ if (supported.length === 0) {
258
+ renderer.writeError("No supported providers found in catalog.");
259
+ return void 0;
300
260
  }
301
- writeToolResult(name, content, isError) {
302
- if (this.silent) return;
303
- const txt = typeof content === "string" ? content : safeStringify(content);
304
- const prefix = isError ? theme.error("\u2718") : theme.success("\u2713");
305
- if (isError) {
306
- const firstLine = txt.split("\n")[0] ?? "";
307
- const truncated = firstLine.length > 200 ? `${firstLine.slice(0, 197)}\u2026` : firstLine;
308
- this.out.write(` ${prefix} ${color.dim(truncated)}
309
- `);
310
- this.lineStart = true;
311
- return;
312
- }
313
- const isEditLike = name === "edit" || name === "write";
314
- const isReadLike = name === "read" || name === "grep" || name === "glob" || name === "bash";
315
- const previewLines = isEditLike ? 0 : isReadLike ? 6 : 2;
316
- const diff = extractDiff(content);
317
- if (isEditLike && diff) {
318
- this.out.write(` ${prefix} ${color.dim(summarize(content, name))}
319
- `);
320
- const rendered = renderDiff(diff).split("\n").map((l) => ` ${l}`).join("\n");
321
- this.out.write(`${rendered}
322
- `);
323
- this.lineStart = true;
324
- return;
325
- }
326
- const lines = txt.split("\n");
327
- const head = lines.slice(0, previewLines).map((l) => l.replace(/\s+$/, ""));
328
- const moreCount = Math.max(0, lines.length - head.length);
329
- this.out.write(` ${prefix} ${color.dim(summarize(content, name))}
330
- `);
331
- for (const l of head) {
332
- const capped = l.length > 200 ? `${l.slice(0, 197)}\u2026` : l;
333
- this.out.write(` ${color.dim(capped)}
334
- `);
335
- }
336
- if (moreCount > 0) {
337
- this.out.write(` ${color.dim(`+${moreCount} more line${moreCount === 1 ? "" : "s"}`)}
261
+ const keyed = supported.filter((p) => hasApiKey(p, config));
262
+ let displayList = keyed;
263
+ let showingFallback = false;
264
+ if (keyed.length === 0) {
265
+ displayList = supported;
266
+ showingFallback = true;
267
+ }
268
+ const families = /* @__PURE__ */ new Map();
269
+ for (const p of displayList) {
270
+ const list = families.get(p.family) ?? [];
271
+ list.push(p);
272
+ families.set(p.family, list);
273
+ }
274
+ const ordered = [];
275
+ const familyOrder = ["anthropic", "openai", "google", "openai-compatible"];
276
+ let idx = 1;
277
+ let defaultIdx;
278
+ renderer.write("\n");
279
+ for (const fam of familyOrder) {
280
+ const list = families.get(fam);
281
+ if (!list || list.length === 0) continue;
282
+ renderer.write(` ${color.bold(fam)}
338
283
  `);
284
+ for (const p of list) {
285
+ const envFound = p.envVars.some((v) => !!process.env[v]);
286
+ const configKey = typeof config?.providers?.[p.id]?.apiKey === "string" && config.providers[p.id].apiKey.length > 0;
287
+ const marker = envFound ? color.green("\u25CF") : configKey ? color.cyan("\u25C9") : color.dim("\u25CB");
288
+ const isDefault = p.id === defaultProvider;
289
+ if (isDefault) defaultIdx = idx;
290
+ const idLabel = isDefault ? color.bold(p.id) : p.id;
291
+ const suffix = isDefault ? color.dim(" (default)") : "";
292
+ renderer.write(
293
+ ` ${color.dim(`${idx}.`.padStart(4))} ${marker} ${idLabel.padEnd(22)} ${color.dim(p.name)}${suffix}
294
+ `
295
+ );
296
+ ordered.push({ provider: p, index: idx });
297
+ idx++;
339
298
  }
340
- this.lineStart = true;
341
299
  }
342
- writeDiff(diff) {
343
- if (this.silent) return;
344
- if (!this.lineStart) this.out.write("\n");
345
- this.out.write(`${renderDiff(diff)}
346
- `);
347
- this.lineStart = true;
300
+ if (showingFallback) {
301
+ renderer.write(
302
+ `
303
+ ${color.yellow("\u26A0 No API keys detected.")} ${color.dim("Pick a provider, then run `wstack auth <provider>` to add one.")}
304
+ `
305
+ );
306
+ } else {
307
+ renderer.write(
308
+ `
309
+ ${color.dim("\u25CF = env key \u25C9 = stored in config \u25CB = no key")}
310
+ `
311
+ );
348
312
  }
349
- writeWarning(text) {
350
- this.err.write(`${theme.warn("\u26A0")} ${text}
351
- `);
313
+ const defaultHint = defaultIdx !== void 0 && defaultProvider ? ` ${color.dim(`[Enter = ${defaultProvider}]`)}` : "";
314
+ const providerAnswer = (await reader.readLine(`
315
+ ${color.amber("?")} Select provider (1-${ordered.length})${defaultHint}: `)).trim();
316
+ if (!providerAnswer) {
317
+ if (defaultIdx !== void 0) {
318
+ const def = ordered[defaultIdx - 1];
319
+ if (def) return pickModel(def.provider, modelsRegistry, renderer, reader, defaultModel);
320
+ }
321
+ renderer.write(color.dim("Cancelled.\n"));
322
+ return void 0;
352
323
  }
353
- writeError(text) {
354
- this.err.write(`${theme.error("\u2718")} ${text}
355
- `);
324
+ const providerIdx = parseInt(providerAnswer, 10);
325
+ if (Number.isNaN(providerIdx) || providerIdx < 1 || providerIdx > ordered.length) {
326
+ const byId = ordered.find((o) => o.provider.id.toLowerCase() === providerAnswer.toLowerCase());
327
+ if (!byId) {
328
+ renderer.writeError(`Invalid selection: "${providerAnswer}"`);
329
+ return void 0;
330
+ }
331
+ return pickModel(byId.provider, modelsRegistry, renderer, reader, defaultModel);
356
332
  }
357
- writeInfo(text) {
358
- this.err.write(`${theme.info("\u2139")} ${text}
333
+ const chosen = ordered[providerIdx - 1];
334
+ if (!chosen) return void 0;
335
+ const modelHint = chosen.provider.id === defaultProvider ? defaultModel : void 0;
336
+ return pickModel(chosen.provider, modelsRegistry, renderer, reader, modelHint);
337
+ }
338
+ async function pickModel(provider, registry, renderer, reader, defaultModel) {
339
+ renderer.write(`
340
+ ${color.bold(provider.name)} ${color.dim(`(${provider.id})`)} models:
341
+
359
342
  `);
343
+ const models = [...provider.models].sort(
344
+ (a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")
345
+ );
346
+ if (models.length === 0) {
347
+ renderer.writeError(" No models listed for this provider in the catalog.");
348
+ return void 0;
360
349
  }
361
- clear() {
362
- if (this.silent) return;
363
- this.out.write("\x1B[2J\x1B[H");
364
- this.lineStart = true;
350
+ const defaultIdxInModels = defaultModel !== void 0 ? models.findIndex((m) => m.id === defaultModel) : -1;
351
+ const pageSize = 30;
352
+ let offset = 0;
353
+ while (offset < models.length) {
354
+ const page = models.slice(offset, offset + pageSize);
355
+ for (let i = 0; i < page.length; i++) {
356
+ const m = page[i];
357
+ const num = offset + i + 1;
358
+ const ctx = m.limit?.context ? `${(m.limit.context / 1e3).toFixed(0)}k`.padStart(6) : " ?";
359
+ const cost = m.cost?.input !== void 0 ? `$${m.cost.input}/$${m.cost.output ?? "?"}` : "";
360
+ const caps = [];
361
+ if (m.tool_call) caps.push("tools");
362
+ if (m.reasoning) caps.push("reason");
363
+ if (m.modalities?.input?.includes("image")) caps.push("vision");
364
+ const capStr = caps.length > 0 ? color.dim(caps.join(",")) : "";
365
+ const isDefault = m.id === defaultModel;
366
+ const idLabel = isDefault ? color.bold(m.id) : m.id;
367
+ const suffix = isDefault ? color.dim(" (default)") : "";
368
+ renderer.write(
369
+ ` ${color.dim(`${num}.`.padStart(5))} ${idLabel.padEnd(44)} ${color.dim(ctx)} ${color.dim(cost.padEnd(14))} ${capStr}${suffix}
370
+ `
371
+ );
372
+ }
373
+ offset += pageSize;
374
+ if (offset < models.length) {
375
+ const more = (await reader.readLine(
376
+ `
377
+ ${color.amber("?")} Showing ${Math.min(offset, models.length)}/${models.length} \u2014 Enter number or ${color.dim("Enter")} for more: `
378
+ )).trim();
379
+ if (!more) continue;
380
+ return resolveModelSelection(more, models, provider, registry, renderer);
381
+ }
382
+ }
383
+ const defaultHint = defaultIdxInModels >= 0 && defaultModel ? ` ${color.dim(`[Enter = ${defaultModel}]`)}` : "";
384
+ const answer = (await reader.readLine(`
385
+ ${color.amber("?")} Select model (1-${models.length})${defaultHint}: `)).trim();
386
+ if (!answer) {
387
+ if (defaultIdxInModels >= 0 && defaultModel) {
388
+ renderer.write(
389
+ `
390
+ ${color.green("\u2713")} ${color.bold(provider.id)} / ${color.bold(defaultModel)}
391
+
392
+ `
393
+ );
394
+ return { provider: provider.id, model: defaultModel };
395
+ }
396
+ renderer.write(color.dim("Cancelled.\n"));
397
+ return void 0;
365
398
  }
366
- };
367
- function renderMarkdown(s) {
368
- let out = s;
369
- out = out.replace(/^(#{1,6}) (.+)$/gm, (_m, hashes, text) => {
370
- return theme.primary(theme.bold(`${hashes} ${text}`));
371
- });
372
- out = out.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
373
- return color.gray(`
374
- \u250C\u2500
375
- ${code.replace(/^/gm, "\u2502 ")}\u2514\u2500`);
376
- });
377
- out = out.replace(/`([^`\n]+)`/g, (_m, code) => theme.accent(code));
378
- out = out.replace(/\*\*([^*]+)\*\*/g, (_m, text) => theme.bold(text));
379
- out = out.replace(/(^|[^*])\*([^*\n]+)\*([^*]|$)/g, (_m, l, t, r) => `${l}${color.italic(t)}${r}`);
380
- return out;
399
+ return resolveModelSelection(answer, models, provider, registry, renderer);
381
400
  }
382
- function formatInputSummary(input) {
383
- if (!input || typeof input !== "object") return "";
384
- const obj = input;
385
- if (typeof obj["path"] === "string") return obj["path"];
386
- if (typeof obj["url"] === "string") return obj["url"];
387
- if (typeof obj["command"] === "string") {
388
- const cmd = obj["command"];
389
- return cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
401
+ async function resolveModelSelection(answer, models, provider, _registry, renderer, _reader) {
402
+ const idx = parseInt(answer, 10);
403
+ let modelId;
404
+ if (!Number.isNaN(idx) && idx >= 1 && idx <= models.length) {
405
+ modelId = models[idx - 1].id;
406
+ } else {
407
+ const lower = answer.toLowerCase();
408
+ const match = models.find((m) => m.id.toLowerCase() === lower);
409
+ if (match) {
410
+ modelId = match.id;
411
+ } else {
412
+ const partial = models.filter((m) => m.id.toLowerCase().includes(lower));
413
+ if (partial.length === 1) {
414
+ modelId = partial[0].id;
415
+ } else if (partial.length > 1) {
416
+ renderer.writeError(`"${answer}" matches multiple models. Be more specific.`);
417
+ return void 0;
418
+ }
419
+ }
390
420
  }
391
- if (typeof obj["pattern"] === "string") return obj["pattern"];
392
- return "";
421
+ if (!modelId) {
422
+ modelId = answer;
423
+ }
424
+ renderer.write(
425
+ `
426
+ ${color.green("\u2713")} ${color.bold(provider.id)} / ${color.bold(modelId)}
427
+
428
+ `
429
+ );
430
+ return { provider: provider.id, model: modelId };
393
431
  }
394
- function safeStringify(value) {
395
- if (typeof value === "string") return value;
432
+ var theme2 = { primary: color.amber };
433
+ async function saveToGlobalConfig(configPath, provider, model) {
396
434
  try {
397
- return JSON.stringify(value);
435
+ const { atomicWrite: atomicWrite2 } = await import('@wrongstack/core');
436
+ const fs6 = await import('fs/promises');
437
+ let existing = {};
438
+ try {
439
+ const raw = await fs6.readFile(configPath, "utf8");
440
+ existing = JSON.parse(raw);
441
+ } catch {
442
+ }
443
+ existing.provider = provider;
444
+ existing.model = model;
445
+ await atomicWrite2(configPath, JSON.stringify(existing, null, 2));
446
+ return true;
398
447
  } catch {
399
- return String(value);
448
+ return false;
400
449
  }
401
450
  }
402
- function extractDiff(value) {
403
- if (typeof value === "object" && value !== null) {
404
- const d = value.diff;
405
- if (typeof d === "string" && d.length > 0) return d;
406
- }
407
- if (typeof value === "string") {
408
- const trimmed = value.trimStart();
409
- if (trimmed.startsWith("{")) {
451
+ function buildBuiltinSlashCommands(opts) {
452
+ return [
453
+ helpCommand(opts),
454
+ initCommand(opts),
455
+ clearCommand(opts),
456
+ compactCommand(opts),
457
+ contextCommand(opts),
458
+ usageCommand(opts),
459
+ toolsCommand(opts),
460
+ skillCommand(opts),
461
+ useCommand(opts),
462
+ modelCommand(opts),
463
+ diagCommand(opts),
464
+ statsCommand(opts),
465
+ saveCommand(opts),
466
+ loadCommand(opts),
467
+ exitCommand(opts)
468
+ ];
469
+ }
470
+ function initCommand(opts) {
471
+ return {
472
+ name: "init",
473
+ description: "Scaffold .wrongstack/AGENTS.md in the current project.",
474
+ async run(args, ctx) {
475
+ const force = args.trim() === "--force";
476
+ const dir = path2.join(ctx.projectRoot, ".wrongstack");
477
+ const file = path2.join(dir, "AGENTS.md");
410
478
  try {
411
- const parsed = JSON.parse(value);
412
- if (typeof parsed.diff === "string" && parsed.diff.length > 0) {
413
- return parsed.diff;
479
+ await fs2.access(file);
480
+ if (!force) {
481
+ const msg = `AGENTS.md already exists at ${file}. Use "/init --force" to overwrite.`;
482
+ opts.renderer.writeWarning(msg);
483
+ return { message: msg };
414
484
  }
415
485
  } catch {
416
486
  }
487
+ const detected = await detectProjectFacts(ctx.projectRoot);
488
+ const body = renderAgentsTemplate(detected);
489
+ await fs2.mkdir(dir, { recursive: true });
490
+ await fs2.writeFile(file, body, "utf8");
491
+ if (detected.hints.length > 0) {
492
+ const msg = `Wrote ${file}
493
+ Pre-filled: ${detected.hints.join(", ")}. Edit the file to add anything else worth remembering.`;
494
+ opts.renderer.writeInfo(`Wrote ${file}`);
495
+ opts.renderer.writeInfo(`Pre-filled: ${detected.hints.join(", ")}. Edit the file to add anything else worth remembering.`);
496
+ return { message: msg };
497
+ } else {
498
+ const msg = `Wrote ${file}
499
+ No project type auto-detected. Edit the file to add build/test commands and conventions.`;
500
+ opts.renderer.writeInfo(`Wrote ${file}`);
501
+ return { message: msg };
502
+ }
417
503
  }
418
- if (/^---[^\n]*\n\+\+\+/m.test(value)) return value;
419
- }
420
- return null;
504
+ };
421
505
  }
422
- function summarize(value, name) {
423
- let v = value;
424
- if (typeof value === "string") {
425
- const trimmed = value.trimStart();
426
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
427
- try {
428
- v = JSON.parse(value);
429
- } catch {
430
- }
431
- }
506
+ async function detectProjectFacts(root) {
507
+ const facts = { hints: [] };
508
+ try {
509
+ const pkg = JSON.parse(await fs2.readFile(path2.join(root, "package.json"), "utf8"));
510
+ const scripts = pkg.scripts ?? {};
511
+ const pm = (pkg.packageManager ?? "npm").split("@")[0] ?? "npm";
512
+ if (scripts["build"]) facts.build = `${pm} run build`;
513
+ if (scripts["test"]) facts.test = `${pm} test`;
514
+ if (scripts["lint"]) facts.lint = `${pm} run lint`;
515
+ if (scripts["dev"] ?? scripts["start"]) facts.run = `${pm} run ${scripts["dev"] ? "dev" : "start"}`;
516
+ facts.hints.push("package.json scripts");
517
+ } catch {
432
518
  }
433
- if (typeof v === "object" && v !== null) {
434
- const o = v;
435
- if (name === "edit") {
436
- const path5 = typeof o["path"] === "string" ? o["path"] : "";
437
- const reps = typeof o["replacements"] === "number" ? o["replacements"] : 0;
438
- return `${path5} ${reps} replacement${reps === 1 ? "" : "s"}`.trim();
439
- }
440
- if (name === "write") {
441
- const path5 = typeof o["path"] === "string" ? o["path"] : "";
442
- const bytes = typeof o["bytes"] === "number" ? o["bytes"] : void 0;
443
- return bytes !== void 0 ? `${path5} ${bytes}B` : path5;
444
- }
445
- if (typeof o["count"] === "number") {
446
- return `${o["count"]} match${o["count"] === 1 ? "" : "es"}`;
447
- }
519
+ try {
520
+ await fs2.access(path2.join(root, "pyproject.toml"));
521
+ facts.test ??= "pytest";
522
+ facts.lint ??= "ruff check .";
523
+ facts.hints.push("pyproject.toml");
524
+ } catch {
448
525
  }
449
- return "";
526
+ try {
527
+ await fs2.access(path2.join(root, "go.mod"));
528
+ facts.build ??= "go build ./...";
529
+ facts.test ??= "go test ./...";
530
+ facts.hints.push("go.mod");
531
+ } catch {
532
+ }
533
+ try {
534
+ await fs2.access(path2.join(root, "Cargo.toml"));
535
+ facts.build ??= "cargo build";
536
+ facts.test ??= "cargo test";
537
+ facts.hints.push("Cargo.toml");
538
+ } catch {
539
+ }
540
+ try {
541
+ await fs2.access(path2.join(root, "Makefile"));
542
+ facts.build ??= "make";
543
+ facts.test ??= "make test";
544
+ facts.hints.push("Makefile");
545
+ } catch {
546
+ }
547
+ return facts;
450
548
  }
451
- async function runRepl(opts) {
452
- if (opts.banner !== false) printBanner(opts.renderer);
453
- let activeCtrl;
454
- let interrupts = 0;
455
- const onSigint = () => {
456
- interrupts++;
457
- if (interrupts >= 2) {
458
- opts.renderer.writeWarning("Exiting.");
459
- process.exit(130);
460
- }
461
- if (activeCtrl) {
462
- activeCtrl.abort();
463
- opts.renderer.writeWarning("Iteration cancelled. Press Ctrl+C again to exit.");
464
- } else {
465
- opts.renderer.writeWarning("Press Ctrl+C again to exit.");
549
+ function renderAgentsTemplate(f) {
550
+ const cmd = (s) => s ? `\`${s}\`` : "_TODO_";
551
+ return `# AGENTS.md
552
+
553
+ Project notes for WrongStack. Committed to the repo so every contributor
554
+ (human or agent) starts with the same context. Edit freely.
555
+
556
+ ## What this project is
557
+
558
+ _One paragraph: what does this codebase do, who runs it, what's the
559
+ deployment target?_
560
+
561
+ ## How to work on it
562
+
563
+ - **Build:** ${cmd(f.build)}
564
+ - **Test:** ${cmd(f.test)}
565
+ - **Lint:** ${cmd(f.lint)}
566
+ - **Run locally:** ${cmd(f.run)}
567
+
568
+ ## Conventions
569
+
570
+ _What style choices matter here? Filenames, module layout, naming, error
571
+ handling, log format. Anything a stranger would get wrong._
572
+
573
+ ## Domain knowledge
574
+
575
+ _Acronyms, business rules, foot-guns, "this looks weird but it's
576
+ intentional because\u2026"._
577
+
578
+ ## Pointers
579
+
580
+ _Where to look for: routing, database migrations, feature flags,
581
+ on-call runbooks, dashboards._
582
+ `;
583
+ }
584
+ function diagCommand(opts) {
585
+ return {
586
+ name: "diag",
587
+ description: "Show runtime diagnostics (provider, tokens, tools, MCP).",
588
+ async run() {
589
+ if (opts.onDiag) {
590
+ opts.onDiag();
591
+ return { message: "diag" };
592
+ } else {
593
+ return { message: "Diag not available in this context." };
594
+ }
466
595
  }
467
596
  };
468
- process.on("SIGINT", onSigint);
469
- const builder = new InputBuilder({ store: opts.attachments });
470
- for (; ; ) {
471
- let raw;
472
- try {
473
- raw = await readPossiblyMultiline(opts);
474
- } catch {
475
- break;
476
- }
477
- const trimmed = raw.trim();
478
- if (!trimmed) {
479
- interrupts = 0;
480
- continue;
481
- }
482
- interrupts = 0;
483
- if (trimmed.startsWith("/")) {
484
- try {
485
- const res = await opts.slashRegistry.dispatch(trimmed, opts.agent.ctx);
486
- if (res?.message) opts.renderer.write(`${res.message}
487
- `);
488
- if (res?.exit) break;
489
- } catch (err) {
490
- opts.renderer.writeError(err instanceof Error ? err.message : String(err));
597
+ }
598
+ function statsCommand(opts) {
599
+ return {
600
+ name: "stats",
601
+ description: "Show session report: tokens, requests, tools, files, cost.",
602
+ async run() {
603
+ if (opts.onStats) {
604
+ opts.onStats();
605
+ return { message: "stats" };
606
+ } else {
607
+ return { message: "Stats not available in this context." };
491
608
  }
492
- continue;
493
- }
494
- const ph = await builder.appendPaste(raw);
495
- if (ph) {
496
- const lineCount = raw.split("\n").length;
497
- opts.renderer.write(color.dim(` \u21B3 ${ph} (${lineCount} lines)
498
- `));
499
609
  }
500
- const blocks = await builder.submit();
501
- const runCtrl = new AbortController();
502
- activeCtrl = runCtrl;
503
- try {
504
- const startedAt = Date.now();
505
- const before = opts.tokenCounter?.total();
506
- const costBefore = opts.tokenCounter?.estimateCost().total ?? 0;
507
- const result = await opts.agent.run(blocks, { signal: runCtrl.signal });
508
- if (result.status === "aborted") {
509
- opts.renderer.writeWarning("Aborted.");
510
- } else if (result.status === "failed") {
511
- opts.renderer.writeError(
512
- `Failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`
513
- );
514
- } else if (result.status === "max_iterations") {
515
- opts.renderer.writeWarning(`Hit max iterations (${result.iterations}).`);
516
- }
517
- if (opts.tokenCounter && before) {
518
- const after = opts.tokenCounter.total();
519
- const costAfter = opts.tokenCounter.estimateCost().total;
520
- const ctxChip = opts.effectiveMaxContext && opts.effectiveMaxContext > 0 ? ` ctx: ${renderContextChip(after.input, opts.effectiveMaxContext)}` : "";
521
- opts.renderer.write(
522
- `
523
- ${color.dim(
524
- `[in: ${fmtTok(after.input - before.input)} out: ${fmtTok(after.output - before.output)} iters: ${result.iterations} cost: ${(costAfter - costBefore).toFixed(4)} ${((Date.now() - startedAt) / 1e3).toFixed(1)}s]${ctxChip}`
525
- )}
526
- `
527
- );
610
+ };
611
+ }
612
+ function helpCommand(opts) {
613
+ return {
614
+ name: "help",
615
+ description: "Show available slash commands.",
616
+ async run() {
617
+ const lines = ["Available slash commands:"];
618
+ for (const { cmd, owner, fullName } of opts.registry.listWithOwner()) {
619
+ const isBuiltin = owner === "core";
620
+ const prefix = isBuiltin ? "" : `${owner}:`;
621
+ const aliases = cmd.aliases ? cmd.aliases.map((a) => `/${prefix}${a}`).join(", ") : "";
622
+ const aliasStr = aliases ? ` (${aliases})` : "";
623
+ lines.push(` /${prefix}${cmd.name}${aliasStr} \u2014 ${cmd.description}`);
528
624
  }
529
- } catch (err) {
530
- opts.renderer.writeError(err instanceof Error ? err.message : String(err));
531
- } finally {
532
- activeCtrl = void 0;
625
+ return { message: lines.join("\n") };
533
626
  }
534
- }
535
- process.off("SIGINT", onSigint);
536
- await opts.reader.close();
537
- return 0;
627
+ };
538
628
  }
539
- async function readPossiblyMultiline(opts) {
540
- const firstPrompt = theme.primary("\u203A ");
541
- const contPrompt = color.dim("\xB7 ");
542
- const first = await opts.reader.readLine(firstPrompt);
543
- if (first.trim() === '"""') {
544
- const parts = [];
545
- for (; ; ) {
546
- const next = await opts.reader.readLine(contPrompt);
547
- if (next.trim() === '"""') break;
548
- parts.push(next);
629
+ function clearCommand(opts) {
630
+ return {
631
+ name: "clear",
632
+ description: "Reset the session and start a new one.",
633
+ async run(_args, ctx) {
634
+ if (ctx) {
635
+ ctx.messages = [];
636
+ ctx.todos = [];
637
+ ctx.readFiles.clear();
638
+ ctx.fileMtimes.clear();
639
+ ctx.meta = {};
640
+ }
641
+ await opts.memoryStore?.clear();
642
+ opts.onClear?.();
643
+ opts.renderer.clear();
644
+ const msg = "Session cleared (context, memory, and history reset).";
645
+ opts.renderer.writeInfo(msg);
646
+ return { message: msg };
549
647
  }
550
- return parts.join("\n");
551
- }
552
- let buf = first;
553
- while (buf.endsWith("\\")) {
554
- buf = buf.slice(0, -1);
555
- const cont = await opts.reader.readLine(contPrompt);
556
- buf += "\n" + cont;
557
- }
558
- return buf;
648
+ };
559
649
  }
560
- function fmtTok(n) {
561
- if (n < 1e3) return String(n);
562
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
563
- return `${(n / 1e6).toFixed(1)}M`;
564
- }
565
- var FILLED = "\u2588";
566
- var EMPTY = "\u2591";
567
- function renderContextChip(used, max) {
568
- const ratio = Math.max(0, Math.min(1, used / max));
569
- const pct = Math.round(ratio * 100);
570
- const bar = renderProgress(ratio, 6);
571
- return `${bar} ${pct}% (${fmtTok(used)}/${fmtTok(max)})`;
572
- }
573
- function renderProgress(ratio, width) {
574
- const clamped = Math.max(0, Math.min(1, ratio));
575
- const filled = clamped === 0 ? 0 : Math.max(1, Math.round(clamped * width));
576
- const capped = Math.min(width, filled);
577
- return FILLED.repeat(capped) + EMPTY.repeat(width - capped);
578
- }
579
- function printBanner(renderer) {
580
- const lines = [
581
- theme.primary(theme.bold("WrongStack")) + color.dim(" v0.0.1"),
582
- color.dim("Built on the wrong stack. Shipped anyway."),
583
- color.dim("Type /help for commands, /exit to quit."),
584
- ""
585
- ];
586
- renderer.write(`${lines.join("\n")}
650
+ function contextCommand(opts) {
651
+ return {
652
+ name: "context",
653
+ aliases: ["ctx"],
654
+ description: "Show context window summary.",
655
+ async run(args, ctx) {
656
+ const messages = ctx.messages;
657
+ const detailed = args.trim() === "detail";
658
+ const pairCount = countTurnPairs(messages);
659
+ const estimatedTokens = estimateTokens(messages);
660
+ const toolUseCount = countToolUses(messages);
661
+ const toolResultCount = countToolResults(messages);
662
+ const lines = [
663
+ `${color.bold("Context Window")}`,
664
+ ` messages: ${messages.length} total (${pairCount} user+assistant pairs)`,
665
+ ` tokens (\u2248): ${estimatedTokens.toLocaleString()} (chars \xF7 4 estimate)`,
666
+ ` system prompt: ${ctx.systemPrompt.length} block${ctx.systemPrompt.length !== 1 ? "s" : ""}`,
667
+ ` tools: ${toolUseCount} calls made, ${toolResultCount} results in history`,
668
+ ` read files: ${ctx.readFiles.size} files`,
669
+ ` todos: ${ctx.todos.filter((t) => t.status === "in_progress").length} in_progress / ${ctx.todos.filter((t) => t.status === "pending").length} pending / ${ctx.todos.filter((t) => t.status === "completed").length} completed`
670
+ ];
671
+ if (detailed) {
672
+ lines.push(` model: ${ctx.model}`);
673
+ lines.push(` cwd: ${ctx.cwd}`);
674
+ lines.push(` projectRoot: ${ctx.projectRoot}`);
675
+ lines.push(` file mtimes: ${ctx.fileMtimes.size} tracked`);
676
+ if (ctx.readFiles.size > 0) {
677
+ lines.push(` file list: ${[...ctx.readFiles].join(", ")}`);
678
+ }
679
+ }
680
+ const msg = lines.join("\n");
681
+ opts.renderer.write(`${msg}
587
682
  `);
683
+ return { message: msg };
684
+ }
685
+ };
588
686
  }
589
- var SessionStats = class {
590
- tokenCounter;
591
- startedAt = Date.now();
592
- apiRequests = 0;
593
- iterations = 0;
594
- errors = 0;
595
- toolStats = /* @__PURE__ */ new Map();
596
- readPaths = /* @__PURE__ */ new Set();
597
- editedPaths = /* @__PURE__ */ new Set();
598
- writtenPaths = /* @__PURE__ */ new Set();
599
- bytesWritten = 0;
600
- bashCommands = 0;
601
- fetches = 0;
602
- constructor(events, tokenCounter) {
603
- this.tokenCounter = tokenCounter;
604
- events.on("provider.response", () => {
605
- this.apiRequests++;
606
- });
607
- events.on("iteration.completed", () => {
608
- this.iterations++;
609
- });
610
- events.on("error", () => {
611
- this.errors++;
612
- });
613
- events.on("tool.executed", (e) => {
614
- const slot = this.toolStats.get(e.name) ?? { ok: 0, fail: 0, totalMs: 0 };
615
- if (e.ok) slot.ok++;
616
- else slot.fail++;
617
- slot.totalMs += e.durationMs;
618
- this.toolStats.set(e.name, slot);
619
- const input = e.input;
620
- if (e.name === "bash") this.bashCommands++;
621
- else if (e.name === "fetch") this.fetches++;
622
- if (!e.ok) return;
623
- const path5 = typeof input?.path === "string" ? input.path : void 0;
624
- if (e.name === "read" && path5) this.readPaths.add(path5);
625
- else if (e.name === "edit" && path5) this.editedPaths.add(path5);
626
- else if (e.name === "write" && path5) {
627
- this.writtenPaths.add(path5);
628
- const content = typeof input?.content === "string" ? input.content : "";
629
- this.bytesWritten += Buffer.byteLength(content, "utf8");
630
- }
631
- });
632
- }
633
- hasActivity() {
634
- return this.apiRequests > 0 || this.iterations > 0 || this.toolStats.size > 0 || this.tokenCounter.total().input > 0;
687
+ function countTurnPairs(messages) {
688
+ let count = 0;
689
+ for (const m of messages) {
690
+ if (m.role === "user" || m.role === "assistant") count++;
635
691
  }
636
- render(renderer) {
637
- if (!this.hasActivity()) return;
638
- const u = this.tokenCounter.total();
639
- const cost = this.tokenCounter.estimateCost();
640
- const elapsedSec = ((Date.now() - this.startedAt) / 1e3).toFixed(1);
641
- const lines = [];
642
- lines.push("");
643
- lines.push(color.bold("Session report"));
644
- lines.push(color.dim("\u2500".repeat(40)));
645
- lines.push(` Elapsed: ${elapsedSec}s`);
646
- lines.push(` Iterations: ${this.iterations}`);
647
- lines.push(` API requests: ${this.apiRequests}`);
648
- if (this.errors > 0) {
649
- lines.push(` Errors: ${color.yellow(String(this.errors))}`);
650
- }
651
- lines.push("");
652
- lines.push(` Tokens: in ${fmtTok2(u.input)} out ${fmtTok2(u.output)}${u.cacheRead ? ` cacheR ${fmtTok2(u.cacheRead)}` : ""}${u.cacheWrite ? ` cacheW ${fmtTok2(u.cacheWrite)}` : ""}`);
653
- const cache = this.tokenCounter.cacheStats();
654
- if (cache.readTokens > 0 || cache.writeTokens > 0) {
655
- const pct = (cache.hitRatio * 100).toFixed(1);
656
- lines.push(
657
- ` Prompt cache: ${pct}% hit ${color.dim(`(${fmtTok2(cache.readTokens)} read / ${fmtTok2(cache.writeTokens)} write)`)}`
658
- );
659
- }
660
- if (cost.total > 0) {
661
- lines.push(` Cost: $${cost.total.toFixed(4)}${color.dim(` (in $${cost.input.toFixed(4)} / out $${cost.output.toFixed(4)})`)}`);
662
- } else {
663
- lines.push(` Cost: ${color.dim("$0 (no pricing on this plan)")}`);
692
+ return Math.floor(count / 2);
693
+ }
694
+ function countToolUses(messages) {
695
+ let count = 0;
696
+ for (const m of messages) {
697
+ const content = m.content;
698
+ if (Array.isArray(content)) {
699
+ count += content.filter((b) => b.type === "tool_use").length;
664
700
  }
665
- if (this.toolStats.size > 0) {
666
- lines.push("");
667
- lines.push(` ${color.bold("Tool calls")}`);
668
- const sorted = [...this.toolStats.entries()].sort(
669
- (a, b) => b[1].ok + b[1].fail - (a[1].ok + a[1].fail)
670
- );
671
- for (const [name, s] of sorted) {
672
- const total = s.ok + s.fail;
673
- const failPart = s.fail > 0 ? color.yellow(` (${s.fail} failed)`) : "";
674
- const avgMs = total > 0 ? Math.round(s.totalMs / total) : 0;
675
- lines.push(` ${name.padEnd(12)} ${String(total).padStart(3)}\xD7 ${color.dim(`avg ${avgMs}ms`)}${failPart}`);
676
- }
701
+ }
702
+ return count;
703
+ }
704
+ function countToolResults(messages) {
705
+ let count = 0;
706
+ for (const m of messages) {
707
+ const content = m.content;
708
+ if (Array.isArray(content)) {
709
+ count += content.filter((b) => b.type === "tool_result").length;
677
710
  }
678
- const fileActivity = this.readPaths.size > 0 || this.editedPaths.size > 0 || this.writtenPaths.size > 0 || this.bytesWritten > 0;
679
- if (fileActivity) {
680
- lines.push("");
681
- lines.push(` ${color.bold("Files")}`);
682
- if (this.readPaths.size > 0)
683
- lines.push(` read: ${this.readPaths.size} ${color.dim(samplePaths(this.readPaths))}`);
684
- if (this.editedPaths.size > 0)
685
- lines.push(` edited: ${this.editedPaths.size} ${color.dim(samplePaths(this.editedPaths))}`);
686
- if (this.writtenPaths.size > 0) {
687
- const bytes = this.bytesWritten;
688
- const byteStr = bytes > 1024 ? `${(bytes / 1024).toFixed(1)}KB` : `${bytes}B`;
689
- lines.push(` written: ${this.writtenPaths.size} (${byteStr}) ${color.dim(samplePaths(this.writtenPaths))}`);
711
+ }
712
+ return count;
713
+ }
714
+ function estimateTokens(messages) {
715
+ let total = 0;
716
+ for (const m of messages) {
717
+ const content = m.content;
718
+ if (typeof content === "string") {
719
+ total += Math.ceil(content.length / 4);
720
+ } else if (Array.isArray(content)) {
721
+ for (const b of content) {
722
+ if (b.type === "text") total += Math.ceil(b.text.length / 4);
723
+ else if (b.type === "tool_use" || b.type === "tool_result") {
724
+ total += Math.ceil(JSON.stringify(b).length / 4);
725
+ }
690
726
  }
691
727
  }
692
- if (this.bashCommands > 0 || this.fetches > 0) {
693
- lines.push("");
694
- if (this.bashCommands > 0) lines.push(` Shell commands: ${this.bashCommands}`);
695
- if (this.fetches > 0) lines.push(` Web fetches: ${this.fetches}`);
696
- }
697
- lines.push("");
698
- renderer.write(`${lines.join("\n")}
699
- `);
700
728
  }
701
- };
702
- function fmtTok2(n) {
703
- if (n < 1e3) return String(n);
704
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
705
- return `${(n / 1e6).toFixed(1)}M`;
706
- }
707
- function samplePaths(set) {
708
- const arr = [...set];
709
- if (arr.length <= 2) return arr.join(", ");
710
- return `${arr[0]}, \u2026 (+${arr.length - 1} more)`;
711
- }
712
- function buildBuiltinSlashCommands(opts) {
713
- return [
714
- helpCommand(opts),
715
- initCommand(opts),
716
- clearCommand(opts),
717
- compactCommand(opts),
718
- contextCommand(opts),
719
- usageCommand(opts),
720
- toolsCommand(opts),
721
- skillCommand(opts),
722
- useCommand(opts),
723
- modelCommand(opts),
724
- diagCommand(opts),
725
- statsCommand(opts),
726
- saveCommand(opts),
727
- loadCommand(opts),
728
- exitCommand(opts)
729
- ];
729
+ return total;
730
730
  }
731
- function initCommand(opts) {
731
+ function compactCommand(opts) {
732
732
  return {
733
- name: "init",
734
- description: "Scaffold .wrongstack/AGENTS.md in the current project.",
733
+ name: "compact",
734
+ description: "Compact the context window.",
735
735
  async run(args, ctx) {
736
- const force = args.trim() === "--force";
737
- const dir = path2.join(ctx.projectRoot, ".wrongstack");
738
- const file = path2.join(dir, "AGENTS.md");
739
- try {
740
- await fs2.access(file);
741
- if (!force) {
742
- const msg = `AGENTS.md already exists at ${file}. Use "/init --force" to overwrite.`;
743
- opts.renderer.writeWarning(msg);
744
- return { message: msg };
745
- }
746
- } catch {
747
- }
748
- const detected = await detectProjectFacts(ctx.projectRoot);
749
- const body = renderAgentsTemplate(detected);
750
- await fs2.mkdir(dir, { recursive: true });
751
- await fs2.writeFile(file, body, "utf8");
752
- if (detected.hints.length > 0) {
753
- const msg = `Wrote ${file}
754
- Pre-filled: ${detected.hints.join(", ")}. Edit the file to add anything else worth remembering.`;
755
- opts.renderer.writeInfo(`Wrote ${file}`);
756
- opts.renderer.writeInfo(`Pre-filled: ${detected.hints.join(", ")}. Edit the file to add anything else worth remembering.`);
757
- return { message: msg };
758
- } else {
759
- const msg = `Wrote ${file}
760
- No project type auto-detected. Edit the file to add build/test commands and conventions.`;
761
- opts.renderer.writeInfo(`Wrote ${file}`);
762
- return { message: msg };
736
+ if (!opts.compactor) {
737
+ const msg2 = "No compactor configured.";
738
+ opts.renderer.writeWarning(msg2);
739
+ return { message: msg2 };
763
740
  }
741
+ const aggressive = args.trim() === "aggressive";
742
+ const report = await opts.compactor.compact(ctx, { aggressive });
743
+ const msg = `Compaction: ${report.before} \u2192 ${report.after} tokens (${report.reductions.map((r) => `${r.phase}: ${r.saved}`).join(", ")})`;
744
+ opts.renderer.writeInfo(msg);
745
+ return { message: msg };
764
746
  }
765
747
  };
766
748
  }
767
- async function detectProjectFacts(root) {
768
- const facts = { hints: [] };
769
- try {
770
- const pkg = JSON.parse(await fs2.readFile(path2.join(root, "package.json"), "utf8"));
771
- const scripts = pkg.scripts ?? {};
772
- const pm = (pkg.packageManager ?? "npm").split("@")[0] ?? "npm";
773
- if (scripts["build"]) facts.build = `${pm} run build`;
774
- if (scripts["test"]) facts.test = `${pm} test`;
775
- if (scripts["lint"]) facts.lint = `${pm} run lint`;
776
- if (scripts["dev"] ?? scripts["start"]) facts.run = `${pm} run ${scripts["dev"] ? "dev" : "start"}`;
777
- facts.hints.push("package.json scripts");
778
- } catch {
779
- }
780
- try {
781
- await fs2.access(path2.join(root, "pyproject.toml"));
782
- facts.test ??= "pytest";
783
- facts.lint ??= "ruff check .";
784
- facts.hints.push("pyproject.toml");
785
- } catch {
786
- }
787
- try {
788
- await fs2.access(path2.join(root, "go.mod"));
789
- facts.build ??= "go build ./...";
790
- facts.test ??= "go test ./...";
791
- facts.hints.push("go.mod");
792
- } catch {
793
- }
794
- try {
795
- await fs2.access(path2.join(root, "Cargo.toml"));
796
- facts.build ??= "cargo build";
797
- facts.test ??= "cargo test";
798
- facts.hints.push("Cargo.toml");
799
- } catch {
800
- }
801
- try {
802
- await fs2.access(path2.join(root, "Makefile"));
803
- facts.build ??= "make";
804
- facts.test ??= "make test";
805
- facts.hints.push("Makefile");
806
- } catch {
807
- }
808
- return facts;
809
- }
810
- function renderAgentsTemplate(f) {
811
- const cmd = (s) => s ? `\`${s}\`` : "_TODO_";
812
- return `# AGENTS.md
813
-
814
- Project notes for WrongStack. Committed to the repo so every contributor
815
- (human or agent) starts with the same context. Edit freely.
816
-
817
- ## What this project is
818
-
819
- _One paragraph: what does this codebase do, who runs it, what's the
820
- deployment target?_
821
-
822
- ## How to work on it
823
-
824
- - **Build:** ${cmd(f.build)}
825
- - **Test:** ${cmd(f.test)}
826
- - **Lint:** ${cmd(f.lint)}
827
- - **Run locally:** ${cmd(f.run)}
828
-
829
- ## Conventions
830
-
831
- _What style choices matter here? Filenames, module layout, naming, error
832
- handling, log format. Anything a stranger would get wrong._
833
-
834
- ## Domain knowledge
835
-
836
- _Acronyms, business rules, foot-guns, "this looks weird but it's
837
- intentional because\u2026"._
838
-
839
- ## Pointers
840
-
841
- _Where to look for: routing, database migrations, feature flags,
842
- on-call runbooks, dashboards._
749
+ function usageCommand(opts) {
750
+ return {
751
+ name: "usage",
752
+ aliases: ["cost"],
753
+ description: "Show token usage and estimated cost.",
754
+ async run() {
755
+ const total = opts.tokenCounter.total();
756
+ const cost = opts.tokenCounter.estimateCost();
757
+ const msg = `${color.bold("Usage")}
758
+ input: ${total.input}
759
+ output: ${total.output}
760
+ cache read: ${total.cacheRead ?? 0}
761
+ cache write: ${total.cacheWrite ?? 0}
762
+ cost: $${cost.total.toFixed(4)} (input $${cost.input.toFixed(4)} / output $${cost.output.toFixed(4)})
843
763
  `;
764
+ opts.renderer.write(msg);
765
+ return { message: msg };
766
+ }
767
+ };
844
768
  }
845
- function diagCommand(opts) {
769
+ function toolsCommand(opts) {
846
770
  return {
847
- name: "diag",
848
- description: "Show runtime diagnostics (provider, tokens, tools, MCP).",
771
+ name: "tools",
772
+ description: "List registered tools.",
849
773
  async run() {
850
- if (opts.onDiag) {
851
- opts.onDiag();
852
- return { message: "diag" };
774
+ const all = opts.toolRegistry.listWithOwner();
775
+ const lines = all.map(({ tool, owner }) => {
776
+ return ` ${tool.name.padEnd(28)} ${color.dim(`[${owner}]`)} ${tool.mutating ? color.yellow("mut") : color.cyan("ro")} ${color.dim(tool.permission)}`;
777
+ });
778
+ const msg = `${color.bold("Tools")} (${all.length}):
779
+ ${lines.join("\n")}
780
+ `;
781
+ opts.renderer.write(msg);
782
+ return { message: msg };
783
+ }
784
+ };
785
+ }
786
+ function skillCommand(opts) {
787
+ return {
788
+ name: "skill",
789
+ description: "Show a skill manifest or list skills.",
790
+ async run(args) {
791
+ if (!opts.skillLoader) {
792
+ const msg = "No skill loader configured.";
793
+ return { message: msg };
794
+ }
795
+ if (!args.trim()) {
796
+ const list = await opts.skillLoader.list();
797
+ if (list.length === 0) {
798
+ const msg2 = "No skills found.";
799
+ return { message: msg2 };
800
+ }
801
+ const lines = list.map((s) => ` ${s.name.padEnd(24)} ${color.dim(`[${s.source}]`)} ${s.description.split("\n")[0]}`);
802
+ const msg = `Skills:
803
+ ${lines.join("\n")}
804
+ `;
805
+ return { message: msg };
853
806
  } else {
854
- return { message: "Diag not available in this context." };
807
+ const skill = await opts.skillLoader.find(args.trim());
808
+ if (!skill) {
809
+ const msg = `Skill "${args.trim()}" not found.`;
810
+ return { message: msg };
811
+ }
812
+ const body = await opts.skillLoader.readBody(skill.name);
813
+ return { message: body };
855
814
  }
856
815
  }
857
816
  };
858
817
  }
859
- function statsCommand(opts) {
818
+ function useCommand(opts) {
860
819
  return {
861
- name: "stats",
862
- description: "Show session report: tokens, requests, tools, files, cost.",
863
- async run() {
864
- if (opts.onStats) {
865
- opts.onStats();
866
- return { message: "stats" };
867
- } else {
868
- return { message: "Stats not available in this context." };
820
+ name: "use",
821
+ description: "Switch provider mid-session: /use <provider>",
822
+ async run(args) {
823
+ const name = args.trim();
824
+ if (!name) {
825
+ const msg2 = "Usage: /use <provider-name>";
826
+ return { message: msg2 };
869
827
  }
828
+ opts.onSwitchProvider?.(name);
829
+ const msg = `Switched provider to "${name}".`;
830
+ return { message: msg };
870
831
  }
871
832
  };
872
833
  }
873
- function helpCommand(opts) {
834
+ function modelCommand(opts) {
874
835
  return {
875
- name: "help",
876
- description: "Show available slash commands.",
877
- async run() {
878
- const lines = ["Available slash commands:"];
879
- for (const { cmd, owner, fullName } of opts.registry.listWithOwner()) {
880
- const isBuiltin = owner === "core";
881
- const prefix = isBuiltin ? "" : `${owner}:`;
882
- const aliases = cmd.aliases ? cmd.aliases.map((a) => `/${prefix}${a}`).join(", ") : "";
883
- const aliasStr = aliases ? ` (${aliases})` : "";
884
- lines.push(` /${prefix}${cmd.name}${aliasStr} \u2014 ${cmd.description}`);
836
+ name: "model",
837
+ description: "Switch model mid-session: /model <model>",
838
+ async run(args) {
839
+ const name = args.trim();
840
+ if (!name) {
841
+ const msg2 = "Usage: /model <model-name>";
842
+ return { message: msg2 };
885
843
  }
886
- return { message: lines.join("\n") };
844
+ opts.onSwitchModel?.(name);
845
+ const msg = `Switched model to "${name}".`;
846
+ return { message: msg };
887
847
  }
888
848
  };
889
849
  }
890
- function clearCommand(opts) {
850
+ function saveCommand(opts) {
891
851
  return {
892
- name: "clear",
893
- description: "Reset the session and start a new one.",
852
+ name: "save",
853
+ description: "Save current session (auto by default; this forces flush).",
894
854
  async run(_args, ctx) {
895
- if (ctx) {
896
- ctx.messages = [];
897
- ctx.todos = [];
898
- ctx.readFiles.clear();
899
- ctx.fileMtimes.clear();
900
- ctx.meta = {};
855
+ await ctx.session.append({
856
+ type: "session_end",
857
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
858
+ usage: opts.tokenCounter.total()
859
+ });
860
+ const msg = `Session ${ctx.session.id} flushed.`;
861
+ return { message: msg };
862
+ }
863
+ };
864
+ }
865
+ function loadCommand(opts) {
866
+ return {
867
+ name: "resume",
868
+ aliases: ["load", "sessions"],
869
+ description: "List recent sessions. To actually resume, exit and run `wstack resume <id>`.",
870
+ async run() {
871
+ if (!opts.sessionStore) {
872
+ const msg2 = "No session store configured.";
873
+ return { message: msg2 };
901
874
  }
902
- await opts.memoryStore?.clear();
903
- opts.onClear?.();
904
- opts.renderer.clear();
905
- const msg = "Session cleared (context, memory, and history reset).";
906
- opts.renderer.writeInfo(msg);
875
+ const list = await opts.sessionStore.list(10);
876
+ if (list.length === 0) {
877
+ const msg2 = "No saved sessions.";
878
+ return { message: msg2 };
879
+ }
880
+ const lines = list.map(
881
+ (s) => ` ${s.id} ${color.dim(s.startedAt)} ${color.dim(`${s.tokenTotal} tok`)} ${s.title}`
882
+ );
883
+ const msg = `Recent sessions:
884
+ ${lines.join("\n")}
885
+
886
+ ` + color.dim(`Resume one with: wstack resume ${list[0]?.id ?? "<id>"}
887
+ `);
888
+ opts.renderer.write(msg);
907
889
  return { message: msg };
908
890
  }
909
891
  };
910
892
  }
911
- function contextCommand(opts) {
893
+ function exitCommand(opts) {
912
894
  return {
913
- name: "context",
914
- aliases: ["ctx"],
915
- description: "Show context window summary.",
916
- async run(args, ctx) {
917
- const messages = ctx.messages;
918
- const detailed = args.trim() === "detail";
919
- const pairCount = countTurnPairs(messages);
920
- const estimatedTokens = estimateTokens(messages);
921
- const toolUseCount = countToolUses(messages);
922
- const toolResultCount = countToolResults(messages);
923
- const lines = [
924
- `${color.bold("Context Window")}`,
925
- ` messages: ${messages.length} total (${pairCount} user+assistant pairs)`,
926
- ` tokens (\u2248): ${estimatedTokens.toLocaleString()} (chars \xF7 4 estimate)`,
927
- ` system prompt: ${ctx.systemPrompt.length} block${ctx.systemPrompt.length !== 1 ? "s" : ""}`,
928
- ` tools: ${toolUseCount} calls made, ${toolResultCount} results in history`,
929
- ` read files: ${ctx.readFiles.size} files`,
930
- ` todos: ${ctx.todos.filter((t) => t.status === "in_progress").length} in_progress / ${ctx.todos.filter((t) => t.status === "pending").length} pending / ${ctx.todos.filter((t) => t.status === "completed").length} completed`
931
- ];
932
- if (detailed) {
933
- lines.push(` model: ${ctx.model}`);
934
- lines.push(` cwd: ${ctx.cwd}`);
935
- lines.push(` projectRoot: ${ctx.projectRoot}`);
936
- lines.push(` file mtimes: ${ctx.fileMtimes.size} tracked`);
937
- if (ctx.readFiles.size > 0) {
938
- lines.push(` file list: ${[...ctx.readFiles].join(", ")}`);
895
+ name: "exit",
896
+ aliases: ["quit", "q"],
897
+ description: "Exit the REPL.",
898
+ async run() {
899
+ opts.onExit?.();
900
+ return { exit: true };
901
+ }
902
+ };
903
+ }
904
+
905
+ // src/pre-launch.ts
906
+ var MANIFESTS = [
907
+ "package.json",
908
+ "pyproject.toml",
909
+ "Cargo.toml",
910
+ "go.mod",
911
+ "Makefile",
912
+ "pom.xml",
913
+ "build.gradle",
914
+ "build.gradle.kts",
915
+ "composer.json",
916
+ "Gemfile"
917
+ ];
918
+ async function detectProjectKind(projectRoot) {
919
+ try {
920
+ await fs2.access(path2.join(projectRoot, ".wrongstack", "AGENTS.md"));
921
+ return "initialized";
922
+ } catch {
923
+ }
924
+ for (const m of MANIFESTS) {
925
+ try {
926
+ await fs2.access(path2.join(projectRoot, m));
927
+ return "project";
928
+ } catch {
929
+ }
930
+ }
931
+ return "empty";
932
+ }
933
+ async function scaffoldAgentsMd(projectRoot) {
934
+ const dir = path2.join(projectRoot, ".wrongstack");
935
+ const file = path2.join(dir, "AGENTS.md");
936
+ const facts = await detectProjectFacts(projectRoot);
937
+ const body = renderAgentsTemplate(facts);
938
+ await fs2.mkdir(dir, { recursive: true });
939
+ await fs2.writeFile(file, body, "utf8");
940
+ return file;
941
+ }
942
+ async function runProjectCheck(opts) {
943
+ const { projectRoot, renderer, reader } = opts;
944
+ const kind = await detectProjectKind(projectRoot);
945
+ if (kind === "initialized") {
946
+ renderer.write(
947
+ `
948
+ ${color.green("\u2713")} Project initialized ${color.dim(`(${path2.join(projectRoot, ".wrongstack", "AGENTS.md")})`)}
949
+ `
950
+ );
951
+ return true;
952
+ }
953
+ if (kind === "project") {
954
+ renderer.write(
955
+ `
956
+ ${color.amber("\u25CF")} Project detected ${color.dim(`(${projectRoot})`)} but ${color.bold(".wrongstack/AGENTS.md")} is missing.
957
+ `
958
+ );
959
+ const answer2 = (await reader.readLine(
960
+ ` ${color.amber("?")} Scaffold ${color.bold("AGENTS.md")} now? ${color.dim("[y/N]")} `
961
+ )).trim().toLowerCase();
962
+ if (answer2 === "y" || answer2 === "yes") {
963
+ try {
964
+ const file = await scaffoldAgentsMd(projectRoot);
965
+ renderer.write(` ${color.green("\u2713")} Wrote ${color.dim(file)}
966
+ `);
967
+ } catch (err) {
968
+ renderer.writeError(
969
+ `Failed to scaffold AGENTS.md: ${err instanceof Error ? err.message : String(err)}`
970
+ );
971
+ }
972
+ }
973
+ return true;
974
+ }
975
+ renderer.write(
976
+ `
977
+ ${color.dim("\u25CB")} ${color.dim(`No project manifest in ${projectRoot} \u2014 running in a scratch directory.`)}
978
+ `
979
+ );
980
+ const answer = (await reader.readLine(
981
+ ` ${color.amber("?")} Continue anyway? ${color.dim("[Y/n]")} `
982
+ )).trim().toLowerCase();
983
+ if (answer === "n" || answer === "no") {
984
+ renderer.write(color.dim(" Cancelled.\n"));
985
+ return false;
986
+ }
987
+ return true;
988
+ }
989
+ async function runLaunchPrompts(opts) {
990
+ const { renderer, reader, modePinned, yoloPinned } = opts;
991
+ let mode;
992
+ if (modePinned) {
993
+ mode = modePinned;
994
+ } else {
995
+ const answer = (await reader.readLine(
996
+ `
997
+ ${color.amber("?")} Interactive mode: ${color.bold("T")}UI / ${color.bold("R")}EPL ${color.dim("[T/r]")} `
998
+ )).trim().toLowerCase();
999
+ mode = answer === "r" || answer === "repl" ? "repl" : "tui";
1000
+ }
1001
+ let yolo;
1002
+ if (yoloPinned !== void 0) {
1003
+ yolo = yoloPinned;
1004
+ } else {
1005
+ const answer = (await reader.readLine(
1006
+ ` ${color.amber("?")} YOLO mode ${color.dim("(auto-approve every tool call)")} ${color.dim("[y/N]")} `
1007
+ )).trim().toLowerCase();
1008
+ yolo = answer === "y" || answer === "yes";
1009
+ }
1010
+ renderer.write(
1011
+ `
1012
+ ${color.green("\u25B6")} Launching in ${color.bold(mode.toUpperCase())} mode${yolo ? color.yellow(" (YOLO)") : ""}
1013
+
1014
+ `
1015
+ );
1016
+ return { mode, yolo };
1017
+ }
1018
+ var TerminalRenderer = class {
1019
+ out;
1020
+ err;
1021
+ lineStart = true;
1022
+ /**
1023
+ * When true, every stdout-bound method is a no-op. This is the only
1024
+ * safe state to be in while Ink owns the terminal (TUI mode):
1025
+ * raw writes to stdout interleave with Ink's cursor math and cause
1026
+ * the input + status bar to be reprinted as scrollback junk.
1027
+ * Stderr-bound methods (writeInfo/Warning/Error) still flow — they
1028
+ * go to a different stream Ink does not manage.
1029
+ */
1030
+ silent = false;
1031
+ constructor(opts = {}) {
1032
+ this.out = opts.out ?? process.stdout;
1033
+ this.err = opts.err ?? process.stderr;
1034
+ }
1035
+ /**
1036
+ * Toggle stdout suppression. Call `setSilent(true)` right before
1037
+ * handing the terminal to Ink, and `setSilent(false)` after Ink
1038
+ * exits. Idempotent.
1039
+ */
1040
+ setSilent(silent) {
1041
+ this.silent = silent;
1042
+ }
1043
+ isSilent() {
1044
+ return this.silent;
1045
+ }
1046
+ write(input) {
1047
+ if (this.silent) return;
1048
+ const text = typeof input === "string" ? input : input.text;
1049
+ if (!text) return;
1050
+ const rendered = renderMarkdown(text);
1051
+ this.out.write(rendered);
1052
+ this.lineStart = rendered.endsWith("\n");
1053
+ }
1054
+ writeLine(text = "") {
1055
+ if (this.silent) return;
1056
+ if (!this.lineStart) this.out.write("\n");
1057
+ if (text) this.out.write(`${text}
1058
+ `);
1059
+ else this.out.write("\n");
1060
+ this.lineStart = true;
1061
+ }
1062
+ writeBlock(block) {
1063
+ if (this.silent) return;
1064
+ if (block.type === "text") {
1065
+ this.write(block);
1066
+ } else if (block.type === "tool_use") {
1067
+ this.writeToolCall(block.name, block.input);
1068
+ } else if (block.type === "tool_result") {
1069
+ const text = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
1070
+ this.writeToolResult("result", text, !!block.is_error);
1071
+ }
1072
+ }
1073
+ writeToolCall(name, input) {
1074
+ if (this.silent) return;
1075
+ if (!this.lineStart) this.out.write("\n");
1076
+ const arrow = theme.primary("\u2192");
1077
+ const display = formatInputSummary(input);
1078
+ this.out.write(`${arrow} ${theme.bold(name)}${display ? color.dim(` ${display}`) : ""}
1079
+ `);
1080
+ this.lineStart = true;
1081
+ }
1082
+ writeToolResult(name, content, isError) {
1083
+ if (this.silent) return;
1084
+ const txt = typeof content === "string" ? content : safeStringify(content);
1085
+ const prefix = isError ? theme.error("\u2718") : theme.success("\u2713");
1086
+ if (isError) {
1087
+ const firstLine = txt.split("\n")[0] ?? "";
1088
+ const truncated = firstLine.length > 200 ? `${firstLine.slice(0, 197)}\u2026` : firstLine;
1089
+ this.out.write(` ${prefix} ${color.dim(truncated)}
1090
+ `);
1091
+ this.lineStart = true;
1092
+ return;
1093
+ }
1094
+ const isEditLike = name === "edit" || name === "write";
1095
+ const isReadLike = name === "read" || name === "grep" || name === "glob" || name === "bash";
1096
+ const previewLines = isEditLike ? 0 : isReadLike ? 6 : 2;
1097
+ const diff = extractDiff(content);
1098
+ if (isEditLike && diff) {
1099
+ this.out.write(` ${prefix} ${color.dim(summarize(content, name))}
1100
+ `);
1101
+ const rendered = renderDiff(diff).split("\n").map((l) => ` ${l}`).join("\n");
1102
+ this.out.write(`${rendered}
1103
+ `);
1104
+ this.lineStart = true;
1105
+ return;
1106
+ }
1107
+ const lines = txt.split("\n");
1108
+ const head = lines.slice(0, previewLines).map((l) => l.replace(/\s+$/, ""));
1109
+ const moreCount = Math.max(0, lines.length - head.length);
1110
+ this.out.write(` ${prefix} ${color.dim(summarize(content, name))}
1111
+ `);
1112
+ for (const l of head) {
1113
+ const capped = l.length > 200 ? `${l.slice(0, 197)}\u2026` : l;
1114
+ this.out.write(` ${color.dim(capped)}
1115
+ `);
1116
+ }
1117
+ if (moreCount > 0) {
1118
+ this.out.write(` ${color.dim(`+${moreCount} more line${moreCount === 1 ? "" : "s"}`)}
1119
+ `);
1120
+ }
1121
+ this.lineStart = true;
1122
+ }
1123
+ writeDiff(diff) {
1124
+ if (this.silent) return;
1125
+ if (!this.lineStart) this.out.write("\n");
1126
+ this.out.write(`${renderDiff(diff)}
1127
+ `);
1128
+ this.lineStart = true;
1129
+ }
1130
+ writeWarning(text) {
1131
+ this.err.write(`${theme.warn("\u26A0")} ${text}
1132
+ `);
1133
+ }
1134
+ writeError(text) {
1135
+ this.err.write(`${theme.error("\u2718")} ${text}
1136
+ `);
1137
+ }
1138
+ writeInfo(text) {
1139
+ this.err.write(`${theme.info("\u2139")} ${text}
1140
+ `);
1141
+ }
1142
+ clear() {
1143
+ if (this.silent) return;
1144
+ this.out.write("\x1B[2J\x1B[H");
1145
+ this.lineStart = true;
1146
+ }
1147
+ };
1148
+ function renderMarkdown(s) {
1149
+ let out = s;
1150
+ out = out.replace(/^(#{1,6}) (.+)$/gm, (_m, hashes, text) => {
1151
+ return theme.primary(theme.bold(`${hashes} ${text}`));
1152
+ });
1153
+ out = out.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
1154
+ return color.gray(`
1155
+ \u250C\u2500
1156
+ ${code.replace(/^/gm, "\u2502 ")}\u2514\u2500`);
1157
+ });
1158
+ out = out.replace(/`([^`\n]+)`/g, (_m, code) => theme.accent(code));
1159
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_m, text) => theme.bold(text));
1160
+ out = out.replace(/(^|[^*])\*([^*\n]+)\*([^*]|$)/g, (_m, l, t, r) => `${l}${color.italic(t)}${r}`);
1161
+ return out;
1162
+ }
1163
+ function formatInputSummary(input) {
1164
+ if (!input || typeof input !== "object") return "";
1165
+ const obj = input;
1166
+ if (typeof obj["path"] === "string") return obj["path"];
1167
+ if (typeof obj["url"] === "string") return obj["url"];
1168
+ if (typeof obj["command"] === "string") {
1169
+ const cmd = obj["command"];
1170
+ return cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
1171
+ }
1172
+ if (typeof obj["pattern"] === "string") return obj["pattern"];
1173
+ return "";
1174
+ }
1175
+ function safeStringify(value) {
1176
+ if (typeof value === "string") return value;
1177
+ try {
1178
+ return JSON.stringify(value);
1179
+ } catch {
1180
+ return String(value);
1181
+ }
1182
+ }
1183
+ function extractDiff(value) {
1184
+ if (typeof value === "object" && value !== null) {
1185
+ const d = value.diff;
1186
+ if (typeof d === "string" && d.length > 0) return d;
1187
+ }
1188
+ if (typeof value === "string") {
1189
+ const trimmed = value.trimStart();
1190
+ if (trimmed.startsWith("{")) {
1191
+ try {
1192
+ const parsed = JSON.parse(value);
1193
+ if (typeof parsed.diff === "string" && parsed.diff.length > 0) {
1194
+ return parsed.diff;
939
1195
  }
1196
+ } catch {
940
1197
  }
941
- const msg = lines.join("\n");
942
- opts.renderer.write(`${msg}
943
- `);
944
- return { message: msg };
945
1198
  }
946
- };
947
- }
948
- function countTurnPairs(messages) {
949
- let count = 0;
950
- for (const m of messages) {
951
- if (m.role === "user" || m.role === "assistant") count++;
1199
+ if (/^---[^\n]*\n\+\+\+/m.test(value)) return value;
952
1200
  }
953
- return Math.floor(count / 2);
1201
+ return null;
954
1202
  }
955
- function countToolUses(messages) {
956
- let count = 0;
957
- for (const m of messages) {
958
- const content = m.content;
959
- if (Array.isArray(content)) {
960
- count += content.filter((b) => b.type === "tool_use").length;
1203
+ function summarize(value, name) {
1204
+ let v = value;
1205
+ if (typeof value === "string") {
1206
+ const trimmed = value.trimStart();
1207
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
1208
+ try {
1209
+ v = JSON.parse(value);
1210
+ } catch {
1211
+ }
961
1212
  }
962
1213
  }
963
- return count;
964
- }
965
- function countToolResults(messages) {
966
- let count = 0;
967
- for (const m of messages) {
968
- const content = m.content;
969
- if (Array.isArray(content)) {
970
- count += content.filter((b) => b.type === "tool_result").length;
1214
+ if (typeof v === "object" && v !== null) {
1215
+ const o = v;
1216
+ if (name === "edit") {
1217
+ const path6 = typeof o["path"] === "string" ? o["path"] : "";
1218
+ const reps = typeof o["replacements"] === "number" ? o["replacements"] : 0;
1219
+ return `${path6} ${reps} replacement${reps === 1 ? "" : "s"}`.trim();
1220
+ }
1221
+ if (name === "write") {
1222
+ const path6 = typeof o["path"] === "string" ? o["path"] : "";
1223
+ const bytes = typeof o["bytes"] === "number" ? o["bytes"] : void 0;
1224
+ return bytes !== void 0 ? `${path6} ${bytes}B` : path6;
1225
+ }
1226
+ if (typeof o["count"] === "number") {
1227
+ return `${o["count"]} match${o["count"] === 1 ? "" : "es"}`;
971
1228
  }
972
1229
  }
973
- return count;
1230
+ return "";
974
1231
  }
975
- function estimateTokens(messages) {
976
- let total = 0;
977
- for (const m of messages) {
978
- const content = m.content;
979
- if (typeof content === "string") {
980
- total += Math.ceil(content.length / 4);
981
- } else if (Array.isArray(content)) {
982
- for (const b of content) {
983
- if (b.type === "text") total += Math.ceil(b.text.length / 4);
984
- else if (b.type === "tool_use" || b.type === "tool_result") {
985
- total += Math.ceil(JSON.stringify(b).length / 4);
986
- }
1232
+ async function runRepl(opts) {
1233
+ if (opts.banner !== false) printBanner(opts.renderer);
1234
+ let activeCtrl;
1235
+ let interrupts = 0;
1236
+ const onSigint = () => {
1237
+ interrupts++;
1238
+ if (interrupts >= 2) {
1239
+ opts.renderer.writeWarning("Exiting.");
1240
+ process.exit(130);
1241
+ }
1242
+ if (activeCtrl) {
1243
+ activeCtrl.abort();
1244
+ opts.renderer.writeWarning("Iteration cancelled. Press Ctrl+C again to exit.");
1245
+ } else {
1246
+ opts.renderer.writeWarning("Press Ctrl+C again to exit.");
1247
+ }
1248
+ };
1249
+ process.on("SIGINT", onSigint);
1250
+ const builder = new InputBuilder({ store: opts.attachments });
1251
+ for (; ; ) {
1252
+ let raw;
1253
+ try {
1254
+ raw = await readPossiblyMultiline(opts);
1255
+ } catch {
1256
+ break;
1257
+ }
1258
+ const trimmed = raw.trim();
1259
+ if (!trimmed) {
1260
+ interrupts = 0;
1261
+ continue;
1262
+ }
1263
+ interrupts = 0;
1264
+ if (trimmed.startsWith("/")) {
1265
+ try {
1266
+ const res = await opts.slashRegistry.dispatch(trimmed, opts.agent.ctx);
1267
+ if (res?.message) opts.renderer.write(`${res.message}
1268
+ `);
1269
+ if (res?.exit) break;
1270
+ } catch (err) {
1271
+ opts.renderer.writeError(err instanceof Error ? err.message : String(err));
1272
+ }
1273
+ continue;
1274
+ }
1275
+ const ph = await builder.appendPaste(raw);
1276
+ if (ph) {
1277
+ const lineCount = raw.split("\n").length;
1278
+ opts.renderer.write(color.dim(` \u21B3 ${ph} (${lineCount} lines)
1279
+ `));
1280
+ }
1281
+ const blocks = await builder.submit();
1282
+ const runCtrl = new AbortController();
1283
+ activeCtrl = runCtrl;
1284
+ try {
1285
+ const startedAt = Date.now();
1286
+ const before = opts.tokenCounter?.total();
1287
+ const costBefore = opts.tokenCounter?.estimateCost().total ?? 0;
1288
+ const result = await opts.agent.run(blocks, { signal: runCtrl.signal });
1289
+ if (result.status === "aborted") {
1290
+ opts.renderer.writeWarning("Aborted.");
1291
+ } else if (result.status === "failed") {
1292
+ opts.renderer.writeError(
1293
+ `Failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`
1294
+ );
1295
+ } else if (result.status === "max_iterations") {
1296
+ opts.renderer.writeWarning(`Hit max iterations (${result.iterations}).`);
1297
+ }
1298
+ if (opts.tokenCounter && before) {
1299
+ const after = opts.tokenCounter.total();
1300
+ const costAfter = opts.tokenCounter.estimateCost().total;
1301
+ const ctxChip = opts.effectiveMaxContext && opts.effectiveMaxContext > 0 ? ` ctx: ${renderContextChip(after.input, opts.effectiveMaxContext)}` : "";
1302
+ opts.renderer.write(
1303
+ `
1304
+ ${color.dim(
1305
+ `[in: ${fmtTok(after.input - before.input)} out: ${fmtTok(after.output - before.output)} iters: ${result.iterations} cost: ${(costAfter - costBefore).toFixed(4)} ${((Date.now() - startedAt) / 1e3).toFixed(1)}s]${ctxChip}`
1306
+ )}
1307
+ `
1308
+ );
987
1309
  }
1310
+ } catch (err) {
1311
+ opts.renderer.writeError(err instanceof Error ? err.message : String(err));
1312
+ } finally {
1313
+ activeCtrl = void 0;
988
1314
  }
989
1315
  }
990
- return total;
1316
+ process.off("SIGINT", onSigint);
1317
+ await opts.reader.close();
1318
+ return 0;
991
1319
  }
992
- function compactCommand(opts) {
993
- return {
994
- name: "compact",
995
- description: "Compact the context window.",
996
- async run(args, ctx) {
997
- if (!opts.compactor) {
998
- const msg2 = "No compactor configured.";
999
- opts.renderer.writeWarning(msg2);
1000
- return { message: msg2 };
1320
+ async function readPossiblyMultiline(opts) {
1321
+ const firstPrompt = theme.primary("\u203A ");
1322
+ const contPrompt = color.dim("\xB7 ");
1323
+ const first = await opts.reader.readLine(firstPrompt);
1324
+ if (first.trim() === '"""') {
1325
+ const parts = [];
1326
+ for (; ; ) {
1327
+ const next = await opts.reader.readLine(contPrompt);
1328
+ if (next.trim() === '"""') break;
1329
+ parts.push(next);
1330
+ }
1331
+ return parts.join("\n");
1332
+ }
1333
+ let buf = first;
1334
+ while (buf.endsWith("\\")) {
1335
+ buf = buf.slice(0, -1);
1336
+ const cont = await opts.reader.readLine(contPrompt);
1337
+ buf += "\n" + cont;
1338
+ }
1339
+ return buf;
1340
+ }
1341
+ function fmtTok(n) {
1342
+ if (n < 1e3) return String(n);
1343
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
1344
+ return `${(n / 1e6).toFixed(1)}M`;
1345
+ }
1346
+ var FILLED = "\u2588";
1347
+ var EMPTY = "\u2591";
1348
+ function renderContextChip(used, max) {
1349
+ const ratio = Math.max(0, Math.min(1, used / max));
1350
+ const pct = Math.round(ratio * 100);
1351
+ const bar = renderProgress(ratio, 6);
1352
+ return `${bar} ${pct}% (${fmtTok(used)}/${fmtTok(max)})`;
1353
+ }
1354
+ function renderProgress(ratio, width) {
1355
+ const clamped = Math.max(0, Math.min(1, ratio));
1356
+ const filled = clamped === 0 ? 0 : Math.max(1, Math.round(clamped * width));
1357
+ const capped = Math.min(width, filled);
1358
+ return FILLED.repeat(capped) + EMPTY.repeat(width - capped);
1359
+ }
1360
+ function printBanner(renderer) {
1361
+ const lines = [
1362
+ theme.primary(theme.bold("WrongStack")) + color.dim(" v0.0.1"),
1363
+ color.dim("Built on the wrong stack. Shipped anyway."),
1364
+ color.dim("Type /help for commands, /exit to quit."),
1365
+ ""
1366
+ ];
1367
+ renderer.write(`${lines.join("\n")}
1368
+ `);
1369
+ }
1370
+ var SessionStats = class {
1371
+ tokenCounter;
1372
+ startedAt = Date.now();
1373
+ apiRequests = 0;
1374
+ iterations = 0;
1375
+ errors = 0;
1376
+ toolStats = /* @__PURE__ */ new Map();
1377
+ readPaths = /* @__PURE__ */ new Set();
1378
+ editedPaths = /* @__PURE__ */ new Set();
1379
+ writtenPaths = /* @__PURE__ */ new Set();
1380
+ bytesWritten = 0;
1381
+ bashCommands = 0;
1382
+ fetches = 0;
1383
+ constructor(events, tokenCounter) {
1384
+ this.tokenCounter = tokenCounter;
1385
+ events.on("provider.response", () => {
1386
+ this.apiRequests++;
1387
+ });
1388
+ events.on("iteration.completed", () => {
1389
+ this.iterations++;
1390
+ });
1391
+ events.on("error", () => {
1392
+ this.errors++;
1393
+ });
1394
+ events.on("tool.executed", (e) => {
1395
+ const slot = this.toolStats.get(e.name) ?? { ok: 0, fail: 0, totalMs: 0 };
1396
+ if (e.ok) slot.ok++;
1397
+ else slot.fail++;
1398
+ slot.totalMs += e.durationMs;
1399
+ this.toolStats.set(e.name, slot);
1400
+ const input = e.input;
1401
+ if (e.name === "bash") this.bashCommands++;
1402
+ else if (e.name === "fetch") this.fetches++;
1403
+ if (!e.ok) return;
1404
+ const path6 = typeof input?.path === "string" ? input.path : void 0;
1405
+ if (e.name === "read" && path6) this.readPaths.add(path6);
1406
+ else if (e.name === "edit" && path6) this.editedPaths.add(path6);
1407
+ else if (e.name === "write" && path6) {
1408
+ this.writtenPaths.add(path6);
1409
+ const content = typeof input?.content === "string" ? input.content : "";
1410
+ this.bytesWritten += Buffer.byteLength(content, "utf8");
1001
1411
  }
1002
- const aggressive = args.trim() === "aggressive";
1003
- const report = await opts.compactor.compact(ctx, { aggressive });
1004
- const msg = `Compaction: ${report.before} \u2192 ${report.after} tokens (${report.reductions.map((r) => `${r.phase}: ${r.saved}`).join(", ")})`;
1005
- opts.renderer.writeInfo(msg);
1006
- return { message: msg };
1007
- }
1008
- };
1009
- }
1010
- function usageCommand(opts) {
1011
- return {
1012
- name: "usage",
1013
- aliases: ["cost"],
1014
- description: "Show token usage and estimated cost.",
1015
- async run() {
1016
- const total = opts.tokenCounter.total();
1017
- const cost = opts.tokenCounter.estimateCost();
1018
- const msg = `${color.bold("Usage")}
1019
- input: ${total.input}
1020
- output: ${total.output}
1021
- cache read: ${total.cacheRead ?? 0}
1022
- cache write: ${total.cacheWrite ?? 0}
1023
- cost: $${cost.total.toFixed(4)} (input $${cost.input.toFixed(4)} / output $${cost.output.toFixed(4)})
1024
- `;
1025
- opts.renderer.write(msg);
1026
- return { message: msg };
1412
+ });
1413
+ }
1414
+ hasActivity() {
1415
+ return this.apiRequests > 0 || this.iterations > 0 || this.toolStats.size > 0 || this.tokenCounter.total().input > 0;
1416
+ }
1417
+ render(renderer) {
1418
+ if (!this.hasActivity()) return;
1419
+ const u = this.tokenCounter.total();
1420
+ const cost = this.tokenCounter.estimateCost();
1421
+ const elapsedSec = ((Date.now() - this.startedAt) / 1e3).toFixed(1);
1422
+ const lines = [];
1423
+ lines.push("");
1424
+ lines.push(color.bold("Session report"));
1425
+ lines.push(color.dim("\u2500".repeat(40)));
1426
+ lines.push(` Elapsed: ${elapsedSec}s`);
1427
+ lines.push(` Iterations: ${this.iterations}`);
1428
+ lines.push(` API requests: ${this.apiRequests}`);
1429
+ if (this.errors > 0) {
1430
+ lines.push(` Errors: ${color.yellow(String(this.errors))}`);
1027
1431
  }
1028
- };
1029
- }
1030
- function toolsCommand(opts) {
1031
- return {
1032
- name: "tools",
1033
- description: "List registered tools.",
1034
- async run() {
1035
- const all = opts.toolRegistry.listWithOwner();
1036
- const lines = all.map(({ tool, owner }) => {
1037
- return ` ${tool.name.padEnd(28)} ${color.dim(`[${owner}]`)} ${tool.mutating ? color.yellow("mut") : color.cyan("ro")} ${color.dim(tool.permission)}`;
1038
- });
1039
- const msg = `${color.bold("Tools")} (${all.length}):
1040
- ${lines.join("\n")}
1041
- `;
1042
- opts.renderer.write(msg);
1043
- return { message: msg };
1432
+ lines.push("");
1433
+ lines.push(` Tokens: in ${fmtTok2(u.input)} out ${fmtTok2(u.output)}${u.cacheRead ? ` cacheR ${fmtTok2(u.cacheRead)}` : ""}${u.cacheWrite ? ` cacheW ${fmtTok2(u.cacheWrite)}` : ""}`);
1434
+ const cache = this.tokenCounter.cacheStats();
1435
+ if (cache.readTokens > 0 || cache.writeTokens > 0) {
1436
+ const pct = (cache.hitRatio * 100).toFixed(1);
1437
+ lines.push(
1438
+ ` Prompt cache: ${pct}% hit ${color.dim(`(${fmtTok2(cache.readTokens)} read / ${fmtTok2(cache.writeTokens)} write)`)}`
1439
+ );
1044
1440
  }
1045
- };
1046
- }
1047
- function skillCommand(opts) {
1048
- return {
1049
- name: "skill",
1050
- description: "Show a skill manifest or list skills.",
1051
- async run(args) {
1052
- if (!opts.skillLoader) {
1053
- const msg = "No skill loader configured.";
1054
- return { message: msg };
1055
- }
1056
- if (!args.trim()) {
1057
- const list = await opts.skillLoader.list();
1058
- if (list.length === 0) {
1059
- const msg2 = "No skills found.";
1060
- return { message: msg2 };
1061
- }
1062
- const lines = list.map((s) => ` ${s.name.padEnd(24)} ${color.dim(`[${s.source}]`)} ${s.description.split("\n")[0]}`);
1063
- const msg = `Skills:
1064
- ${lines.join("\n")}
1065
- `;
1066
- return { message: msg };
1067
- } else {
1068
- const skill = await opts.skillLoader.find(args.trim());
1069
- if (!skill) {
1070
- const msg = `Skill "${args.trim()}" not found.`;
1071
- return { message: msg };
1072
- }
1073
- const body = await opts.skillLoader.readBody(skill.name);
1074
- return { message: body };
1075
- }
1441
+ if (cost.total > 0) {
1442
+ lines.push(` Cost: $${cost.total.toFixed(4)}${color.dim(` (in $${cost.input.toFixed(4)} / out $${cost.output.toFixed(4)})`)}`);
1443
+ } else {
1444
+ lines.push(` Cost: ${color.dim("$0 (no pricing on this plan)")}`);
1076
1445
  }
1077
- };
1078
- }
1079
- function useCommand(opts) {
1080
- return {
1081
- name: "use",
1082
- description: "Switch provider mid-session: /use <provider>",
1083
- async run(args) {
1084
- const name = args.trim();
1085
- if (!name) {
1086
- const msg2 = "Usage: /use <provider-name>";
1087
- return { message: msg2 };
1446
+ if (this.toolStats.size > 0) {
1447
+ lines.push("");
1448
+ lines.push(` ${color.bold("Tool calls")}`);
1449
+ const sorted = [...this.toolStats.entries()].sort(
1450
+ (a, b) => b[1].ok + b[1].fail - (a[1].ok + a[1].fail)
1451
+ );
1452
+ for (const [name, s] of sorted) {
1453
+ const total = s.ok + s.fail;
1454
+ const failPart = s.fail > 0 ? color.yellow(` (${s.fail} failed)`) : "";
1455
+ const avgMs = total > 0 ? Math.round(s.totalMs / total) : 0;
1456
+ lines.push(` ${name.padEnd(12)} ${String(total).padStart(3)}\xD7 ${color.dim(`avg ${avgMs}ms`)}${failPart}`);
1088
1457
  }
1089
- opts.onSwitchProvider?.(name);
1090
- const msg = `Switched provider to "${name}".`;
1091
- return { message: msg };
1092
1458
  }
1093
- };
1094
- }
1095
- function modelCommand(opts) {
1096
- return {
1097
- name: "model",
1098
- description: "Switch model mid-session: /model <model>",
1099
- async run(args) {
1100
- const name = args.trim();
1101
- if (!name) {
1102
- const msg2 = "Usage: /model <model-name>";
1103
- return { message: msg2 };
1459
+ const fileActivity = this.readPaths.size > 0 || this.editedPaths.size > 0 || this.writtenPaths.size > 0 || this.bytesWritten > 0;
1460
+ if (fileActivity) {
1461
+ lines.push("");
1462
+ lines.push(` ${color.bold("Files")}`);
1463
+ if (this.readPaths.size > 0)
1464
+ lines.push(` read: ${this.readPaths.size} ${color.dim(samplePaths(this.readPaths))}`);
1465
+ if (this.editedPaths.size > 0)
1466
+ lines.push(` edited: ${this.editedPaths.size} ${color.dim(samplePaths(this.editedPaths))}`);
1467
+ if (this.writtenPaths.size > 0) {
1468
+ const bytes = this.bytesWritten;
1469
+ const byteStr = bytes > 1024 ? `${(bytes / 1024).toFixed(1)}KB` : `${bytes}B`;
1470
+ lines.push(` written: ${this.writtenPaths.size} (${byteStr}) ${color.dim(samplePaths(this.writtenPaths))}`);
1104
1471
  }
1105
- opts.onSwitchModel?.(name);
1106
- const msg = `Switched model to "${name}".`;
1107
- return { message: msg };
1108
1472
  }
1109
- };
1110
- }
1111
- function saveCommand(opts) {
1112
- return {
1113
- name: "save",
1114
- description: "Save current session (auto by default; this forces flush).",
1115
- async run(_args, ctx) {
1116
- await ctx.session.append({
1117
- type: "session_end",
1118
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1119
- usage: opts.tokenCounter.total()
1120
- });
1121
- const msg = `Session ${ctx.session.id} flushed.`;
1122
- return { message: msg };
1473
+ if (this.bashCommands > 0 || this.fetches > 0) {
1474
+ lines.push("");
1475
+ if (this.bashCommands > 0) lines.push(` Shell commands: ${this.bashCommands}`);
1476
+ if (this.fetches > 0) lines.push(` Web fetches: ${this.fetches}`);
1123
1477
  }
1124
- };
1125
- }
1126
- function loadCommand(opts) {
1127
- return {
1128
- name: "resume",
1129
- aliases: ["load", "sessions"],
1130
- description: "List recent sessions. To actually resume, exit and run `wstack resume <id>`.",
1131
- async run() {
1132
- if (!opts.sessionStore) {
1133
- const msg2 = "No session store configured.";
1134
- return { message: msg2 };
1135
- }
1136
- const list = await opts.sessionStore.list(10);
1137
- if (list.length === 0) {
1138
- const msg2 = "No saved sessions.";
1139
- return { message: msg2 };
1140
- }
1141
- const lines = list.map(
1142
- (s) => ` ${s.id} ${color.dim(s.startedAt)} ${color.dim(`${s.tokenTotal} tok`)} ${s.title}`
1143
- );
1144
- const msg = `Recent sessions:
1145
- ${lines.join("\n")}
1146
-
1147
- ` + color.dim(`Resume one with: wstack resume ${list[0]?.id ?? "<id>"}
1478
+ lines.push("");
1479
+ renderer.write(`${lines.join("\n")}
1148
1480
  `);
1149
- opts.renderer.write(msg);
1150
- return { message: msg };
1151
- }
1152
- };
1481
+ }
1482
+ };
1483
+ function fmtTok2(n) {
1484
+ if (n < 1e3) return String(n);
1485
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
1486
+ return `${(n / 1e6).toFixed(1)}M`;
1153
1487
  }
1154
- function exitCommand(opts) {
1155
- return {
1156
- name: "exit",
1157
- aliases: ["quit", "q"],
1158
- description: "Exit the REPL.",
1159
- async run() {
1160
- opts.onExit?.();
1161
- return { exit: true };
1162
- }
1163
- };
1488
+ function samplePaths(set) {
1489
+ const arr = [...set];
1490
+ if (arr.length <= 2) return arr.join(", ");
1491
+ return `${arr[0]}, \u2026 (+${arr.length - 1} more)`;
1164
1492
  }
1165
1493
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1166
1494
  var FILLED2 = "\u2588";
@@ -1893,12 +2221,73 @@ async function main(argv) {
1893
2221
  await reader.close();
1894
2222
  return code2;
1895
2223
  }
1896
- if (!config.provider || !config.model) {
1897
- process.stderr.write(
1898
- "No provider or model configured. Run `wstack init` first, or set WRONGSTACK_PROVIDER + WRONGSTACK_MODEL.\n"
1899
- );
1900
- await reader.close();
1901
- return 2;
2224
+ const isSingleShot = positional.length > 0 || typeof flags["prompt"] === "string";
2225
+ const isInteractiveTTY = !!process.stdin.isTTY && !isSingleShot;
2226
+ if (isInteractiveTTY) {
2227
+ const cont = await runProjectCheck({ projectRoot, renderer, reader });
2228
+ if (!cont) {
2229
+ await reader.close();
2230
+ return 0;
2231
+ }
2232
+ }
2233
+ const providerFlag = typeof flags["provider"] === "string" ? flags["provider"] : void 0;
2234
+ const modelFlag = typeof flags["model"] === "string" ? flags["model"] : void 0;
2235
+ const bothFlagsPinned = !!providerFlag && !!modelFlag;
2236
+ if (!bothFlagsPinned) {
2237
+ if (process.stdin.isTTY) {
2238
+ const picked = await runPicker({
2239
+ modelsRegistry,
2240
+ renderer,
2241
+ reader,
2242
+ config,
2243
+ defaultProvider: providerFlag ?? config.provider,
2244
+ defaultModel: modelFlag ?? config.model
2245
+ });
2246
+ if (!picked) {
2247
+ if (!config.provider || !config.model) {
2248
+ await reader.close();
2249
+ return 2;
2250
+ }
2251
+ } else {
2252
+ const prevProvider = config.provider;
2253
+ const prevModel = config.model;
2254
+ config = Object.freeze({ ...config, provider: picked.provider, model: picked.model });
2255
+ if (picked.provider !== prevProvider || picked.model !== prevModel) {
2256
+ const saved = await saveToGlobalConfig(
2257
+ wpaths.globalConfig,
2258
+ picked.provider,
2259
+ picked.model
2260
+ );
2261
+ if (saved) {
2262
+ renderer.writeInfo(`Saved ${picked.provider}/${picked.model} as default.
2263
+ `);
2264
+ }
2265
+ }
2266
+ }
2267
+ } else if (!config.provider || !config.model) {
2268
+ process.stderr.write(
2269
+ "No provider or model configured. Run `wrongstack init` first, or pass --provider <id> --model <id>.\n"
2270
+ );
2271
+ await reader.close();
2272
+ return 2;
2273
+ }
2274
+ }
2275
+ if (isInteractiveTTY) {
2276
+ let modePinned;
2277
+ if (flags["no-tui"]) modePinned = "repl";
2278
+ else if (flags["tui"]) modePinned = "tui";
2279
+ const yoloPinned = flags["yolo"] === true ? true : void 0;
2280
+ const choices = await runLaunchPrompts({ renderer, reader, modePinned, yoloPinned });
2281
+ if (choices.mode === "tui") {
2282
+ flags["tui"] = true;
2283
+ flags["no-tui"] = false;
2284
+ } else {
2285
+ flags["tui"] = false;
2286
+ flags["no-tui"] = true;
2287
+ }
2288
+ if (choices.yolo !== config.yolo) {
2289
+ config = Object.freeze({ ...config, yolo: choices.yolo });
2290
+ }
1902
2291
  }
1903
2292
  const resolvedProvider = await modelsRegistry.getProvider(config.provider).catch(() => void 0);
1904
2293
  if (!resolvedProvider) {
@@ -2265,7 +2654,7 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2265
2654
  };
2266
2655
  const newProvider = providerRegistry.create({ ...newCfg, type: name });
2267
2656
  context.provider = newProvider;
2268
- config.provider = name;
2657
+ config = Object.freeze({ ...config, provider: name });
2269
2658
  } catch (err) {
2270
2659
  renderer.writeError(
2271
2660
  `Cannot switch to "${name}": ${err instanceof Error ? err.message : err}`