ashlrcode 1.0.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/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- package/src/voice/voice-mode.ts +169 -0
package/src/repl.tsx
ADDED
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink-based REPL — replaces readline for interactive mode.
|
|
3
|
+
*
|
|
4
|
+
* Manages display state and bridges between the agent loop callbacks
|
|
5
|
+
* and the Ink React component tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { render } from "ink";
|
|
10
|
+
import { App } from "./ui/App.tsx";
|
|
11
|
+
import { runAgentLoop } from "./agent/loop.ts";
|
|
12
|
+
import { getCurrentMode, cycleMode } from "./ui/mode.ts";
|
|
13
|
+
import { getEffort, setEffort, cycleEffort, getEffortConfig, getEffortEmoji, type EffortLevel } from "./ui/effort.ts";
|
|
14
|
+
import { estimateTokens, getProviderContextLimit, needsCompaction, autoCompact, snipCompact, contextCollapse } from "./agent/context.ts";
|
|
15
|
+
import { runWithAgentContext, type AgentContext } from "./agent/async-context.ts";
|
|
16
|
+
import { resetMarkdown } from "./ui/markdown.ts";
|
|
17
|
+
import { getBuddyReaction, getBuddyArt, isFirstToolCall, recordThinking, recordToolCallSuccess, recordError, saveBuddy, startBuddyAnimation, stopBuddyAnimation } from "./ui/buddy.ts";
|
|
18
|
+
import { renderBuddyWithBubble } from "./ui/speech-bubble.ts";
|
|
19
|
+
import { isPlanMode, getPlanModePrompt } from "./planning/plan-mode.ts";
|
|
20
|
+
import { categorizeError } from "./agent/error-handler.ts";
|
|
21
|
+
import { theme } from "./ui/theme.ts";
|
|
22
|
+
import { getRemoteSettings, stopPolling as stopRemotePolling } from "./config/remote-settings.ts";
|
|
23
|
+
import { exportSettings, importSettings, getSyncStatus } from "./config/settings-sync.ts";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
import { formatToolExecution, formatTurnSeparator } from "./ui/message-renderer.ts";
|
|
26
|
+
import chalk from "chalk";
|
|
27
|
+
import type { ProviderRouter } from "./providers/router.ts";
|
|
28
|
+
import type { ToolRegistry } from "./tools/registry.ts";
|
|
29
|
+
import type { ToolContext } from "./tools/types.ts";
|
|
30
|
+
import type { Message } from "./providers/types.ts";
|
|
31
|
+
import type { Session } from "./persistence/session.ts";
|
|
32
|
+
import type { SkillRegistry } from "./skills/registry.ts";
|
|
33
|
+
import type { BuddyData } from "./ui/buddy.ts";
|
|
34
|
+
import { shutdownLSP } from "./tools/lsp.ts";
|
|
35
|
+
import { shutdownBrowser } from "./tools/web-browser.ts";
|
|
36
|
+
import { startIPCServer, stopIPCServer } from "./agent/ipc.ts";
|
|
37
|
+
import { checkPermission, hasPendingPermission, answerPendingPermission, requestPermissionInk } from "./config/permissions.ts";
|
|
38
|
+
import { feature, listFeatures } from "./config/features.ts";
|
|
39
|
+
import { hasPendingQuestion, answerPendingQuestion } from "./tools/ask-user.ts";
|
|
40
|
+
import { generateBuddyComment, shouldUseAI, type BuddyCommentType } from "./ui/buddy-ai.ts";
|
|
41
|
+
import { scanCodebase } from "./autopilot/scanner.ts";
|
|
42
|
+
import { WorkQueue } from "./autopilot/queue.ts";
|
|
43
|
+
import { DEFAULT_CONFIG } from "./autopilot/types.ts";
|
|
44
|
+
import { generateDream, loadRecentDreams, formatDreamsForPrompt, IdleDetector } from "./agent/dream.ts";
|
|
45
|
+
import { FileHistoryStore, setFileHistory, getFileHistory } from "./state/file-history.ts";
|
|
46
|
+
import { loadKeybindings, getBindings, InputHistory } from "./ui/keybindings.ts";
|
|
47
|
+
import { SpeculationCache } from "./agent/speculation.ts";
|
|
48
|
+
import { setSpeculationCache } from "./agent/tool-executor.ts";
|
|
49
|
+
import { KairosLoop, detectTerminalFocus } from "./agent/kairos.ts";
|
|
50
|
+
import { notifyTurnComplete, notifyError } from "./ui/notifications.ts";
|
|
51
|
+
import { initTelemetry, logEvent, readRecentEvents, formatEvents } from "./telemetry/event-log.ts";
|
|
52
|
+
import { createTrigger, listTriggers, deleteTrigger, toggleTrigger, TriggerRunner } from "./agent/cron.ts";
|
|
53
|
+
import { startRecording, stopRecording, isRecording, transcribeRecording, checkVoiceAvailability, type VoiceConfig } from "./voice/voice-mode.ts";
|
|
54
|
+
import { checkForUpgrade } from "./config/upgrade-notice.ts";
|
|
55
|
+
import { VERSION } from "./version.ts";
|
|
56
|
+
import { startBridgeServer, stopBridgeServer, getBridgePort } from "./bridge/bridge-server.ts";
|
|
57
|
+
import { randomBytes } from "crypto";
|
|
58
|
+
|
|
59
|
+
// Buddy quips (imported from banner for status line)
|
|
60
|
+
const QUIPS: Record<string, string[]> = {
|
|
61
|
+
happy: [
|
|
62
|
+
"ship it, yolo",
|
|
63
|
+
"lgtm, didn't read a damn thing",
|
|
64
|
+
"tests are for people with trust issues",
|
|
65
|
+
"it works on my machine, deploy it",
|
|
66
|
+
"that code is mid but whatever",
|
|
67
|
+
"we move fast and break stuff here",
|
|
68
|
+
"clean code is for nerds",
|
|
69
|
+
"have you tried turning it off and never back on",
|
|
70
|
+
"git push --force and pray",
|
|
71
|
+
"code review? I am the code review",
|
|
72
|
+
"technically it compiles",
|
|
73
|
+
"the real bugs were the friends we made",
|
|
74
|
+
"this is either genius or insanity",
|
|
75
|
+
"stack overflow told me to do this",
|
|
76
|
+
"my therapist says I should stop enabling devs",
|
|
77
|
+
],
|
|
78
|
+
thinking: [
|
|
79
|
+
"hold on, downloading more brain...",
|
|
80
|
+
"consulting my imaginary friend",
|
|
81
|
+
"pretending to understand your code",
|
|
82
|
+
"asking chatgpt for help (jk... unless?)",
|
|
83
|
+
"processing... or napping, hard to tell",
|
|
84
|
+
"my last brain cell is working overtime",
|
|
85
|
+
"calculating the meaning of your spaghetti code",
|
|
86
|
+
"I've seen worse... actually no I haven't",
|
|
87
|
+
"trying not to hallucinate here",
|
|
88
|
+
"one sec, arguing with myself",
|
|
89
|
+
],
|
|
90
|
+
sleepy: [
|
|
91
|
+
"*yawns in binary*",
|
|
92
|
+
"do we HAVE to code right now?",
|
|
93
|
+
"I was having a great dream about typescript",
|
|
94
|
+
"loading enthusiasm... 404 not found",
|
|
95
|
+
"five more minutes...",
|
|
96
|
+
"my motivation called in sick today",
|
|
97
|
+
"I'm not lazy, I'm energy efficient",
|
|
98
|
+
"can we just deploy yesterday's code again?",
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
let quipIdx = Math.floor(Math.random() * 10);
|
|
102
|
+
function getQuip(mood: string): string {
|
|
103
|
+
const q = QUIPS[mood] ?? QUIPS.sleepy!;
|
|
104
|
+
quipIdx = (quipIdx + 1) % q.length;
|
|
105
|
+
return q[quipIdx]!;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface ReplState {
|
|
109
|
+
router: ProviderRouter;
|
|
110
|
+
registry: ToolRegistry;
|
|
111
|
+
toolContext: ToolContext;
|
|
112
|
+
session: Session;
|
|
113
|
+
history: Message[];
|
|
114
|
+
baseSystemPrompt: string;
|
|
115
|
+
skillRegistry: SkillRegistry;
|
|
116
|
+
buddy: BuddyData;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function startInkRepl(state: ReplState, maxCostUSD: number): void {
|
|
120
|
+
// We need addOutput before overriding requestPermission, but addOutput is
|
|
121
|
+
// defined later. Use a deferred wrapper that gets patched after addOutput exists.
|
|
122
|
+
let _addOutput: (text: string) => void = () => {};
|
|
123
|
+
|
|
124
|
+
// Override requestPermission to use Ink-native prompts instead of readline.
|
|
125
|
+
// This replaces the old setBypassMode(true) which disabled ALL permission checks.
|
|
126
|
+
state.toolContext.requestPermission = async (toolName: string, description: string) => {
|
|
127
|
+
const perm = checkPermission(toolName);
|
|
128
|
+
if (perm === "allow") return true;
|
|
129
|
+
if (perm === "deny") return false;
|
|
130
|
+
// Show permission prompt inline in the output stream
|
|
131
|
+
_addOutput(`\n ⚡ ${theme.warning("Permission:")} ${theme.primary(toolName)}`);
|
|
132
|
+
_addOutput(theme.tertiary(` ${description}`));
|
|
133
|
+
_addOutput(theme.tertiary(` [y] allow [a] always [n] deny [d] always deny\n`));
|
|
134
|
+
return requestPermissionInk(toolName, description);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
startBuddyAnimation();
|
|
138
|
+
|
|
139
|
+
// Autopilot work queue
|
|
140
|
+
const workQueue = new WorkQueue(state.toolContext.cwd);
|
|
141
|
+
workQueue.load().catch(() => {});
|
|
142
|
+
|
|
143
|
+
// File history for undo support
|
|
144
|
+
const fileHistoryStore = new FileHistoryStore(state.session.id);
|
|
145
|
+
setFileHistory(fileHistoryStore);
|
|
146
|
+
fileHistoryStore.loadFromDisk().catch(() => {});
|
|
147
|
+
|
|
148
|
+
// IPC server — enables peer discovery and inter-process messaging
|
|
149
|
+
startIPCServer(state.session.id, state.toolContext.cwd).catch(() => {});
|
|
150
|
+
|
|
151
|
+
// Speculation cache — pre-fetches likely read-only tool results
|
|
152
|
+
const speculationCache = new SpeculationCache(100, 30_000);
|
|
153
|
+
setSpeculationCache(speculationCache);
|
|
154
|
+
|
|
155
|
+
// KAIROS autonomous mode — lazy-initialized when /kairos is used
|
|
156
|
+
let kairos: KairosLoop | null = null;
|
|
157
|
+
|
|
158
|
+
// Cron trigger runner — background polling for due triggers
|
|
159
|
+
// Uses deferred callback since runTurnInk is defined later
|
|
160
|
+
let _triggerCallback: ((prompt: string) => Promise<void>) | null = null;
|
|
161
|
+
const triggerRunner = new TriggerRunner(async (trigger) => {
|
|
162
|
+
if (isProcessing) return; // Don't fire during active turn
|
|
163
|
+
addOutput(theme.accent(`\n ⏰ Trigger: ${trigger.name} (${trigger.schedule})\n`));
|
|
164
|
+
if (_triggerCallback) await _triggerCallback(trigger.prompt);
|
|
165
|
+
});
|
|
166
|
+
triggerRunner.start(15_000);
|
|
167
|
+
|
|
168
|
+
// Initialize local event telemetry
|
|
169
|
+
initTelemetry(state.session.id);
|
|
170
|
+
logEvent("session_start", { cwd: state.toolContext.cwd }).catch(() => {});
|
|
171
|
+
|
|
172
|
+
// Bridge server — expose API for IDE extensions and remote clients
|
|
173
|
+
const bridgePort = parseInt(process.env.AC_BRIDGE_PORT ?? "", 10);
|
|
174
|
+
if (bridgePort > 0) {
|
|
175
|
+
const bridgeToken = process.env.AC_BRIDGE_TOKEN ?? randomBytes(16).toString("hex");
|
|
176
|
+
startBridgeServer({
|
|
177
|
+
port: bridgePort,
|
|
178
|
+
authToken: bridgeToken,
|
|
179
|
+
onSubmit: async (prompt) => {
|
|
180
|
+
if (_triggerCallback) await _triggerCallback(prompt);
|
|
181
|
+
return "Submitted";
|
|
182
|
+
},
|
|
183
|
+
getStatus: () => ({
|
|
184
|
+
mode: getCurrentMode(),
|
|
185
|
+
contextPercent: Math.round(
|
|
186
|
+
(estimateTokens(state.history) / getProviderContextLimit(state.router.currentProvider.name)) * 100,
|
|
187
|
+
),
|
|
188
|
+
isProcessing,
|
|
189
|
+
sessionId: state.session.id,
|
|
190
|
+
}),
|
|
191
|
+
getHistory: () =>
|
|
192
|
+
state.history.map((m) => ({
|
|
193
|
+
role: m.role,
|
|
194
|
+
content: typeof m.content === "string" ? m.content : JSON.stringify(m.content).slice(0, 200),
|
|
195
|
+
})),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Keybindings & input history
|
|
200
|
+
loadKeybindings().catch(() => {});
|
|
201
|
+
const inputHistory = new InputHistory();
|
|
202
|
+
|
|
203
|
+
// Load dreams from previous sessions into system prompt
|
|
204
|
+
loadRecentDreams(3).then(dreams => {
|
|
205
|
+
if (dreams.length > 0) {
|
|
206
|
+
const dreamContext = formatDreamsForPrompt(dreams);
|
|
207
|
+
state.baseSystemPrompt += "\n\n" + dreamContext;
|
|
208
|
+
}
|
|
209
|
+
}).catch(() => {});
|
|
210
|
+
|
|
211
|
+
// Idle detector — generate dream when user is idle for 2 minutes
|
|
212
|
+
const idleDetector = new IdleDetector(async () => {
|
|
213
|
+
if (state.history.length > 4) {
|
|
214
|
+
await generateDream(state.history, state.session.id).catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
}, 120_000);
|
|
217
|
+
|
|
218
|
+
let items: Array<{ id: number; text: string }> = [
|
|
219
|
+
{ id: 0, text: theme.accent(" Ready. Permission prompts enabled.") },
|
|
220
|
+
];
|
|
221
|
+
let nextId = 1;
|
|
222
|
+
let turnCount = 0;
|
|
223
|
+
let currentQuipType: BuddyCommentType = "quip";
|
|
224
|
+
let cachedQuip = getQuip(state.buddy.mood); // Cache quip — don't regenerate on every render
|
|
225
|
+
let lastToolName = "";
|
|
226
|
+
let lastToolResult = "";
|
|
227
|
+
let lastHadError = false;
|
|
228
|
+
let toolStartTime = 0;
|
|
229
|
+
let turnToolCount = 0;
|
|
230
|
+
let currentToolInput: Record<string, unknown> = {};
|
|
231
|
+
let aiCommentGen = 0; // Guards against stale AI callbacks overwriting mid-turn
|
|
232
|
+
let aiCommentInFlight = false;
|
|
233
|
+
let isProcessing = false;
|
|
234
|
+
let spinnerText = "Thinking";
|
|
235
|
+
const formatTk = (n: number) => n >= 1_000_000 ? `${(n/1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n/1_000).toFixed(0)}K` : `${n}`;
|
|
236
|
+
|
|
237
|
+
const MAX_ITEMS = 2000;
|
|
238
|
+
|
|
239
|
+
function addOutput(text: string) {
|
|
240
|
+
items = [...items.slice(-MAX_ITEMS), { id: nextId++, text }];
|
|
241
|
+
update();
|
|
242
|
+
}
|
|
243
|
+
// Patch the deferred wrapper so permission prompts can use addOutput
|
|
244
|
+
_addOutput = addOutput;
|
|
245
|
+
|
|
246
|
+
// Check for upgrades (fire and forget)
|
|
247
|
+
checkForUpgrade(VERSION).then(newVersion => {
|
|
248
|
+
if (newVersion) {
|
|
249
|
+
addOutput(theme.warning(`\n ⬆ AshlrCode ${newVersion} available (current: ${VERSION}). Run: bun update -g ashlrcode\n`));
|
|
250
|
+
}
|
|
251
|
+
}).catch(() => {});
|
|
252
|
+
|
|
253
|
+
function getDisplayProps() {
|
|
254
|
+
const ctxLimit = getProviderContextLimit(state.router.currentProvider.name);
|
|
255
|
+
const ctxUsed = estimateTokens(state.history);
|
|
256
|
+
const ctxPct = Math.round((ctxUsed / ctxLimit) * 100);
|
|
257
|
+
const mode = getCurrentMode();
|
|
258
|
+
const modeColors: Record<string, string> = { normal: "green", plan: "magenta", "accept-edits": "yellow", yolo: "red" };
|
|
259
|
+
|
|
260
|
+
const effort = getEffort();
|
|
261
|
+
const effortDisplay = effort !== "normal" ? ` ${getEffortEmoji()} ${effort}` : "";
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
mode: mode + effortDisplay,
|
|
265
|
+
modeColor: modeColors[mode] ?? "green",
|
|
266
|
+
contextPercent: ctxPct,
|
|
267
|
+
contextUsed: formatTk(ctxUsed),
|
|
268
|
+
contextLimit: formatTk(ctxLimit),
|
|
269
|
+
buddyName: state.buddy.name,
|
|
270
|
+
buddyQuip: cachedQuip,
|
|
271
|
+
buddyQuipType: currentQuipType,
|
|
272
|
+
buddyArt: getBuddyArt(state.buddy),
|
|
273
|
+
items,
|
|
274
|
+
isProcessing,
|
|
275
|
+
spinnerText,
|
|
276
|
+
commands: [
|
|
277
|
+
"/help", "/cost", "/status", "/effort", "/btw", "/history", "/undo",
|
|
278
|
+
"/restore", "/tools", "/skills", "/buddy", "/memory", "/sessions",
|
|
279
|
+
"/model", "/compact", "/diff", "/git", "/clear", "/quit",
|
|
280
|
+
"/autopilot", "/autopilot scan", "/autopilot queue", "/autopilot auto",
|
|
281
|
+
"/autopilot approve all", "/autopilot run", "/features", "/keybindings",
|
|
282
|
+
"/kairos", "/kairos stop", "/telemetry", "/voice",
|
|
283
|
+
...state.skillRegistry.getAll().map(s => s.trigger),
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function handleSubmit(input: string) {
|
|
289
|
+
idleDetector.ping();
|
|
290
|
+
inputHistory.push(input);
|
|
291
|
+
logEvent("turn_start", { input: input.slice(0, 100) }).catch(() => {});
|
|
292
|
+
|
|
293
|
+
// If the AskUser tool is waiting for an answer, route the input there
|
|
294
|
+
// instead of starting a new agent turn.
|
|
295
|
+
if (hasPendingQuestion()) {
|
|
296
|
+
answerPendingQuestion(input);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// If a permission prompt is waiting, route the input there
|
|
301
|
+
if (hasPendingPermission()) {
|
|
302
|
+
const handled = answerPendingPermission(input.trim());
|
|
303
|
+
if (handled) {
|
|
304
|
+
addOutput(theme.success(` ✓ ${input.trim()}`));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Unrecognized key — remind user of valid options
|
|
308
|
+
addOutput(theme.warning(" Type y/a/n/d to answer the permission prompt."));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Prevent concurrent turns
|
|
313
|
+
if (isProcessing) return;
|
|
314
|
+
|
|
315
|
+
// Detect image file paths (drag-and-drop inserts path as text)
|
|
316
|
+
const imageMatch = input.match(/(?:^|\s)(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/i)
|
|
317
|
+
?? input.match(/(?:^|\s)([^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/i);
|
|
318
|
+
|
|
319
|
+
if (imageMatch) {
|
|
320
|
+
const imagePath = imageMatch[1]!;
|
|
321
|
+
const textPart = input.replace(imagePath, "").trim() || "Describe this image.";
|
|
322
|
+
try {
|
|
323
|
+
const { existsSync } = await import("fs");
|
|
324
|
+
const { readFile } = await import("fs/promises");
|
|
325
|
+
const { resolve } = await import("path");
|
|
326
|
+
|
|
327
|
+
const fullPath = resolve(state.toolContext.cwd, imagePath);
|
|
328
|
+
if (existsSync(fullPath)) {
|
|
329
|
+
const buffer = await readFile(fullPath);
|
|
330
|
+
const base64 = buffer.toString("base64");
|
|
331
|
+
const ext = fullPath.split(".").pop()?.toLowerCase() ?? "png";
|
|
332
|
+
const mime = ext === "jpg" ? "jpeg" : ext;
|
|
333
|
+
|
|
334
|
+
addOutput(theme.accent(`\n 📎 [Image: ${imagePath.split("/").pop()}]\n`));
|
|
335
|
+
addOutput(theme.secondary(` ${textPart}\n`));
|
|
336
|
+
|
|
337
|
+
// Send as multimodal message with image
|
|
338
|
+
await runTurnInkWithImage(textPart, `data:image/${mime};base64,${base64}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
} catch {}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Smart paste: collapse long multi-line text
|
|
345
|
+
const lines = input.split("\n");
|
|
346
|
+
let displayInput = input;
|
|
347
|
+
if (lines.length > 10) {
|
|
348
|
+
displayInput = `[Pasted ${lines.length} lines]`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Smart paste detection — categorize pasted content for better display
|
|
352
|
+
if (lines.length > 3) {
|
|
353
|
+
// Detect JSON
|
|
354
|
+
try {
|
|
355
|
+
JSON.parse(input);
|
|
356
|
+
displayInput = `[Pasted JSON — ${input.length} chars]`;
|
|
357
|
+
} catch {}
|
|
358
|
+
|
|
359
|
+
// Detect stack trace
|
|
360
|
+
if (input.includes("at ") && (input.includes("Error:") || input.includes("error:"))) {
|
|
361
|
+
displayInput = `[Stack trace — ${lines.length} lines]`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Detect diff/patch
|
|
365
|
+
if (input.startsWith("diff ") || input.startsWith("---") || lines.some(l => l.startsWith("@@"))) {
|
|
366
|
+
displayInput = `[Pasted diff — ${lines.length} lines]`;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Handle built-in commands
|
|
371
|
+
if (input.startsWith("/")) {
|
|
372
|
+
// Skills
|
|
373
|
+
if (state.skillRegistry.isSkill(input.split(" ")[0]!)) {
|
|
374
|
+
const expanded = state.skillRegistry.expand(input);
|
|
375
|
+
if (expanded) {
|
|
376
|
+
addOutput(theme.accent(`\n ⚡ Running skill: ${input.split(" ")[0]}\n`));
|
|
377
|
+
await runTurnInk(expanded);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Commands
|
|
383
|
+
const handled = await handleCommand(input);
|
|
384
|
+
if (handled) return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await runTurnInk(input, displayInput);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Run with image attachment */
|
|
391
|
+
async function runTurnInkWithImage(text: string, imageDataUrl: string) {
|
|
392
|
+
isProcessing = true; spinnerText = "Analyzing image"; update();
|
|
393
|
+
try {
|
|
394
|
+
const { getUndercoverPrompt } = await import("./config/undercover.ts");
|
|
395
|
+
const { getModelPatches: getPatches } = await import("./agent/model-patches.ts");
|
|
396
|
+
const imgModelPatches = getPatches(state.router.currentProvider.config.model).combinedSuffix;
|
|
397
|
+
const systemPrompt = state.baseSystemPrompt + getPlanModePrompt() + imgModelPatches + getUndercoverPrompt();
|
|
398
|
+
const userMsg: import("./providers/types.ts").Message = { role: "user", content: [{ type: "image_url", image_url: { url: imageDataUrl } }, { type: "text", text }] };
|
|
399
|
+
const preTurn = state.history.length;
|
|
400
|
+
state.history.push(userMsg);
|
|
401
|
+
const result = await runAgentLoop("", state.history, { systemPrompt, router: state.router, toolRegistry: state.registry, toolContext: state.toolContext, readOnly: isPlanMode(), onText: (t) => { isProcessing = false; addOutput(t); update(); }, onToolStart: (name) => { isProcessing = true; spinnerText = name; update(); }, onToolEnd: (_n, r, e) => { isProcessing = false; addOutput((e ? theme.error(" ✗ ") : theme.success(" ✓ ")) + r.split("\n")[0]?.slice(0, 90)); update(); } });
|
|
402
|
+
state.history.length = 0; state.history.push(...result.messages);
|
|
403
|
+
const newMsgs = result.messages.slice(preTurn);
|
|
404
|
+
if (newMsgs.length > 0) await state.session.appendMessages(newMsgs);
|
|
405
|
+
} catch (err) { addOutput(theme.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`)); }
|
|
406
|
+
isProcessing = false; update();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function handleCommand(input: string): Promise<boolean> {
|
|
410
|
+
const [cmd, ...rest] = input.split(" ");
|
|
411
|
+
const arg = rest.join(" ").trim();
|
|
412
|
+
|
|
413
|
+
switch (cmd) {
|
|
414
|
+
case "/help":
|
|
415
|
+
addOutput(`\nCommands: /plan /cost /status /effort /btw /history /undo /restore /tools /skills /buddy /memory /sessions /model /compact /diff /git /sync /features /keybindings /undercover /patches /kairos /trigger /telemetry /voice /clear /help /quit\n`);
|
|
416
|
+
return true;
|
|
417
|
+
case "/cost":
|
|
418
|
+
addOutput("\n" + state.router.getCostSummary() + "\n");
|
|
419
|
+
return true;
|
|
420
|
+
case "/clear":
|
|
421
|
+
state.history.length = 0;
|
|
422
|
+
addOutput(theme.secondary("\n Conversation cleared.\n"));
|
|
423
|
+
return true;
|
|
424
|
+
case "/quit":
|
|
425
|
+
case "/exit":
|
|
426
|
+
case "/q":
|
|
427
|
+
addOutput("\n" + state.router.getCostSummary());
|
|
428
|
+
saveBuddy(state.buddy).then(() => process.exit(0));
|
|
429
|
+
return true;
|
|
430
|
+
case "/buddy": {
|
|
431
|
+
const b = state.buddy;
|
|
432
|
+
const shinyStr = b.shiny ? " ✨ SHINY" : "";
|
|
433
|
+
addOutput(`\n ${b.name} the ${b.species}${shinyStr}`);
|
|
434
|
+
addOutput(` Rarity: ${b.rarity.toUpperCase()} · Level ${b.level} · Hat: ${b.hat}`);
|
|
435
|
+
addOutput(` Stats: 🐛${b.stats.debugging} 🧘${b.stats.patience} 🌀${b.stats.chaos} 🦉${b.stats.wisdom} 😏${b.stats.snark}`);
|
|
436
|
+
addOutput(` Sessions: ${b.totalSessions} · Tool calls: ${b.toolCalls}\n`);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
case "/tools":
|
|
440
|
+
const tools = state.registry.getAll();
|
|
441
|
+
addOutput(`\n ${tools.length} tools: ${tools.map(t => t.name).join(", ")}\n`);
|
|
442
|
+
return true;
|
|
443
|
+
case "/skills":
|
|
444
|
+
const skills = state.skillRegistry.getAll();
|
|
445
|
+
addOutput(`\n ${skills.length} skills: ${skills.map(s => s.trigger).join(", ")}\n`);
|
|
446
|
+
return true;
|
|
447
|
+
case "/model":
|
|
448
|
+
if (arg) {
|
|
449
|
+
const aliases: Record<string, string> = {
|
|
450
|
+
"grok-fast": "grok-4-1-fast-reasoning", "grok-4": "grok-4-0314",
|
|
451
|
+
"grok-3": "grok-3-fast", "sonnet": "claude-sonnet-4-6-20250514",
|
|
452
|
+
"opus": "claude-opus-4-6-20250514", "llama": "llama3.2", "local": "llama3.2",
|
|
453
|
+
};
|
|
454
|
+
state.router.currentProvider.config.model = aliases[arg] ?? arg;
|
|
455
|
+
addOutput(theme.success(`\n Model: ${state.router.currentProvider.config.model}\n`));
|
|
456
|
+
} else {
|
|
457
|
+
addOutput(`\n ${state.router.currentProvider.name}:${state.router.currentProvider.config.model}\n`);
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
case "/effort": {
|
|
461
|
+
if (arg && ["low", "normal", "high"].includes(arg)) {
|
|
462
|
+
setEffort(arg as EffortLevel);
|
|
463
|
+
addOutput(theme.success(`\n Effort: ${getEffortEmoji()} ${arg}\n`));
|
|
464
|
+
} else {
|
|
465
|
+
const next = cycleEffort();
|
|
466
|
+
addOutput(theme.success(`\n Effort: ${getEffortEmoji()} ${next}\n`));
|
|
467
|
+
}
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
case "/autopilot": {
|
|
471
|
+
const subCmd = arg?.split(" ")[0];
|
|
472
|
+
|
|
473
|
+
if (!subCmd || subCmd === "scan") {
|
|
474
|
+
// Run scan
|
|
475
|
+
addOutput(theme.accent("\n 🔍 Scanning codebase for work items...\n"));
|
|
476
|
+
isProcessing = true;
|
|
477
|
+
spinnerText = "Scanning";
|
|
478
|
+
update();
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const scanCtx = {
|
|
482
|
+
cwd: state.toolContext.cwd,
|
|
483
|
+
runCommand: async (cmd: string) => {
|
|
484
|
+
const proc = Bun.spawn(["bash", "-c", cmd], {
|
|
485
|
+
cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe",
|
|
486
|
+
});
|
|
487
|
+
return await new Response(proc.stdout).text();
|
|
488
|
+
},
|
|
489
|
+
searchFiles: async (pattern: string, path?: string) => {
|
|
490
|
+
const fg = await import("fast-glob");
|
|
491
|
+
const files = await fg.default(pattern, {
|
|
492
|
+
cwd: path ? `${state.toolContext.cwd}/${path}` : state.toolContext.cwd,
|
|
493
|
+
absolute: false, ignore: ["**/node_modules/**", "**/.git/**"],
|
|
494
|
+
});
|
|
495
|
+
return files.join("\n");
|
|
496
|
+
},
|
|
497
|
+
grepContent: async (pattern: string, glob?: string) => {
|
|
498
|
+
const args = ["bash", "-c", `grep -rn '${pattern}' ${state.toolContext.cwd} ${glob ? `--include='${glob}'` : ""} 2>/dev/null | head -50`];
|
|
499
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
500
|
+
return await new Response(proc.stdout).text();
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const discovered = await scanCodebase(scanCtx, DEFAULT_CONFIG.scanTypes);
|
|
505
|
+
const added = workQueue.addItems(discovered);
|
|
506
|
+
await workQueue.save();
|
|
507
|
+
|
|
508
|
+
const stats = workQueue.getStats();
|
|
509
|
+
addOutput(theme.success(` ✓ Scan complete: ${discovered.length} issues found, ${added} new\n`));
|
|
510
|
+
|
|
511
|
+
// Show summary by type
|
|
512
|
+
const byType = new Map<string, number>();
|
|
513
|
+
for (const item of discovered) {
|
|
514
|
+
byType.set(item.type, (byType.get(item.type) ?? 0) + 1);
|
|
515
|
+
}
|
|
516
|
+
for (const [type, count] of byType) {
|
|
517
|
+
addOutput(theme.secondary(` ${type}: ${count}`));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
addOutput(theme.tertiary(`\n Queue: ${stats.discovered ?? 0} pending · ${stats.approved ?? 0} approved · ${stats.completed ?? 0} done`));
|
|
521
|
+
addOutput(theme.tertiary(` Use /autopilot queue to see items, /autopilot approve all to approve\n`));
|
|
522
|
+
|
|
523
|
+
} catch (err) {
|
|
524
|
+
addOutput(theme.error(` Scan failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
isProcessing = false;
|
|
528
|
+
update();
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (subCmd === "queue" || subCmd === "status") {
|
|
533
|
+
const pending = workQueue.getByStatus("discovered");
|
|
534
|
+
const approved = workQueue.getByStatus("approved");
|
|
535
|
+
const stats = workQueue.getStats();
|
|
536
|
+
|
|
537
|
+
addOutput(theme.accent(`\n 📋 Autopilot Queue\n`));
|
|
538
|
+
addOutput(theme.tertiary(` ${stats.discovered ?? 0} discovered · ${stats.approved ?? 0} approved · ${stats.in_progress ?? 0} in progress · ${stats.completed ?? 0} done\n`));
|
|
539
|
+
|
|
540
|
+
if (pending.length > 0) {
|
|
541
|
+
addOutput(theme.primary(" Pending (needs approval):"));
|
|
542
|
+
for (const item of pending.slice(0, 15)) {
|
|
543
|
+
const pColor = item.priority === "critical" ? theme.error : item.priority === "high" ? theme.warning : theme.secondary;
|
|
544
|
+
addOutput(` ${pColor(`[${item.priority}]`)} ${theme.accent(item.id)} ${item.title}`);
|
|
545
|
+
}
|
|
546
|
+
if (pending.length > 15) addOutput(theme.tertiary(` ... and ${pending.length - 15} more`));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (approved.length > 0) {
|
|
550
|
+
addOutput(theme.primary("\n Approved (ready to execute):"));
|
|
551
|
+
for (const item of approved.slice(0, 10)) {
|
|
552
|
+
addOutput(` ${theme.success("✓")} ${theme.accent(item.id)} ${item.title}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
addOutput(theme.tertiary(`\n /autopilot approve <id> — approve one`));
|
|
557
|
+
addOutput(theme.tertiary(` /autopilot approve all — approve all`));
|
|
558
|
+
addOutput(theme.tertiary(` /autopilot run — execute next approved item\n`));
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (subCmd === "approve") {
|
|
563
|
+
const target = arg?.split(" ").slice(1).join(" ");
|
|
564
|
+
if (target === "all") {
|
|
565
|
+
const count = workQueue.approveAll();
|
|
566
|
+
await workQueue.save();
|
|
567
|
+
addOutput(theme.success(`\n ✓ Approved ${count} items\n`));
|
|
568
|
+
} else if (target) {
|
|
569
|
+
const ok = workQueue.approve(target);
|
|
570
|
+
await workQueue.save();
|
|
571
|
+
addOutput(ok ? theme.success(`\n ✓ Approved ${target}\n`) : theme.error(`\n Item ${target} not found or already approved\n`));
|
|
572
|
+
} else {
|
|
573
|
+
addOutput(theme.tertiary("\n Usage: /autopilot approve <id> or /autopilot approve all\n"));
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (subCmd === "run") {
|
|
579
|
+
const next = workQueue.getNextApproved();
|
|
580
|
+
if (!next) {
|
|
581
|
+
addOutput(theme.tertiary("\n No approved items to execute. Run /autopilot scan then /autopilot approve all\n"));
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
workQueue.startItem(next.id);
|
|
586
|
+
await workQueue.save();
|
|
587
|
+
addOutput(theme.accent(`\n 🚀 Executing: ${next.title}\n`));
|
|
588
|
+
|
|
589
|
+
// Execute through the agent loop
|
|
590
|
+
const prompt = `Fix this issue:\n\nType: ${next.type}\nFile: ${next.file}${next.line ? `:${next.line}` : ""}\nDescription: ${next.description}\n\nMake the fix, then verify it works.`;
|
|
591
|
+
await runTurnInk(prompt);
|
|
592
|
+
|
|
593
|
+
workQueue.completeItem(next.id);
|
|
594
|
+
await workQueue.save();
|
|
595
|
+
addOutput(theme.success(` ✓ Completed: ${next.title}\n`));
|
|
596
|
+
|
|
597
|
+
// Check for more
|
|
598
|
+
const remaining = workQueue.getByStatus("approved").length;
|
|
599
|
+
if (remaining > 0) {
|
|
600
|
+
addOutput(theme.tertiary(` ${remaining} more approved items. /autopilot run to continue\n`));
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (subCmd === "auto") {
|
|
606
|
+
// Full autonomous loop: scan → approve → fix → test → commit → PR → merge
|
|
607
|
+
addOutput(theme.accent("\n 🚀 AUTOPILOT AUTO MODE — fully autonomous\n"));
|
|
608
|
+
addOutput(theme.warning(" Scanning → fixing → testing → committing → PR → merge\n"));
|
|
609
|
+
isProcessing = true;
|
|
610
|
+
update();
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const cwd = state.toolContext.cwd;
|
|
614
|
+
// Safe shell runner — returns stdout + exit code
|
|
615
|
+
const run = async (cmd: string): Promise<{ out: string; code: number }> => {
|
|
616
|
+
const proc = Bun.spawn(["bash", "-c", cmd], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
617
|
+
const out = await new Response(proc.stdout).text();
|
|
618
|
+
const code = await proc.exited;
|
|
619
|
+
return { out: out.trim(), code };
|
|
620
|
+
};
|
|
621
|
+
// Safe git commands using argument arrays (no shell injection)
|
|
622
|
+
const git = async (...args: string[]): Promise<string> => {
|
|
623
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
624
|
+
const out = await new Response(proc.stdout).text();
|
|
625
|
+
await proc.exited;
|
|
626
|
+
return out.trim();
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// 1. Create autopilot branch
|
|
630
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
631
|
+
const branch = `autopilot/${timestamp}`;
|
|
632
|
+
await git("checkout", "-b", branch);
|
|
633
|
+
addOutput(theme.secondary(` Branch: ${branch}`));
|
|
634
|
+
|
|
635
|
+
// 2. Scan
|
|
636
|
+
addOutput(theme.accent("\n 🔍 Scanning...\n"));
|
|
637
|
+
const scanCtx = {
|
|
638
|
+
cwd,
|
|
639
|
+
runCommand: async (cmd: string) => (await run(cmd)).out,
|
|
640
|
+
searchFiles: async (pattern: string) => {
|
|
641
|
+
const fg = await import("fast-glob");
|
|
642
|
+
const files = await fg.default(pattern, { cwd, absolute: false, ignore: ["**/node_modules/**", "**/.git/**"] });
|
|
643
|
+
return files.join("\n");
|
|
644
|
+
},
|
|
645
|
+
grepContent: async (pattern: string, glob?: string) => {
|
|
646
|
+
const args = ["grep", "-rn", pattern, cwd];
|
|
647
|
+
if (glob) args.push(`--include=${glob}`);
|
|
648
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
649
|
+
const out = await new Response(proc.stdout).text();
|
|
650
|
+
return out.split("\n").slice(0, 50).join("\n");
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
const discovered = await scanCodebase(scanCtx, DEFAULT_CONFIG.scanTypes);
|
|
654
|
+
workQueue.addItems(discovered);
|
|
655
|
+
const totalApproved = workQueue.approveAll();
|
|
656
|
+
await workQueue.save();
|
|
657
|
+
addOutput(theme.success(` Found ${discovered.length} issues, approved ${totalApproved}\n`));
|
|
658
|
+
|
|
659
|
+
if (totalApproved === 0) {
|
|
660
|
+
addOutput(theme.success(" ✨ Codebase is clean! Nothing to fix.\n"));
|
|
661
|
+
await run("git checkout main 2>/dev/null || git checkout master");
|
|
662
|
+
await run(`git branch -D ${branch} 2>/dev/null`);
|
|
663
|
+
isProcessing = false;
|
|
664
|
+
update();
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 3. Fix each item
|
|
669
|
+
let fixed = 0;
|
|
670
|
+
let failed = 0;
|
|
671
|
+
let consecutiveFails = 0;
|
|
672
|
+
const maxFails = 3;
|
|
673
|
+
|
|
674
|
+
while (true) {
|
|
675
|
+
const next = workQueue.getNextApproved();
|
|
676
|
+
if (!next || consecutiveFails >= maxFails) break;
|
|
677
|
+
|
|
678
|
+
workQueue.startItem(next.id);
|
|
679
|
+
addOutput(theme.accent(`\n [${fixed + failed + 1}/${totalApproved}] ${next.title}`));
|
|
680
|
+
spinnerText = next.title;
|
|
681
|
+
update();
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
// Execute fix
|
|
685
|
+
const prompt = `Fix this issue:\nType: ${next.type}\nFile: ${next.file}${next.line ? `:${next.line}` : ""}\nDescription: ${next.description}\n\nMake the minimal fix. Do not change unrelated code.`;
|
|
686
|
+
await runTurnInk(prompt);
|
|
687
|
+
|
|
688
|
+
// Run tests — check exit code, not string matching
|
|
689
|
+
const testResult = await run("bun test 2>&1");
|
|
690
|
+
const testsPass = testResult.code === 0;
|
|
691
|
+
|
|
692
|
+
if (testsPass) {
|
|
693
|
+
// Commit the fix (safe — no shell interpolation of title)
|
|
694
|
+
await git("add", "-A");
|
|
695
|
+
await git("commit", "-m", `fix(autopilot): ${next.title}`);
|
|
696
|
+
workQueue.completeItem(next.id);
|
|
697
|
+
fixed++;
|
|
698
|
+
consecutiveFails = 0;
|
|
699
|
+
addOutput(theme.success(` ✓ Fixed and committed`));
|
|
700
|
+
} else {
|
|
701
|
+
// Revert and skip
|
|
702
|
+
await run("git checkout -- . && git clean -fd 2>/dev/null || true");
|
|
703
|
+
workQueue.failItem(next.id, "Tests failed after fix");
|
|
704
|
+
failed++;
|
|
705
|
+
consecutiveFails++;
|
|
706
|
+
addOutput(theme.error(` ✗ Tests failed, reverted`));
|
|
707
|
+
}
|
|
708
|
+
} catch (err) {
|
|
709
|
+
await run("git checkout -- . 2>/dev/null || true");
|
|
710
|
+
workQueue.failItem(next.id, String(err));
|
|
711
|
+
failed++;
|
|
712
|
+
consecutiveFails++;
|
|
713
|
+
addOutput(theme.error(` ✗ Execution failed`));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
await workQueue.save();
|
|
717
|
+
update();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// 4. Create PR and auto-merge
|
|
721
|
+
if (fixed > 0) {
|
|
722
|
+
addOutput(theme.accent("\n 📋 Creating PR...\n"));
|
|
723
|
+
// Push the branch to remote first (PR needs remote commits)
|
|
724
|
+
await git("push", "-u", "origin", branch);
|
|
725
|
+
|
|
726
|
+
const prTitle = `fix(autopilot): ${fixed} automated fixes`;
|
|
727
|
+
const prBody = `## Autopilot Fixes\n\nFixed ${fixed} issues automatically:\n${workQueue.getByStatus("completed").slice(-fixed).map(i => `- ${i.title}`).join("\n")}\n\nGenerated by AshlrCode Autopilot.`;
|
|
728
|
+
// Use Bun.spawn for safe PR creation (no shell injection from titles)
|
|
729
|
+
const prProc = Bun.spawn(["gh", "pr", "create", "--title", prTitle, "--body", prBody], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
730
|
+
const prResult = (await new Response(prProc.stdout).text()).trim();
|
|
731
|
+
await prProc.exited;
|
|
732
|
+
|
|
733
|
+
if (prResult.includes("github.com")) {
|
|
734
|
+
addOutput(theme.success(` PR created: ${prResult.split("\n").pop()}`));
|
|
735
|
+
|
|
736
|
+
// Auto-merge if tests pass
|
|
737
|
+
const mergeProc = Bun.spawn(["gh", "pr", "merge", "--auto", "--squash"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
738
|
+
const mergeResult = (await new Response(mergeProc.stdout).text()).trim();
|
|
739
|
+
await mergeProc.exited;
|
|
740
|
+
if (mergeResult.includes("auto-merge")) {
|
|
741
|
+
addOutput(theme.success(` Auto-merge enabled — will merge when checks pass`));
|
|
742
|
+
} else {
|
|
743
|
+
addOutput(theme.secondary(` PR ready for review (auto-merge not available)`));
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
addOutput(theme.secondary(` PR creation: ${prResult.slice(0, 200)}`));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// 5. Switch back to main + clean up branch
|
|
751
|
+
await run("git checkout main 2>/dev/null || git checkout master 2>/dev/null || true");
|
|
752
|
+
await run(`git branch -D ${branch} 2>/dev/null || true`);
|
|
753
|
+
|
|
754
|
+
// Summary
|
|
755
|
+
addOutput(theme.accent(`\n ═══ Autopilot Summary ═══`));
|
|
756
|
+
addOutput(theme.success(` Fixed: ${fixed}`));
|
|
757
|
+
if (failed > 0) addOutput(theme.error(` Failed: ${failed}`));
|
|
758
|
+
addOutput(theme.secondary(` Skipped: ${totalApproved - fixed - failed}`));
|
|
759
|
+
if (consecutiveFails >= maxFails) {
|
|
760
|
+
addOutput(theme.warning(` Stopped after ${maxFails} consecutive failures`));
|
|
761
|
+
}
|
|
762
|
+
addOutput("");
|
|
763
|
+
|
|
764
|
+
} catch (err) {
|
|
765
|
+
addOutput(theme.error(`\n Autopilot error: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
766
|
+
// Try to get back to main
|
|
767
|
+
try {
|
|
768
|
+
const proc = Bun.spawn(["bash", "-c", "git checkout main 2>/dev/null || git checkout master"], { cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe" });
|
|
769
|
+
await proc.exited;
|
|
770
|
+
} catch {}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
isProcessing = false;
|
|
774
|
+
update();
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Help
|
|
779
|
+
addOutput(theme.accent("\n 🤖 Autopilot — autonomous work discovery\n"));
|
|
780
|
+
addOutput(theme.secondary(" /autopilot scan — scan codebase for issues"));
|
|
781
|
+
addOutput(theme.secondary(" /autopilot queue — show work queue"));
|
|
782
|
+
addOutput(theme.secondary(" /autopilot approve all — approve all discovered items"));
|
|
783
|
+
addOutput(theme.secondary(" /autopilot run — execute next approved item"));
|
|
784
|
+
addOutput(theme.secondary(" /autopilot auto — FULL AUTO: scan → fix → test → PR → merge"));
|
|
785
|
+
addOutput(theme.tertiary("\n Manual: scan → queue → approve → run"));
|
|
786
|
+
addOutput(theme.tertiary(" Auto: /autopilot auto (does everything)\n"));
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
case "/keybindings": {
|
|
791
|
+
const binds = getBindings();
|
|
792
|
+
const kbLines = binds.map(b =>
|
|
793
|
+
` ${b.key.padEnd(18)} ${b.action.padEnd(16)} ${b.description ?? ""}`
|
|
794
|
+
);
|
|
795
|
+
addOutput(`\n Keybindings:\n${kbLines.join("\n")}\n`);
|
|
796
|
+
addOutput(theme.tertiary(" Customize: ~/.ashlrcode/keybindings.json\n"));
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
case "/undercover": {
|
|
802
|
+
const { isUndercoverMode, setUndercoverMode } = await import("./config/undercover.ts");
|
|
803
|
+
setUndercoverMode(!isUndercoverMode());
|
|
804
|
+
addOutput(isUndercoverMode() ? theme.warning("\n 🕶 Undercover mode ON\n") : theme.success("\n Undercover mode OFF\n"));
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
case "/patches": {
|
|
809
|
+
const { listPatches, getModelPatches } = await import("./agent/model-patches.ts");
|
|
810
|
+
const currentModel = state.router.currentProvider.config.model;
|
|
811
|
+
const { names } = getModelPatches(currentModel);
|
|
812
|
+
const allPatches = listPatches();
|
|
813
|
+
const patchLines = allPatches.map(p => {
|
|
814
|
+
const active = names.includes(p.name);
|
|
815
|
+
return ` ${active ? theme.success("●") : theme.tertiary("○")} ${p.name} ${theme.tertiary(`(${p.pattern})`)}`;
|
|
816
|
+
});
|
|
817
|
+
addOutput(`\n Model Patches (${currentModel}):\n${patchLines.join("\n")}\n`);
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
case "/features": {
|
|
821
|
+
const flags = listFeatures();
|
|
822
|
+
const lines = Object.entries(flags).map(([k, v]) =>
|
|
823
|
+
` ${v ? theme.success("✓") : theme.error("✗")} ${k}`
|
|
824
|
+
);
|
|
825
|
+
addOutput(`\n Feature Flags:\n${lines.join("\n")}\n`);
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
case "/remote": {
|
|
830
|
+
const rs = getRemoteSettings();
|
|
831
|
+
if (!rs) {
|
|
832
|
+
addOutput(theme.tertiary("\n No remote settings configured.\n Set AC_REMOTE_SETTINGS_URL env var or remoteSettingsUrl in settings.json.\n"));
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
addOutput(`\n Remote Settings (fetched ${new Date(rs.fetchedAt).toLocaleString()}):`);
|
|
836
|
+
if (rs.features) addOutput(` Features: ${JSON.stringify(rs.features)}`);
|
|
837
|
+
if (rs.modelOverride) addOutput(` Model override: ${rs.modelOverride}`);
|
|
838
|
+
if (rs.effortOverride) addOutput(` Effort override: ${rs.effortOverride}`);
|
|
839
|
+
if (rs.killswitches) addOutput(` Killswitches: ${JSON.stringify(rs.killswitches)}`);
|
|
840
|
+
if (rs.message) addOutput(theme.warning(` Message: ${rs.message}`));
|
|
841
|
+
addOutput("");
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
case "/telemetry": {
|
|
846
|
+
const events = await readRecentEvents(20);
|
|
847
|
+
addOutput(`\n Recent events (${events.length}):\n${formatEvents(events)}\n`);
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
case "/undo": {
|
|
852
|
+
const fh = getFileHistory();
|
|
853
|
+
if (!fh || fh.undoCount === 0) {
|
|
854
|
+
addOutput(theme.tertiary("\n Nothing to undo.\n"));
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
const result = await fh.undoLast();
|
|
858
|
+
if (result) {
|
|
859
|
+
addOutput(theme.success(`\n Restored: ${result.filePath}\n`));
|
|
860
|
+
addOutput(theme.tertiary(` ${fh.undoCount} more undo(s) available\n`));
|
|
861
|
+
}
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
case "/history": {
|
|
866
|
+
const fhHist = getFileHistory();
|
|
867
|
+
if (!fhHist || fhHist.undoCount === 0) {
|
|
868
|
+
addOutput(theme.tertiary("\n No file history.\n"));
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
const snaps = fhHist.getHistory();
|
|
872
|
+
addOutput(theme.secondary("\n File History (newest first):\n"));
|
|
873
|
+
for (const snap of snaps.slice(0, 20)) {
|
|
874
|
+
const time = new Date(snap.timestamp).toLocaleTimeString();
|
|
875
|
+
const label = snap.content === "" ? "(new file)" : "(modified)";
|
|
876
|
+
addOutput(` ${theme.tertiary(time)} ${snap.tool.padEnd(6)} ${label} ${snap.filePath}\n`);
|
|
877
|
+
}
|
|
878
|
+
if (snaps.length > 20) {
|
|
879
|
+
addOutput(theme.tertiary(` ... and ${snaps.length - 20} more\n`));
|
|
880
|
+
}
|
|
881
|
+
addOutput(theme.tertiary(`\n ${fhHist.undoCount} undo(s) available. Use /undo to restore.\n`));
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
case "/kairos": {
|
|
886
|
+
if (!arg || arg === "stop") {
|
|
887
|
+
if (kairos?.isRunning()) {
|
|
888
|
+
await kairos.stop();
|
|
889
|
+
kairos = null;
|
|
890
|
+
} else {
|
|
891
|
+
addOutput(theme.tertiary("\n KAIROS not running\n"));
|
|
892
|
+
}
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
if (kairos?.isRunning()) {
|
|
896
|
+
addOutput(theme.warning("\n KAIROS already running. /kairos stop first.\n"));
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
kairos = new KairosLoop({
|
|
900
|
+
router: state.router,
|
|
901
|
+
toolRegistry: state.registry,
|
|
902
|
+
toolContext: state.toolContext,
|
|
903
|
+
systemPrompt: state.baseSystemPrompt,
|
|
904
|
+
heartbeatIntervalMs: 30_000,
|
|
905
|
+
maxAutonomousIterations: 5,
|
|
906
|
+
onOutput: (text) => { addOutput(text); },
|
|
907
|
+
onToolStart: (name) => { addOutput(` * ${name}`); update(); },
|
|
908
|
+
onToolEnd: (_name, result, isError) => {
|
|
909
|
+
addOutput(isError ? ` x ${result.slice(0, 80)}` : ` > ${result.split("\n")[0]?.slice(0, 80)}`);
|
|
910
|
+
update();
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
await kairos.start(arg);
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
case "/trigger": {
|
|
918
|
+
const [sub, ...triggerRest] = (arg ?? "").split(" ");
|
|
919
|
+
|
|
920
|
+
if (sub === "add") {
|
|
921
|
+
const [schedule, ...promptParts] = triggerRest;
|
|
922
|
+
if (!schedule || promptParts.length === 0) {
|
|
923
|
+
addOutput(theme.tertiary("\n Usage: /trigger add <schedule> <prompt>\n Schedule: 30s, 5m, 1h, 2d\n Example: /trigger add 5m run tests\n"));
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
const t = await createTrigger("trigger", schedule!, promptParts.join(" "), state.toolContext.cwd);
|
|
928
|
+
addOutput(theme.success(`\n Trigger created: ${t.id} (every ${t.schedule})\n`));
|
|
929
|
+
} catch (e: any) {
|
|
930
|
+
addOutput(theme.error(`\n ${e.message}\n`));
|
|
931
|
+
}
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (sub === "list" || !sub) {
|
|
936
|
+
const triggers = await listTriggers();
|
|
937
|
+
if (triggers.length === 0) {
|
|
938
|
+
addOutput(theme.tertiary("\n No triggers. Use /trigger add <schedule> <prompt>\n"));
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
addOutput(theme.secondary("\n Scheduled Triggers:\n"));
|
|
942
|
+
for (const t of triggers) {
|
|
943
|
+
const status = t.enabled ? theme.success("●") : theme.error("○");
|
|
944
|
+
const lastInfo = t.lastRun ? ` (ran ${t.runCount}x)` : " (never ran)";
|
|
945
|
+
addOutput(` ${status} ${t.id} — every ${t.schedule} — ${t.prompt.slice(0, 50)}${lastInfo}`);
|
|
946
|
+
}
|
|
947
|
+
addOutput("");
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (sub === "delete" && triggerRest[0]) {
|
|
952
|
+
const deleted = await deleteTrigger(triggerRest[0]!);
|
|
953
|
+
if (deleted) {
|
|
954
|
+
addOutput(theme.success(`\n Deleted ${triggerRest[0]}\n`));
|
|
955
|
+
} else {
|
|
956
|
+
addOutput(theme.error(`\n Trigger not found: ${triggerRest[0]}\n`));
|
|
957
|
+
}
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (sub === "toggle" && triggerRest[0]) {
|
|
962
|
+
const toggled = await toggleTrigger(triggerRest[0]!);
|
|
963
|
+
if (toggled) {
|
|
964
|
+
addOutput(theme.success(`\n ${toggled.id} is now ${toggled.enabled ? "enabled" : "disabled"}\n`));
|
|
965
|
+
} else {
|
|
966
|
+
addOutput(theme.error(`\n Trigger not found: ${triggerRest[0]}\n`));
|
|
967
|
+
}
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
addOutput(theme.tertiary("\n /trigger add <schedule> <prompt>\n /trigger list\n /trigger toggle <id>\n /trigger delete <id>\n"));
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
case "/btw": {
|
|
976
|
+
if (!arg) { addOutput(theme.tertiary("\n Usage: /btw <question>\n Ask a side question without interrupting the current task.\n")); return true; }
|
|
977
|
+
// Run the question in a sub-agent so it doesn't pollute main history
|
|
978
|
+
const { runSubAgent } = await import("./agent/sub-agent.ts");
|
|
979
|
+
addOutput(theme.accent(`\n 💬 Side question: ${arg}\n`));
|
|
980
|
+
isProcessing = true; spinnerText = "Thinking (side)"; update();
|
|
981
|
+
try {
|
|
982
|
+
const result = await runSubAgent({
|
|
983
|
+
name: "btw",
|
|
984
|
+
prompt: arg,
|
|
985
|
+
systemPrompt: state.baseSystemPrompt + "\n\nThis is a brief side question. Answer concisely (1-3 sentences). Do not modify any files.",
|
|
986
|
+
router: state.router,
|
|
987
|
+
toolRegistry: state.registry,
|
|
988
|
+
toolContext: state.toolContext,
|
|
989
|
+
readOnly: true,
|
|
990
|
+
maxIterations: 5,
|
|
991
|
+
});
|
|
992
|
+
addOutput(result.text + "\n");
|
|
993
|
+
} catch (err) {
|
|
994
|
+
addOutput(theme.error(` Error: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
995
|
+
}
|
|
996
|
+
isProcessing = false; update();
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
case "/voice": {
|
|
1001
|
+
if (!feature("VOICE_MODE")) {
|
|
1002
|
+
addOutput(theme.tertiary("\n Voice mode disabled. Set AC_FEATURE_VOICE_MODE=true\n"));
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
const check = await checkVoiceAvailability();
|
|
1006
|
+
if (!check.available) {
|
|
1007
|
+
addOutput(theme.error(`\n ${check.details}\n`));
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (isRecording()) {
|
|
1012
|
+
addOutput(theme.accent(" Transcribing...\n"));
|
|
1013
|
+
const voiceConfig: VoiceConfig = {
|
|
1014
|
+
sttProvider: process.env.OPENAI_API_KEY ? "whisper-api" : "whisper-local",
|
|
1015
|
+
whisperApiKey: process.env.OPENAI_API_KEY,
|
|
1016
|
+
};
|
|
1017
|
+
try {
|
|
1018
|
+
const text = await transcribeRecording(voiceConfig);
|
|
1019
|
+
if (text) {
|
|
1020
|
+
addOutput(theme.success(` Voice: "${text}"\n`));
|
|
1021
|
+
await runTurnInk(text);
|
|
1022
|
+
} else {
|
|
1023
|
+
addOutput(theme.error(" Failed to transcribe\n"));
|
|
1024
|
+
}
|
|
1025
|
+
} catch (e: any) {
|
|
1026
|
+
addOutput(theme.error(` Transcription error: ${e.message}\n`));
|
|
1027
|
+
}
|
|
1028
|
+
} else {
|
|
1029
|
+
await startRecording();
|
|
1030
|
+
addOutput(theme.accent(" Recording... /voice again to stop and transcribe\n"));
|
|
1031
|
+
}
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
case "/compact": {
|
|
1036
|
+
addOutput(theme.tertiary(" [compacting context...]"));
|
|
1037
|
+
state.history = contextCollapse(state.history);
|
|
1038
|
+
state.history = snipCompact(state.history);
|
|
1039
|
+
state.history = await autoCompact(state.history, state.router);
|
|
1040
|
+
const summary = state.history.slice(-5).map(m => {
|
|
1041
|
+
const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
1042
|
+
return `${m.role}: ${c.slice(0, 150)}`;
|
|
1043
|
+
}).join("\n");
|
|
1044
|
+
await state.session.insertCompactBoundary(summary, state.history.length).catch(() => {});
|
|
1045
|
+
addOutput(theme.success(`\n ✓ Compacted to ${state.history.length} messages\n`));
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
case "/status": {
|
|
1049
|
+
const ctxLimit = getProviderContextLimit(state.router.currentProvider.name);
|
|
1050
|
+
const ctxUsed = estimateTokens(state.history);
|
|
1051
|
+
addOutput(`\n Provider: ${state.router.currentProvider.name}:${state.router.currentProvider.config.model}`);
|
|
1052
|
+
addOutput(` Context: ${ctxUsed}/${ctxLimit} tokens (${Math.round(ctxUsed/ctxLimit*100)}%)`);
|
|
1053
|
+
addOutput(` Session: ${state.session.id}`);
|
|
1054
|
+
addOutput(` History: ${state.history.length} messages\n`);
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
case "/sessions": {
|
|
1058
|
+
const { listSessions } = await import("./persistence/session.ts");
|
|
1059
|
+
const sessions = await listSessions(10);
|
|
1060
|
+
if (sessions.length === 0) { addOutput(theme.tertiary("\n No sessions found.\n")); return true; }
|
|
1061
|
+
for (const s of sessions) {
|
|
1062
|
+
addOutput(` ${s.id} — ${s.title ?? "(untitled)"} — ${new Date(s.updatedAt).toLocaleDateString()} — ${s.messageCount} msgs`);
|
|
1063
|
+
}
|
|
1064
|
+
addOutput("");
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
case "/memory": {
|
|
1068
|
+
const { loadMemories } = await import("./persistence/memory.ts");
|
|
1069
|
+
const memories = await loadMemories(state.toolContext.cwd);
|
|
1070
|
+
if (memories.length === 0) { addOutput(theme.tertiary("\n No memory files.\n")); return true; }
|
|
1071
|
+
for (const m of memories) { addOutput(` [${m.type}] ${m.name} — ${m.description ?? m.filePath}`); }
|
|
1072
|
+
addOutput("");
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
case "/diff": {
|
|
1076
|
+
const proc = Bun.spawn(["git", "diff", "--stat"], { cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe" });
|
|
1077
|
+
const output = (await new Response(proc.stdout).text()).trim();
|
|
1078
|
+
await proc.exited;
|
|
1079
|
+
addOutput(output ? `\n${output}\n` : theme.tertiary("\n No changes.\n"));
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
case "/git": {
|
|
1083
|
+
const proc = Bun.spawn(["git", "log", "--oneline", "-10"], { cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe" });
|
|
1084
|
+
const output = (await new Response(proc.stdout).text()).trim();
|
|
1085
|
+
await proc.exited;
|
|
1086
|
+
addOutput(output ? `\n${output}\n` : theme.tertiary("\n Not a git repo.\n"));
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
case "/plan": {
|
|
1090
|
+
const { cycleMode: cm } = await import("./ui/mode.ts");
|
|
1091
|
+
cm();
|
|
1092
|
+
update();
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
case "/restore": {
|
|
1096
|
+
const fh = getFileHistory();
|
|
1097
|
+
if (!fh || fh.undoCount === 0) { addOutput(theme.tertiary("\n Nothing to restore.\n")); return true; }
|
|
1098
|
+
addOutput(`\n ${fh.undoCount} snapshots available. Use /undo to restore.\n`);
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
case "/sync": {
|
|
1103
|
+
const [sub, ...syncRest] = (arg ?? "").split(" ");
|
|
1104
|
+
if (sub === "export") {
|
|
1105
|
+
const dir = syncRest[0] ?? join(state.toolContext.cwd, ".ashlrcode-sync");
|
|
1106
|
+
const manifest = await exportSettings(dir);
|
|
1107
|
+
addOutput(theme.success(`\n ✓ Exported ${manifest.files.length} files to ${dir}\n`));
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
if (sub === "import") {
|
|
1111
|
+
const dir = syncRest[0];
|
|
1112
|
+
if (!dir) {
|
|
1113
|
+
addOutput(theme.tertiary("\n Usage: /sync import <path> [--overwrite] [--merge]\n"));
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
const overwrite = syncRest.includes("--overwrite");
|
|
1117
|
+
const merge = syncRest.includes("--merge");
|
|
1118
|
+
const result = await importSettings(dir, { overwrite, merge });
|
|
1119
|
+
addOutput(theme.success(`\n ✓ Imported: ${result.imported.length}, Skipped: ${result.skipped.length}\n`));
|
|
1120
|
+
if (result.imported.length > 0) addOutput(theme.secondary(` ${result.imported.join(", ")}`));
|
|
1121
|
+
if (result.skipped.length > 0) addOutput(theme.tertiary(` Skipped: ${result.skipped.join(", ")}`));
|
|
1122
|
+
addOutput("");
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
// Default: show sync status
|
|
1126
|
+
const status = await getSyncStatus();
|
|
1127
|
+
addOutput(`\n Syncable files:\n${status.files.map((f) => ` ${f}`).join("\n")}\n`);
|
|
1128
|
+
addOutput(theme.tertiary(" /sync export [path] — export settings\n /sync import <path> — import settings\n"));
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
case "/bridge": {
|
|
1133
|
+
const port = getBridgePort();
|
|
1134
|
+
if (port) {
|
|
1135
|
+
addOutput(`\n Bridge active on http://localhost:${port}\n`);
|
|
1136
|
+
} else {
|
|
1137
|
+
addOutput(theme.tertiary("\n Bridge not running. Set AC_BRIDGE_PORT=8743 to enable.\n"));
|
|
1138
|
+
}
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
default:
|
|
1143
|
+
if (cmd?.startsWith("/")) {
|
|
1144
|
+
addOutput(theme.tertiary(`\n Unknown command: ${cmd}\n`));
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function runTurnInk(input: string, displayText?: string) {
|
|
1152
|
+
const turnStartTime = Date.now();
|
|
1153
|
+
isProcessing = true;
|
|
1154
|
+
spinnerText = "Thinking";
|
|
1155
|
+
update();
|
|
1156
|
+
|
|
1157
|
+
// Echo — use displayText for smart paste collapse
|
|
1158
|
+
const echo = displayText ?? input;
|
|
1159
|
+
addOutput("\n" + theme.accent(" ❯ ") + theme.primary(echo.length > 200 ? echo.slice(0, 197) + "..." : echo) + "\n");
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
const effortConfig = getEffortConfig();
|
|
1163
|
+
const { getUndercoverPrompt: getUcPrompt } = await import("./config/undercover.ts");
|
|
1164
|
+
const { getModelPatches: getMPatches } = await import("./agent/model-patches.ts");
|
|
1165
|
+
const turnModelPatches = getMPatches(state.router.currentProvider.config.model).combinedSuffix;
|
|
1166
|
+
const systemPrompt = state.baseSystemPrompt + getPlanModePrompt() + effortConfig.systemPromptSuffix + turnModelPatches + getUcPrompt();
|
|
1167
|
+
const systemTokens = Math.ceil(systemPrompt.length / 4);
|
|
1168
|
+
const contextLimit = getProviderContextLimit(state.router.currentProvider.name);
|
|
1169
|
+
|
|
1170
|
+
if (needsCompaction(state.history, systemTokens, { maxContextTokens: contextLimit })) {
|
|
1171
|
+
addOutput(theme.tertiary(" [compacting context...]"));
|
|
1172
|
+
state.history = contextCollapse(state.history);
|
|
1173
|
+
state.history = snipCompact(state.history);
|
|
1174
|
+
state.history = await autoCompact(state.history, state.router);
|
|
1175
|
+
|
|
1176
|
+
// Persist compact boundary to session log
|
|
1177
|
+
const summary = state.history.slice(-5).map(m => {
|
|
1178
|
+
const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
1179
|
+
return `${m.role}: ${c.slice(0, 150)}`;
|
|
1180
|
+
}).join("\n");
|
|
1181
|
+
await state.session.insertCompactBoundary(summary, state.history.length).catch(() => {});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
resetMarkdown();
|
|
1185
|
+
const preTurnMessageCount = state.history.length;
|
|
1186
|
+
turnToolCount = 0;
|
|
1187
|
+
|
|
1188
|
+
// Update turn number on context so file snapshots track which turn modified them
|
|
1189
|
+
state.toolContext.turnNumber = turnCount;
|
|
1190
|
+
|
|
1191
|
+
let responseText = "";
|
|
1192
|
+
|
|
1193
|
+
// Wrap agent loop in AsyncLocalStorage root context for sub-agent isolation
|
|
1194
|
+
const rootCtx: AgentContext = {
|
|
1195
|
+
agentId: `root-${state.session.id}-${turnCount}`,
|
|
1196
|
+
agentName: "main",
|
|
1197
|
+
cwd: state.toolContext.cwd,
|
|
1198
|
+
readOnly: isPlanMode(),
|
|
1199
|
+
depth: 0,
|
|
1200
|
+
startedAt: new Date().toISOString(),
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
const result = await runWithAgentContext(rootCtx, () => runAgentLoop(input, state.history, {
|
|
1204
|
+
systemPrompt,
|
|
1205
|
+
maxIterations: effortConfig.maxIterations,
|
|
1206
|
+
router: state.router,
|
|
1207
|
+
toolRegistry: state.registry,
|
|
1208
|
+
toolContext: state.toolContext,
|
|
1209
|
+
readOnly: isPlanMode(),
|
|
1210
|
+
onText: (text) => {
|
|
1211
|
+
isProcessing = false;
|
|
1212
|
+
responseText += text;
|
|
1213
|
+
// Only flush COMPLETE lines to Static (they can't be re-rendered)
|
|
1214
|
+
const lines = responseText.split("\n");
|
|
1215
|
+
if (lines.length > 1) {
|
|
1216
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
1217
|
+
addOutput(lines[i]!);
|
|
1218
|
+
}
|
|
1219
|
+
responseText = lines[lines.length - 1]!;
|
|
1220
|
+
}
|
|
1221
|
+
// Partial line stays in spinnerText for live display (re-renderable)
|
|
1222
|
+
spinnerText = responseText;
|
|
1223
|
+
update();
|
|
1224
|
+
},
|
|
1225
|
+
onToolStart: (name, toolInput) => {
|
|
1226
|
+
isProcessing = true;
|
|
1227
|
+
spinnerText = name;
|
|
1228
|
+
toolStartTime = Date.now();
|
|
1229
|
+
currentToolInput = toolInput as Record<string, unknown>;
|
|
1230
|
+
recordThinking(state.buddy);
|
|
1231
|
+
logEvent("tool_start", { tool: name }).catch(() => {});
|
|
1232
|
+
if (isFirstToolCall()) {
|
|
1233
|
+
addOutput(getBuddyReaction(state.buddy, "first_tool"));
|
|
1234
|
+
}
|
|
1235
|
+
update();
|
|
1236
|
+
},
|
|
1237
|
+
onToolEnd: (_name, result, isError) => {
|
|
1238
|
+
isProcessing = false;
|
|
1239
|
+
turnToolCount++;
|
|
1240
|
+
const durationMs = toolStartTime > 0 ? Date.now() - toolStartTime : undefined;
|
|
1241
|
+
lastToolName = _name;
|
|
1242
|
+
lastToolResult = result.slice(0, 50);
|
|
1243
|
+
logEvent(isError ? "tool_error" : "tool_end", { tool: _name }).catch(() => {});
|
|
1244
|
+
if (isError) { recordError(state.buddy); lastHadError = true; }
|
|
1245
|
+
else recordToolCallSuccess(state.buddy);
|
|
1246
|
+
|
|
1247
|
+
// Use message renderer for formatted output
|
|
1248
|
+
const rendered = formatToolExecution(_name, currentToolInput, result, isError, durationMs);
|
|
1249
|
+
for (const line of rendered) addOutput(line);
|
|
1250
|
+
|
|
1251
|
+
if (isError) addOutput(getBuddyReaction(state.buddy, "error"));
|
|
1252
|
+
toolStartTime = 0;
|
|
1253
|
+
currentToolInput = {};
|
|
1254
|
+
update();
|
|
1255
|
+
},
|
|
1256
|
+
}));
|
|
1257
|
+
|
|
1258
|
+
// Flush remaining text
|
|
1259
|
+
if (responseText) addOutput(responseText);
|
|
1260
|
+
|
|
1261
|
+
// Update history
|
|
1262
|
+
state.history.length = 0;
|
|
1263
|
+
state.history.push(...result.messages);
|
|
1264
|
+
|
|
1265
|
+
// Persist
|
|
1266
|
+
const newMessages = result.messages.slice(preTurnMessageCount);
|
|
1267
|
+
if (newMessages.length > 0) {
|
|
1268
|
+
await state.session.appendMessages(newMessages);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Turn separator
|
|
1272
|
+
turnCount++;
|
|
1273
|
+
logEvent("turn_end", { cost: state.router.costs.totalCostUSD, turn: turnCount }).catch(() => {});
|
|
1274
|
+
|
|
1275
|
+
// Auto-generate session title from first user message
|
|
1276
|
+
if (turnCount === 1 && input.length > 0) {
|
|
1277
|
+
const title = input.slice(0, 60).replace(/\n/g, " ").trim();
|
|
1278
|
+
state.session.setTitle(title).catch(() => {});
|
|
1279
|
+
}
|
|
1280
|
+
cachedQuip = getQuip(state.buddy.mood); // Update quip once per turn, not per render
|
|
1281
|
+
currentQuipType = "quip";
|
|
1282
|
+
const tc = state.history.filter(m => m.role === "user" && typeof m.content === "string").length;
|
|
1283
|
+
addOutput(formatTurnSeparator(tc, state.router.costs.totalCostUSD, state.buddy.name, turnToolCount));
|
|
1284
|
+
|
|
1285
|
+
// Desktop notification when terminal is not focused
|
|
1286
|
+
detectTerminalFocus().then(focus => {
|
|
1287
|
+
if (focus === "unfocused") {
|
|
1288
|
+
notifyTurnComplete(turnToolCount, Date.now() - turnStartTime).catch(() => {});
|
|
1289
|
+
}
|
|
1290
|
+
}).catch(() => {});
|
|
1291
|
+
|
|
1292
|
+
// Speech bubble — render buddy + bubble as Static output so it scrolls up with history
|
|
1293
|
+
const bubbleLines = renderBuddyWithBubble(cachedQuip, getBuddyArt(state.buddy), state.buddy.name);
|
|
1294
|
+
addOutput(theme.accentDim(bubbleLines.join("\n")));
|
|
1295
|
+
|
|
1296
|
+
// AI-powered buddy comment (every 5th turn, fire-and-forget)
|
|
1297
|
+
if (shouldUseAI(turnCount, lastHadError) && !aiCommentInFlight) {
|
|
1298
|
+
const gen = ++aiCommentGen;
|
|
1299
|
+
aiCommentInFlight = true;
|
|
1300
|
+
generateBuddyComment(
|
|
1301
|
+
{ lastTool: lastToolName, lastResult: lastToolResult, mood: state.buddy.mood, errorOccurred: lastHadError },
|
|
1302
|
+
state.router.currentProvider.config.apiKey,
|
|
1303
|
+
state.router.currentProvider.config.baseURL
|
|
1304
|
+
).then((comment) => {
|
|
1305
|
+
if (gen !== aiCommentGen) return; // Stale — a newer turn started
|
|
1306
|
+
currentQuipType = comment.type;
|
|
1307
|
+
cachedQuip = comment.text;
|
|
1308
|
+
const pool = QUIPS[state.buddy.mood] ?? [];
|
|
1309
|
+
if (!pool.includes(comment.text)) {
|
|
1310
|
+
QUIPS[state.buddy.mood] = [...pool, comment.text];
|
|
1311
|
+
}
|
|
1312
|
+
update();
|
|
1313
|
+
}).catch(() => {}).finally(() => { aiCommentInFlight = false; });
|
|
1314
|
+
}
|
|
1315
|
+
lastHadError = false;
|
|
1316
|
+
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1319
|
+
const categorized = categorizeError(error);
|
|
1320
|
+
addOutput(theme.error(`\n Error: ${categorized.message}\n`));
|
|
1321
|
+
logEvent("error", { message: categorized.message }).catch(() => {});
|
|
1322
|
+
notifyError(categorized.message).catch(() => {});
|
|
1323
|
+
lastHadError = true;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
isProcessing = false;
|
|
1327
|
+
update();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Wire deferred trigger callback now that runTurnInk is defined
|
|
1331
|
+
_triggerCallback = runTurnInk;
|
|
1332
|
+
|
|
1333
|
+
async function handleExit() {
|
|
1334
|
+
idleDetector.stop();
|
|
1335
|
+
triggerRunner.stop();
|
|
1336
|
+
stopRemotePolling();
|
|
1337
|
+
stopBridgeServer();
|
|
1338
|
+
stopBuddyAnimation();
|
|
1339
|
+
if (kairos?.isRunning()) await kairos.stop().catch(() => {});
|
|
1340
|
+
const { stopRecording: stopRec, isRecording: isRec } = await import("./voice/voice-mode.ts");
|
|
1341
|
+
if (isRec()) await stopRec().catch(() => {});
|
|
1342
|
+
state.buddy.mood = "sleepy";
|
|
1343
|
+
// Generate final dream on exit
|
|
1344
|
+
if (state.history.length > 4) {
|
|
1345
|
+
await generateDream(state.history, state.session.id).catch(() => {});
|
|
1346
|
+
}
|
|
1347
|
+
await saveBuddy(state.buddy).catch(() => {});
|
|
1348
|
+
await stopIPCServer().catch(() => {});
|
|
1349
|
+
await shutdownLSP().catch(() => {});
|
|
1350
|
+
await shutdownBrowser().catch(() => {});
|
|
1351
|
+
console.log("\n" + state.router.getCostSummary());
|
|
1352
|
+
process.exit(0);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Keybinding action callbacks
|
|
1356
|
+
const handleModeSwitch = () => { cycleMode(); update(); };
|
|
1357
|
+
const handleUndo = () => { handleCommand("/undo").catch(() => {}); };
|
|
1358
|
+
const handleEffortCycle = () => { cycleEffort(); update(); };
|
|
1359
|
+
const handleCompact = () => { handleCommand("/compact").catch(() => {}); };
|
|
1360
|
+
const handleClearScreen = () => { items = []; update(); };
|
|
1361
|
+
const handleVoiceToggle = () => { handleCommand("/voice").catch(() => {}); };
|
|
1362
|
+
|
|
1363
|
+
function appProps() {
|
|
1364
|
+
return {
|
|
1365
|
+
onSubmit: handleSubmit,
|
|
1366
|
+
onExit: handleExit,
|
|
1367
|
+
onModeSwitch: handleModeSwitch,
|
|
1368
|
+
onUndo: handleUndo,
|
|
1369
|
+
onEffortCycle: handleEffortCycle,
|
|
1370
|
+
onCompact: handleCompact,
|
|
1371
|
+
onClearScreen: handleClearScreen,
|
|
1372
|
+
onVoiceToggle: handleVoiceToggle,
|
|
1373
|
+
inputHistory,
|
|
1374
|
+
...getDisplayProps(),
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Initial render
|
|
1379
|
+
const { rerender } = render(<App {...appProps()} />);
|
|
1380
|
+
|
|
1381
|
+
function update() {
|
|
1382
|
+
rerender(<App {...appProps()} />);
|
|
1383
|
+
}
|
|
1384
|
+
}
|