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.
- package/README.md +73 -16
- package/package.json +28 -9
- package/prompts/skills/commit.md +36 -0
- package/prompts/skills/coordinate.md +21 -0
- package/prompts/skills/daily-review.md +65 -0
- package/prompts/skills/debug.md +23 -0
- package/prompts/skills/deep-work.md +129 -0
- package/prompts/skills/explore.md +24 -0
- package/prompts/skills/init.md +39 -0
- package/prompts/skills/kairos.md +19 -0
- package/prompts/skills/plan.md +19 -0
- package/prompts/skills/polish.md +94 -0
- package/prompts/skills/pr.md +30 -0
- package/prompts/skills/refactor.md +26 -0
- package/prompts/skills/resume-branch.md +27 -0
- package/prompts/skills/review.md +27 -0
- package/prompts/skills/ship.md +32 -0
- package/prompts/skills/simplify.md +25 -0
- package/prompts/skills/test.md +19 -0
- package/prompts/skills/verify.md +17 -0
- package/prompts/skills/weekly-plan.md +63 -0
- package/prompts/system.md +451 -0
- package/src/agent/away-summary.ts +138 -0
- package/src/agent/context.ts +6 -0
- package/src/agent/coordinator.ts +494 -0
- package/src/agent/dream.ts +149 -11
- package/src/agent/error-handler.ts +51 -35
- package/src/agent/kairos.ts +52 -4
- package/src/agent/loop.ts +153 -13
- package/src/agent/mailbox.ts +151 -0
- package/src/agent/model-patches.ts +28 -3
- package/src/agent/product-agent.ts +463 -0
- package/src/agent/speculation.ts +21 -18
- package/src/agent/sub-agent.ts +11 -1
- package/src/agent/system-prompt.ts +19 -0
- package/src/agent/tool-executor.ts +83 -3
- package/src/agent/verification.ts +223 -0
- package/src/agent/worktree-manager.ts +50 -1
- package/src/cli.ts +228 -36
- package/src/config/features.ts +8 -8
- package/src/config/keychain.ts +105 -0
- package/src/config/permissions.ts +3 -2
- package/src/config/settings.ts +73 -5
- package/src/config/upgrade-notice.ts +15 -2
- package/src/mcp/client.ts +392 -2
- package/src/mcp/manager.ts +129 -13
- package/src/mcp/types.ts +4 -1
- package/src/migrate.ts +228 -0
- package/src/persistence/session.ts +209 -5
- package/src/providers/anthropic.ts +112 -98
- package/src/providers/cost-tracker.ts +71 -2
- package/src/providers/retry.ts +2 -4
- package/src/providers/types.ts +5 -1
- package/src/providers/xai.ts +1 -0
- package/src/repl.tsx +514 -127
- package/src/setup.ts +37 -1
- package/src/tools/coordinate.ts +88 -0
- package/src/tools/grep.ts +9 -11
- package/src/tools/lsp.ts +44 -32
- package/src/tools/registry.ts +75 -9
- package/src/tools/send-message.ts +89 -30
- package/src/tools/types.ts +2 -0
- package/src/tools/verify.ts +88 -0
- package/src/tools/web-browser.ts +8 -5
- package/src/tools/workflow.ts +34 -10
- package/src/ui/AnimatedSpinner.tsx +302 -0
- package/src/ui/App.tsx +16 -15
- package/src/ui/BuddyPanel.tsx +27 -34
- package/src/ui/SlashInput.tsx +99 -0
- package/src/ui/banner.ts +10 -0
- package/src/ui/buddy.ts +5 -4
- package/src/ui/effort.ts +5 -1
- package/src/ui/markdown.ts +269 -88
- package/src/ui/message-renderer.ts +183 -35
- package/src/ui/quips.json +41 -0
- package/src/ui/speech-bubble.ts +35 -19
- package/src/utils/ring-buffer.ts +101 -0
- package/src/voice/voice-mode.ts +13 -2
- package/src/__tests__/branded-types.test.ts +0 -47
- package/src/__tests__/context.test.ts +0 -163
- package/src/__tests__/cost-tracker.test.ts +0 -274
- package/src/__tests__/cron.test.ts +0 -197
- package/src/__tests__/dream.test.ts +0 -204
- package/src/__tests__/error-handler.test.ts +0 -192
- package/src/__tests__/features.test.ts +0 -69
- package/src/__tests__/file-history.test.ts +0 -177
- package/src/__tests__/hooks.test.ts +0 -145
- package/src/__tests__/keybindings.test.ts +0 -159
- package/src/__tests__/model-patches.test.ts +0 -82
- package/src/__tests__/permissions-rules.test.ts +0 -121
- package/src/__tests__/permissions.test.ts +0 -108
- package/src/__tests__/project-config.test.ts +0 -63
- package/src/__tests__/retry.test.ts +0 -321
- package/src/__tests__/router.test.ts +0 -158
- package/src/__tests__/session-compact.test.ts +0 -191
- package/src/__tests__/session.test.ts +0 -145
- package/src/__tests__/skill-registry.test.ts +0 -130
- package/src/__tests__/speculation.test.ts +0 -196
- package/src/__tests__/tasks-v2.test.ts +0 -267
- package/src/__tests__/telemetry.test.ts +0 -149
- package/src/__tests__/tool-executor.test.ts +0 -141
- package/src/__tests__/tool-registry.test.ts +0 -166
- package/src/__tests__/undercover.test.ts +0 -93
- package/src/__tests__/workflow.test.ts +0 -195
|
@@ -1,26 +1,170 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Message rendering — format tool output with
|
|
2
|
+
* Message rendering — format tool output with bordered blocks and syntax highlighting.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { theme, stylePath } from "./theme.ts";
|
|
7
|
+
import { highlightCode } from "./markdown.ts";
|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
//
|
|
14
|
-
|
|
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
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
160
|
+
// Filter empty trailing lines
|
|
161
|
+
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1]!.trim() === "") {
|
|
162
|
+
bodyLines.pop();
|
|
163
|
+
}
|
|
19
164
|
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
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(
|
|
69
|
-
|
|
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
|
+
}
|
package/src/ui/speech-bubble.ts
CHANGED
|
@@ -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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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; // "
|
|
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("
|
|
60
|
+
bubbleLines.push(" ╭" + "─".repeat(bubbleWidth - 2) + "╮");
|
|
55
61
|
|
|
56
62
|
// Content lines
|
|
57
63
|
for (const line of textLines) {
|
|
58
|
-
bubbleLines.push("
|
|
64
|
+
bubbleLines.push(" │ " + line.padEnd(innerWidth) + " │");
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
// Bottom border with tail
|
|
62
|
-
const tailPos = Math.min(10, bubbleWidth -
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
package/src/voice/voice-mode.ts
CHANGED
|
@@ -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
|
-
//
|
|
43
|
-
|
|
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
|
-
});
|