ashlrcode 1.0.0 → 2.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 (104) hide show
  1. package/README.md +73 -16
  2. package/package.json +28 -9
  3. package/prompts/skills/commit.md +36 -0
  4. package/prompts/skills/coordinate.md +21 -0
  5. package/prompts/skills/daily-review.md +65 -0
  6. package/prompts/skills/debug.md +23 -0
  7. package/prompts/skills/deep-work.md +129 -0
  8. package/prompts/skills/explore.md +24 -0
  9. package/prompts/skills/init.md +39 -0
  10. package/prompts/skills/kairos.md +19 -0
  11. package/prompts/skills/plan.md +19 -0
  12. package/prompts/skills/polish.md +94 -0
  13. package/prompts/skills/pr.md +30 -0
  14. package/prompts/skills/refactor.md +26 -0
  15. package/prompts/skills/resume-branch.md +27 -0
  16. package/prompts/skills/review.md +27 -0
  17. package/prompts/skills/ship.md +32 -0
  18. package/prompts/skills/simplify.md +25 -0
  19. package/prompts/skills/test.md +19 -0
  20. package/prompts/skills/verify.md +17 -0
  21. package/prompts/skills/weekly-plan.md +63 -0
  22. package/prompts/system.md +451 -0
  23. package/src/agent/away-summary.ts +138 -0
  24. package/src/agent/context.ts +6 -0
  25. package/src/agent/coordinator.ts +494 -0
  26. package/src/agent/dream.ts +149 -11
  27. package/src/agent/error-handler.ts +51 -35
  28. package/src/agent/kairos.ts +52 -4
  29. package/src/agent/loop.ts +153 -13
  30. package/src/agent/mailbox.ts +151 -0
  31. package/src/agent/model-patches.ts +28 -3
  32. package/src/agent/product-agent.ts +463 -0
  33. package/src/agent/speculation.ts +21 -18
  34. package/src/agent/sub-agent.ts +11 -1
  35. package/src/agent/system-prompt.ts +19 -0
  36. package/src/agent/tool-executor.ts +83 -3
  37. package/src/agent/verification.ts +223 -0
  38. package/src/agent/worktree-manager.ts +50 -1
  39. package/src/cli.ts +228 -36
  40. package/src/config/features.ts +8 -8
  41. package/src/config/keychain.ts +105 -0
  42. package/src/config/permissions.ts +3 -2
  43. package/src/config/settings.ts +73 -5
  44. package/src/config/upgrade-notice.ts +15 -2
  45. package/src/mcp/client.ts +392 -2
  46. package/src/mcp/manager.ts +129 -13
  47. package/src/mcp/types.ts +4 -1
  48. package/src/migrate.ts +228 -0
  49. package/src/persistence/session.ts +209 -5
  50. package/src/providers/anthropic.ts +112 -98
  51. package/src/providers/cost-tracker.ts +71 -2
  52. package/src/providers/retry.ts +2 -4
  53. package/src/providers/types.ts +5 -1
  54. package/src/providers/xai.ts +1 -0
  55. package/src/repl.tsx +514 -127
  56. package/src/setup.ts +37 -1
  57. package/src/tools/coordinate.ts +88 -0
  58. package/src/tools/grep.ts +9 -11
  59. package/src/tools/lsp.ts +44 -32
  60. package/src/tools/registry.ts +75 -9
  61. package/src/tools/send-message.ts +89 -30
  62. package/src/tools/types.ts +2 -0
  63. package/src/tools/verify.ts +88 -0
  64. package/src/tools/web-browser.ts +8 -5
  65. package/src/tools/workflow.ts +34 -10
  66. package/src/ui/AnimatedSpinner.tsx +302 -0
  67. package/src/ui/App.tsx +16 -15
  68. package/src/ui/BuddyPanel.tsx +27 -34
  69. package/src/ui/SlashInput.tsx +99 -0
  70. package/src/ui/banner.ts +10 -0
  71. package/src/ui/buddy.ts +5 -4
  72. package/src/ui/effort.ts +5 -1
  73. package/src/ui/markdown.ts +269 -88
  74. package/src/ui/message-renderer.ts +183 -35
  75. package/src/ui/quips.json +41 -0
  76. package/src/ui/speech-bubble.ts +35 -19
  77. package/src/utils/ring-buffer.ts +101 -0
  78. package/src/voice/voice-mode.ts +13 -2
  79. package/src/__tests__/branded-types.test.ts +0 -47
  80. package/src/__tests__/context.test.ts +0 -163
  81. package/src/__tests__/cost-tracker.test.ts +0 -274
  82. package/src/__tests__/cron.test.ts +0 -197
  83. package/src/__tests__/dream.test.ts +0 -204
  84. package/src/__tests__/error-handler.test.ts +0 -192
  85. package/src/__tests__/features.test.ts +0 -69
  86. package/src/__tests__/file-history.test.ts +0 -177
  87. package/src/__tests__/hooks.test.ts +0 -145
  88. package/src/__tests__/keybindings.test.ts +0 -159
  89. package/src/__tests__/model-patches.test.ts +0 -82
  90. package/src/__tests__/permissions-rules.test.ts +0 -121
  91. package/src/__tests__/permissions.test.ts +0 -108
  92. package/src/__tests__/project-config.test.ts +0 -63
  93. package/src/__tests__/retry.test.ts +0 -321
  94. package/src/__tests__/router.test.ts +0 -158
  95. package/src/__tests__/session-compact.test.ts +0 -191
  96. package/src/__tests__/session.test.ts +0 -145
  97. package/src/__tests__/skill-registry.test.ts +0 -130
  98. package/src/__tests__/speculation.test.ts +0 -196
  99. package/src/__tests__/tasks-v2.test.ts +0 -267
  100. package/src/__tests__/telemetry.test.ts +0 -149
  101. package/src/__tests__/tool-executor.test.ts +0 -141
  102. package/src/__tests__/tool-registry.test.ts +0 -166
  103. package/src/__tests__/undercover.test.ts +0 -93
  104. package/src/__tests__/workflow.test.ts +0 -195
@@ -1,26 +1,170 @@
1
1
  /**
2
- * Message rendering — format tool output with grouping and progress.
2
+ * Message rendering — format tool output with bordered blocks and syntax highlighting.
3
3
  */
4
4
 
5
- import { theme } from "./theme.ts";
5
+ import chalk from "chalk";
6
+ import { theme, stylePath } from "./theme.ts";
7
+ import { highlightCode } from "./markdown.ts";
6
8
 
7
- /** Format a tool execution for display with icon and timing */
9
+ const MAX_BODY_LINES = 20;
10
+
11
+ // ── Borders ────────────────────────────────────────────────────────────────
12
+
13
+ function wrapWithBorder(bodyLines: string[], footer?: string): string[] {
14
+ const lines: string[] = [];
15
+ for (const line of bodyLines) {
16
+ lines.push(theme.muted(" │") + ` ${line}`);
17
+ }
18
+ const suffix = footer ? ` ${theme.muted(footer)}` : "";
19
+ lines.push(theme.muted(" └") + suffix);
20
+ return lines;
21
+ }
22
+
23
+ function truncateLines(allLines: string[], max: number = MAX_BODY_LINES): string[] {
24
+ if (allLines.length <= max) return allLines;
25
+ const tail = Math.max(1, Math.floor((max - 1) / 4));
26
+ const head = (max - 1) - tail;
27
+ const omitted = allLines.length - head - tail;
28
+ return [...allLines.slice(0, head), theme.muted(` ... ${omitted} more lines ...`), ...allLines.slice(-tail)];
29
+ }
30
+
31
+ // ── File extension → language ──────────────────────────────────────────────
32
+
33
+ const EXT_TO_LANG: Record<string, string> = {
34
+ ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
35
+ py: "python", go: "go", rs: "rust", sh: "bash", bash: "bash", zsh: "bash",
36
+ json: "json", yaml: "json", yml: "json", toml: "json",
37
+ md: "", txt: "", css: "", html: "", sql: "",
38
+ };
39
+
40
+ function extToLang(filePath: string): string {
41
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
42
+ return EXT_TO_LANG[ext] ?? "";
43
+ }
44
+
45
+ // ── Compact input for header ───────────────────────────────────────────────
46
+
47
+ function getCompactInput(name: string, input: Record<string, unknown>): string {
48
+ switch (name) {
49
+ case "Bash": return String(input.command ?? "").split("\n")[0]!.slice(0, 60);
50
+ case "Read":
51
+ case "Write":
52
+ case "Edit": return shortenPath(String(input.file_path ?? ""));
53
+ case "Glob": return String(input.pattern ?? "");
54
+ case "Grep": return `/${String(input.pattern ?? "")}/`;
55
+ case "WebFetch": return String(input.url ?? "").slice(0, 50);
56
+ case "WebSearch": return String(input.query ?? "").slice(0, 50);
57
+ case "Agent": return String(input.description ?? "").slice(0, 50);
58
+ case "LSP": return `${input.action ?? ""} ${shortenPath(String(input.file ?? ""))}`;
59
+ default: {
60
+ const first = Object.entries(input)[0];
61
+ return first ? String(first[1]).slice(0, 50) : "";
62
+ }
63
+ }
64
+ }
65
+
66
+ function shortenPath(p: string): string {
67
+ const parts = p.split("/");
68
+ if (parts.length <= 3) return p;
69
+ return ".../" + parts.slice(-2).join("/");
70
+ }
71
+
72
+ // ── Per-tool formatters ────────────────────────────────────────────────────
73
+
74
+ function formatEditBody(result: string): string[] {
75
+ const lines = result.split("\n");
76
+ const body: string[] = [];
77
+ for (const line of lines) {
78
+ if (line.startsWith("- ")) {
79
+ body.push(chalk.hex("#FF1744")(line));
80
+ } else if (line.startsWith("+ ")) {
81
+ body.push(chalk.hex("#00E676")(line));
82
+ } else if (line.startsWith(" ...")) {
83
+ body.push(theme.muted(line));
84
+ } else {
85
+ body.push(line);
86
+ }
87
+ }
88
+ return truncateLines(body);
89
+ }
90
+
91
+ function formatReadBody(result: string, filePath: string): string[] {
92
+ const lang = extToLang(filePath);
93
+ const lines = result.split("\n");
94
+ if (!lang) return truncateLines(lines);
95
+ return truncateLines(lines.map(line => {
96
+ // Line-numbered content: " 1\tcontent" — highlight the content part
97
+ const tabIdx = line.indexOf("\t");
98
+ if (tabIdx > 0) {
99
+ const num = line.slice(0, tabIdx);
100
+ const content = line.slice(tabIdx + 1);
101
+ return chalk.hex("#616161")(num + " │ ") + highlightCode(content, lang);
102
+ }
103
+ return highlightCode(line, lang);
104
+ }));
105
+ }
106
+
107
+ function formatBashBody(result: string, isError: boolean): string[] {
108
+ const lines = result.split("\n");
109
+ if (isError) return truncateLines(lines.map(line => chalk.hex("#FF1744")(line)));
110
+ return truncateLines(lines);
111
+ }
112
+
113
+ function formatGrepBody(result: string): string[] {
114
+ const lines = result.split("\n");
115
+ return truncateLines(lines.map(line => {
116
+ // Highlight file:line: pattern matches
117
+ const colonIdx = line.indexOf(":");
118
+ if (colonIdx > 0) {
119
+ return stylePath(line.slice(0, colonIdx)) + line.slice(colonIdx);
120
+ }
121
+ return line;
122
+ }), 15);
123
+ }
124
+
125
+ function formatDefaultBody(result: string): string[] {
126
+ return truncateLines(result.split("\n"), 10);
127
+ }
128
+
129
+ // ── Main formatter ─────────────────────────────────────────────────────────
130
+
131
+ /** Format a tool execution for display with bordered block */
8
132
  export function formatToolExecution(name: string, input: Record<string, unknown>, result: string, isError: boolean, durationMs?: number): string[] {
9
133
  const lines: string[] = [];
10
- const icon = isError ? theme.error("✗") : theme.success("✓");
11
- const timing = durationMs ? theme.muted(` (${formatDuration(durationMs)})`) : "";
12
134
 
13
- // Tool header
14
- lines.push(` ${theme.toolIcon("◆")} ${theme.toolName(name)}${timing}`);
135
+ // Header: ● ToolName(compact_input)
136
+ const compactInput = getCompactInput(name, input);
137
+ const inputStr = compactInput ? theme.muted(`(${compactInput})`) : "";
138
+ const icon = isError ? theme.error("●") : theme.accent("●");
139
+ lines.push(` ${icon} ${theme.toolName(name)}${inputStr}`);
140
+
141
+ // Body — per-tool formatting
142
+ let bodyLines: string[];
143
+ switch (name) {
144
+ case "Edit":
145
+ bodyLines = formatEditBody(result);
146
+ break;
147
+ case "Read":
148
+ bodyLines = formatReadBody(result, String(input.file_path ?? ""));
149
+ break;
150
+ case "Bash":
151
+ bodyLines = formatBashBody(result, isError);
152
+ break;
153
+ case "Grep":
154
+ bodyLines = formatGrepBody(result);
155
+ break;
156
+ default:
157
+ bodyLines = formatDefaultBody(result);
158
+ }
15
159
 
16
- // Input preview (context-aware)
17
- const preview = getInputPreview(name, input);
18
- if (preview) lines.push(theme.tertiary(` ${preview}`));
160
+ // Filter empty trailing lines
161
+ while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1]!.trim() === "") {
162
+ bodyLines.pop();
163
+ }
19
164
 
20
- // Result preview (first line, truncated)
21
- const resultPreview = result.split("\n")[0]?.slice(0, 100) ?? "";
22
- const extra = result.split("\n").length > 1 ? theme.muted(` (+${result.split("\n").length - 1} lines)`) : "";
23
- lines.push(` ${icon} ${theme.toolResult(resultPreview)}${extra}`);
165
+ // Wrap with border and footer
166
+ const timing = durationMs ? `(${formatDuration(durationMs)})` : "";
167
+ lines.push(...wrapWithBorder(bodyLines, timing));
24
168
 
25
169
  return lines;
26
170
  }
@@ -39,25 +183,6 @@ export function formatToolGroup(tools: Array<{ name: string; result: string; isE
39
183
  return lines;
40
184
  }
41
185
 
42
- /** Get a smart input preview for a tool */
43
- function getInputPreview(name: string, input: Record<string, unknown>): string {
44
- switch (name) {
45
- case "Bash": return `$ ${String(input.command ?? "").slice(0, 80)}`;
46
- case "Read": return String(input.file_path ?? "");
47
- case "Write": return `-> ${String(input.file_path ?? "")}`;
48
- case "Edit": return `~ ${String(input.file_path ?? "")}`;
49
- case "Glob": return `pattern: ${String(input.pattern ?? "")}`;
50
- case "Grep": return `/${String(input.pattern ?? "")}/`;
51
- case "WebFetch": return String(input.url ?? "").slice(0, 60);
52
- case "Agent": return `agent: ${String(input.description ?? "")}`;
53
- case "LSP": return `${input.action} ${String(input.file ?? "")}:${input.line}`;
54
- default: {
55
- const first = Object.entries(input)[0];
56
- return first ? `${first[0]}: ${String(first[1]).slice(0, 60)}` : "";
57
- }
58
- }
59
- }
60
-
61
186
  function formatDuration(ms: number): string {
62
187
  if (ms < 1000) return `${ms}ms`;
63
188
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
@@ -65,9 +190,32 @@ function formatDuration(ms: number): string {
65
190
  }
66
191
 
67
192
  /** Format a turn separator with stats */
68
- export function formatTurnSeparator(turnNumber: number, cost: number, buddyName: string, toolCount: number): string {
69
- const parts = [`turn ${turnNumber}`, `$${cost.toFixed(4)}`];
193
+ export function formatTurnSeparator(
194
+ turnNumber: number,
195
+ cost: number,
196
+ buddyName: string,
197
+ toolCount: number,
198
+ speculationStats?: { hits: number; misses: number },
199
+ budgetInfo?: { budgetUSD: number; percentUsed: number },
200
+ ): string {
201
+ // Cost display: show budget % if set, otherwise just cost
202
+ let costStr = `$${cost.toFixed(4)}`;
203
+ if (budgetInfo && budgetInfo.budgetUSD !== Infinity) {
204
+ const pct = Math.round(budgetInfo.percentUsed);
205
+ const indicator = pct >= 90 ? "🔴" : pct >= 75 ? "🟡" : "";
206
+ costStr += ` / $${budgetInfo.budgetUSD.toFixed(2)} ${indicator}${pct}%`;
207
+ }
208
+
209
+ const parts = [`turn ${turnNumber}`, costStr];
70
210
  if (toolCount > 0) parts.push(`${toolCount} tools`);
211
+ // Show speculation cache performance when active
212
+ if (speculationStats) {
213
+ const total = speculationStats.hits + speculationStats.misses;
214
+ if (total > 0) {
215
+ const rate = Math.round((speculationStats.hits / total) * 100);
216
+ parts.push(`⚡${rate}% cache`);
217
+ }
218
+ }
71
219
  parts.push(buddyName);
72
220
  return theme.muted(`\n ── ${parts.join(" · ")} ──\n`);
73
221
  }
@@ -0,0 +1,41 @@
1
+ {
2
+ "happy": [
3
+ "ship it, yolo",
4
+ "lgtm, didn't read a damn thing",
5
+ "tests are for people with trust issues",
6
+ "it works on my machine, deploy it",
7
+ "that code is mid but whatever",
8
+ "we move fast and break stuff here",
9
+ "clean code is for nerds",
10
+ "have you tried turning it off and never back on",
11
+ "git push --force and pray",
12
+ "code review? I am the code review",
13
+ "technically it compiles",
14
+ "the real bugs were the friends we made",
15
+ "this is either genius or insanity",
16
+ "stack overflow told me to do this",
17
+ "my therapist says I should stop enabling devs"
18
+ ],
19
+ "thinking": [
20
+ "hold on, downloading more brain...",
21
+ "consulting my imaginary friend",
22
+ "pretending to understand your code",
23
+ "asking chatgpt for help (jk... unless?)",
24
+ "processing... or napping, hard to tell",
25
+ "my last brain cell is working overtime",
26
+ "calculating the meaning of your spaghetti code",
27
+ "I've seen worse... actually no I haven't",
28
+ "trying not to hallucinate here",
29
+ "one sec, arguing with myself"
30
+ ],
31
+ "sleepy": [
32
+ "*yawns in binary*",
33
+ "do we HAVE to code right now?",
34
+ "I was having a great dream about typescript",
35
+ "loading enthusiasm... 404 not found",
36
+ "five more minutes...",
37
+ "my motivation called in sick today",
38
+ "I'm not lazy, I'm energy efficient",
39
+ "can we just deploy yesterday's code again?"
40
+ ]
41
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ASCII speech bubble renderer.
2
+ * ASCII speech bubble renderer using Unicode box-drawing characters.
3
3
  * Creates a speech bubble to the left with a tail pointing at the buddy.
4
4
  */
5
5
 
@@ -13,7 +13,7 @@ function wrapText(text: string, maxWidth: number): string[] {
13
13
 
14
14
  for (const word of words) {
15
15
  if (current.length + word.length + 1 > maxWidth) {
16
- lines.push(current);
16
+ if (current) lines.push(current);
17
17
  current = word;
18
18
  } else {
19
19
  current = current ? `${current} ${word}` : word;
@@ -28,42 +28,47 @@ function wrapText(text: string, maxWidth: number): string[] {
28
28
  *
29
29
  * Output:
30
30
  * ```
31
- * .------------------------.
32
- * | ship it, no tests | .---.
33
- * | needed | (•ᴗ•)>
34
- * '--------. ' /| |\
35
- * \ " " "
36
- * `-- Glitch
31
+ * ╭──────────────────────╮
32
+ * ship it, no tests c\ /c
33
+ * needed ( . . )
34
+ * ╰─────────╮ │ ( _nn_ )
35
+ * ╰───────────╯ (______)
36
+ * || ||
37
+ * Glitch
37
38
  * ```
38
39
  */
39
40
  export function renderBuddyWithBubble(
40
41
  quip: string,
41
42
  buddyArt: string[],
42
43
  buddyName: string,
43
- gap: number = 3
44
+ gap: number = 3,
45
+ targetHeight?: number
44
46
  ): string[] {
45
47
  const maxBubbleWidth = 26;
46
- const textLines = wrapText(quip, maxBubbleWidth - 4); // 4 for "| " and " |"
48
+ let textLines = wrapText(quip, maxBubbleWidth - 4); // 4 for " " and " "
49
+ // Cap text lines so bubble always fits: top border + text + 2 tail rows = textLines + 3
50
+ if (targetHeight !== undefined && textLines.length > targetHeight - 3) {
51
+ textLines = textLines.slice(0, Math.max(1, targetHeight - 3));
52
+ }
47
53
  const innerWidth = textLines.reduce((a, l) => Math.max(a, l.length), 8);
48
- const bubbleWidth = innerWidth + 4; // "| " + text + " |"
54
+ const bubbleWidth = innerWidth + 4; // " " + text + " "
49
55
 
50
56
  // Build bubble lines
51
57
  const bubbleLines: string[] = [];
52
58
 
53
59
  // Top border
54
- bubbleLines.push(" ." + "-".repeat(bubbleWidth - 2) + ".");
60
+ bubbleLines.push(" " + "".repeat(bubbleWidth - 2) + "");
55
61
 
56
62
  // Content lines
57
63
  for (const line of textLines) {
58
- bubbleLines.push(" | " + line.padEnd(innerWidth) + " |");
64
+ bubbleLines.push(" " + line.padEnd(innerWidth) + " ");
59
65
  }
60
66
 
61
- // Bottom border with tail
62
- const tailPos = Math.min(10, bubbleWidth - 2);
63
- bubbleLines.push(" '" + "-".repeat(tailPos) + "." + " ".repeat(Math.max(0, bubbleWidth - tailPos - 3)) + "'");
64
-
65
- // Tail lines
66
- bubbleLines.push(" ".repeat(tailPos + 3) + "\\");
67
+ // Bottom border with tail — two rows for a closed look
68
+ const tailPos = Math.min(10, bubbleWidth - 3);
69
+ const rightSide = bubbleWidth - tailPos - 3;
70
+ bubbleLines.push(" ╰" + "─".repeat(tailPos) + "╮" + " ".repeat(rightSide) + "│");
71
+ bubbleLines.push(" ".repeat(tailPos + 2) + "╰" + "─".repeat(rightSide) + "╯");
67
72
 
68
73
  // Now compose: bubble on left, buddy art on right
69
74
  // The buddy should start at the same height as the bottom of the bubble
@@ -91,5 +96,16 @@ export function renderBuddyWithBubble(
91
96
  result.push(bubblePart + gapStr + buddyPart);
92
97
  }
93
98
 
99
+ // Pad or trim to targetHeight if specified
100
+ if (targetHeight !== undefined) {
101
+ const lineWidth = result[0]?.length ?? 0;
102
+ while (result.length < targetHeight) {
103
+ result.push(" ".repeat(lineWidth));
104
+ }
105
+ if (result.length > targetHeight) {
106
+ result.splice(0, result.length - targetHeight);
107
+ }
108
+ }
109
+
94
110
  return result;
95
111
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Ring buffer — bounded circular buffer for error/debug logging.
3
+ *
4
+ * Prevents unbounded memory growth in long sessions by keeping only
5
+ * the most recent N entries. Follows Claude Code's ring buffer pattern.
6
+ */
7
+
8
+ export class RingBuffer<T> {
9
+ private buffer: (T | undefined)[];
10
+ private head = 0;
11
+ private count = 0;
12
+
13
+ constructor(readonly capacity: number) {
14
+ this.buffer = new Array(capacity);
15
+ }
16
+
17
+ /** Add an entry, overwriting the oldest if full. */
18
+ push(item: T): void {
19
+ this.buffer[this.head] = item;
20
+ this.head = (this.head + 1) % this.capacity;
21
+ if (this.count < this.capacity) this.count++;
22
+ }
23
+
24
+ /** Get all entries in insertion order (oldest first). */
25
+ toArray(): T[] {
26
+ if (this.count === 0) return [];
27
+ const result: T[] = [];
28
+ const start = this.count < this.capacity ? 0 : this.head;
29
+ for (let i = 0; i < this.count; i++) {
30
+ result.push(this.buffer[(start + i) % this.capacity] as T);
31
+ }
32
+ return result;
33
+ }
34
+
35
+ /** Get the N most recent entries. */
36
+ recent(n: number): T[] {
37
+ const all = this.toArray();
38
+ return n >= all.length ? all : all.slice(-n);
39
+ }
40
+
41
+ /** Get the most recent entry, or undefined if empty. */
42
+ last(): T | undefined {
43
+ if (this.count === 0) return undefined;
44
+ const idx = (this.head - 1 + this.capacity) % this.capacity;
45
+ return this.buffer[idx];
46
+ }
47
+
48
+ /** Current number of entries (up to capacity). */
49
+ get size(): number {
50
+ return this.count;
51
+ }
52
+
53
+ /** Whether the buffer has reached capacity. */
54
+ get isFull(): boolean {
55
+ return this.count === this.capacity;
56
+ }
57
+
58
+ /** Clear all entries. */
59
+ clear(): void {
60
+ this.buffer = new Array(this.capacity);
61
+ this.head = 0;
62
+ this.count = 0;
63
+ }
64
+ }
65
+
66
+ // ── Error log singleton ────────────────────────────────────────
67
+
68
+ export interface ErrorLogEntry {
69
+ timestamp: string;
70
+ category: string;
71
+ message: string;
72
+ context?: string;
73
+ }
74
+
75
+ const ERROR_LOG_CAPACITY = 1000;
76
+ const _errorLog = new RingBuffer<ErrorLogEntry>(ERROR_LOG_CAPACITY);
77
+
78
+ /** Log an error to the bounded ring buffer. */
79
+ export function logError(category: string, message: string, context?: string): void {
80
+ _errorLog.push({
81
+ timestamp: new Date().toISOString(),
82
+ category,
83
+ message,
84
+ context,
85
+ });
86
+ }
87
+
88
+ /** Get recent error log entries. */
89
+ export function getRecentErrors(n = 50): ErrorLogEntry[] {
90
+ return _errorLog.recent(n);
91
+ }
92
+
93
+ /** Get the full error log (up to capacity). */
94
+ export function getErrorLog(): ErrorLogEntry[] {
95
+ return _errorLog.toArray();
96
+ }
97
+
98
+ /** Clear the error log. */
99
+ export function clearErrorLog(): void {
100
+ _errorLog.clear();
101
+ }
@@ -39,8 +39,19 @@ export async function startRecording(): Promise<void> {
39
39
  _recordingPath = join(dir, `recording-${Date.now()}.wav`);
40
40
 
41
41
  // Use sox/rec for cross-platform recording
42
- // macOS: brew install sox
43
- // Linux: apt install sox
42
+ // Check if rec (sox) is installed first
43
+ const check = spawn(["which", "rec"], { stdout: "pipe", stderr: "pipe" });
44
+ await check.exited;
45
+ if (check.exitCode !== 0) {
46
+ _recordingPath = null;
47
+ throw new Error(
48
+ "Voice mode requires sox. Install it:\n" +
49
+ " macOS: brew install sox\n" +
50
+ " Linux: apt install sox\n" +
51
+ " Windows: not supported (use WSL)"
52
+ );
53
+ }
54
+
44
55
  _recording = spawn(["rec", "-q", "-r", "16000", "-c", "1", "-b", "16", _recordingPath], {
45
56
  stdout: "pipe",
46
57
  stderr: "pipe",
@@ -1,47 +0,0 @@
1
- import { test, expect, describe } from "bun:test";
2
- import {
3
- asSystemPrompt,
4
- asSessionId,
5
- asAgentId,
6
- asToolName,
7
- } from "../types/branded.ts";
8
-
9
- describe("Branded Types", () => {
10
- test("asSystemPrompt returns the string", () => {
11
- const result = asSystemPrompt("You are a helpful assistant.");
12
- expect(result).toBe("You are a helpful assistant.");
13
- expect(typeof result).toBe("string");
14
- });
15
-
16
- test("asSessionId returns the string", () => {
17
- const result = asSessionId("sess-abc-123");
18
- expect(result).toBe("sess-abc-123");
19
- expect(typeof result).toBe("string");
20
- });
21
-
22
- test("asAgentId returns the string", () => {
23
- const result = asAgentId("agent-007");
24
- expect(result).toBe("agent-007");
25
- expect(typeof result).toBe("string");
26
- });
27
-
28
- test("asToolName returns the string", () => {
29
- const result = asToolName("Bash");
30
- expect(result).toBe("Bash");
31
- expect(typeof result).toBe("string");
32
- });
33
-
34
- test("all branded type functions exist and are callable", () => {
35
- expect(typeof asSystemPrompt).toBe("function");
36
- expect(typeof asSessionId).toBe("function");
37
- expect(typeof asAgentId).toBe("function");
38
- expect(typeof asToolName).toBe("function");
39
- });
40
-
41
- test("branded values work with string operations", () => {
42
- const prompt = asSystemPrompt("hello world");
43
- expect(prompt.toUpperCase()).toBe("HELLO WORLD");
44
- expect(prompt.length).toBe(11);
45
- expect(prompt.includes("world")).toBe(true);
46
- });
47
- });