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/setup.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run setup wizard — interactive configuration for new users.
|
|
3
|
+
*
|
|
4
|
+
* Runs when no API key is configured. Walks through:
|
|
5
|
+
* 1. API key entry (xAI or Anthropic)
|
|
6
|
+
* 2. Model selection
|
|
7
|
+
* 3. Saves to ~/.ashlrcode/settings.json
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { createInterface } from "readline";
|
|
12
|
+
import { saveSettings, type Settings } from "./config/settings.ts";
|
|
13
|
+
import type { ProviderRouterConfig } from "./providers/types.ts";
|
|
14
|
+
|
|
15
|
+
export async function runSetupWizard(): Promise<Settings> {
|
|
16
|
+
console.log("");
|
|
17
|
+
console.log(chalk.bold.cyan(" Welcome to AshlrCode"));
|
|
18
|
+
console.log(chalk.dim(" Multi-provider AI coding agent CLI\n"));
|
|
19
|
+
console.log(chalk.dim(" Let's get you set up. This takes about 30 seconds.\n"));
|
|
20
|
+
|
|
21
|
+
// Step 1: Choose provider
|
|
22
|
+
console.log(chalk.bold(" Step 1: Choose your AI provider\n"));
|
|
23
|
+
console.log(chalk.dim(" 1. ") + chalk.bold("xAI Grok") + chalk.dim(" — $0.20/$0.50 per M tokens, 2M context (recommended)"));
|
|
24
|
+
console.log(chalk.dim(" 2. ") + chalk.bold("Anthropic Claude") + chalk.dim(" — $3/$15 per M tokens, 200K context"));
|
|
25
|
+
console.log(chalk.dim(" 3. ") + chalk.bold("Both") + chalk.dim(" — xAI primary, Claude fallback\n"));
|
|
26
|
+
|
|
27
|
+
const providerChoice = await prompt(chalk.cyan(" Provider [1/2/3]: "));
|
|
28
|
+
const choice = providerChoice.trim() || "1";
|
|
29
|
+
|
|
30
|
+
// Step 2: API key(s)
|
|
31
|
+
let xaiKey = "";
|
|
32
|
+
let anthropicKey = "";
|
|
33
|
+
|
|
34
|
+
if (choice === "1" || choice === "3") {
|
|
35
|
+
console.log(chalk.dim("\n Get an xAI API key at: https://console.x.ai/\n"));
|
|
36
|
+
xaiKey = await prompt(chalk.cyan(" xAI API key: "));
|
|
37
|
+
xaiKey = xaiKey.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (choice === "2" || choice === "3") {
|
|
41
|
+
console.log(chalk.dim("\n Get a Claude API key at: https://console.anthropic.com/\n"));
|
|
42
|
+
anthropicKey = await prompt(chalk.cyan(" Anthropic API key: "));
|
|
43
|
+
anthropicKey = anthropicKey.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!xaiKey && !anthropicKey) {
|
|
47
|
+
console.error(chalk.red("\n At least one API key is required."));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Step 3: Model selection
|
|
52
|
+
let model: string;
|
|
53
|
+
if (xaiKey) {
|
|
54
|
+
console.log(chalk.dim("\n Step 2: Choose model\n"));
|
|
55
|
+
console.log(chalk.dim(" 1. ") + chalk.bold("grok-4-1-fast-reasoning") + chalk.dim(" — best value, tool-calling optimized (recommended)"));
|
|
56
|
+
console.log(chalk.dim(" 2. ") + chalk.bold("grok-4-0314") + chalk.dim(" — highest quality, higher cost"));
|
|
57
|
+
console.log(chalk.dim(" 3. ") + chalk.bold("grok-3-fast") + chalk.dim(" — older, cheapest\n"));
|
|
58
|
+
const modelChoice = await prompt(chalk.cyan(" Model [1/2/3]: "));
|
|
59
|
+
const mc = modelChoice.trim() || "1";
|
|
60
|
+
model = mc === "2" ? "grok-4-0314" : mc === "3" ? "grok-3-fast" : "grok-4-1-fast-reasoning";
|
|
61
|
+
} else {
|
|
62
|
+
model = "claude-sonnet-4-6-20250514";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build settings
|
|
66
|
+
const providers: ProviderRouterConfig = xaiKey
|
|
67
|
+
? {
|
|
68
|
+
primary: {
|
|
69
|
+
provider: "xai",
|
|
70
|
+
apiKey: xaiKey,
|
|
71
|
+
model,
|
|
72
|
+
baseURL: "https://api.x.ai/v1",
|
|
73
|
+
},
|
|
74
|
+
fallbacks: anthropicKey
|
|
75
|
+
? [{ provider: "anthropic", apiKey: anthropicKey, model: "claude-sonnet-4-6-20250514" }]
|
|
76
|
+
: [],
|
|
77
|
+
}
|
|
78
|
+
: {
|
|
79
|
+
primary: {
|
|
80
|
+
provider: "anthropic",
|
|
81
|
+
apiKey: anthropicKey,
|
|
82
|
+
model,
|
|
83
|
+
},
|
|
84
|
+
fallbacks: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const settings: Settings = { providers, maxTokens: 8192 };
|
|
88
|
+
|
|
89
|
+
// Save
|
|
90
|
+
await saveSettings(settings);
|
|
91
|
+
|
|
92
|
+
console.log(chalk.green("\n Setup complete!"));
|
|
93
|
+
console.log(chalk.dim(` Provider: ${providers.primary.provider}`));
|
|
94
|
+
console.log(chalk.dim(` Model: ${model}`));
|
|
95
|
+
console.log(chalk.dim(` Config saved to: ~/.ashlrcode/settings.json`));
|
|
96
|
+
console.log(chalk.dim(`\n Run ${chalk.bold("ac")} to start coding.\n`));
|
|
97
|
+
|
|
98
|
+
return settings;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if setup is needed (no API key from env or settings).
|
|
103
|
+
*/
|
|
104
|
+
export function needsSetup(settings: { providers: ProviderRouterConfig }): boolean {
|
|
105
|
+
return !settings.providers.primary.apiKey;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function prompt(question: string): Promise<string> {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const rl = createInterface({
|
|
111
|
+
input: process.stdin,
|
|
112
|
+
output: process.stdout,
|
|
113
|
+
});
|
|
114
|
+
rl.question(question, (answer) => {
|
|
115
|
+
rl.close();
|
|
116
|
+
resolve(answer);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill loader — reads skill definitions from .md files.
|
|
3
|
+
*
|
|
4
|
+
* Loads from:
|
|
5
|
+
* 1. Built-in skills: prompts/skills/*.md (shipped with AshlrCode)
|
|
6
|
+
* 2. User skills: ~/.ashlrcode/skills/*.md
|
|
7
|
+
* 3. Project skills: .ashlrcode/skills/*.md (per-project)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { readFile, readdir } from "fs/promises";
|
|
12
|
+
import { join, resolve } from "path";
|
|
13
|
+
import { getConfigDir } from "../config/settings.ts";
|
|
14
|
+
import type { SkillDefinition } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const BUILT_IN_DIR = resolve(import.meta.dir, "../../prompts/skills");
|
|
17
|
+
|
|
18
|
+
export async function loadSkills(cwd: string): Promise<SkillDefinition[]> {
|
|
19
|
+
const skills: SkillDefinition[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
|
|
22
|
+
// Load from all sources (project overrides user overrides built-in)
|
|
23
|
+
const dirs = [
|
|
24
|
+
BUILT_IN_DIR,
|
|
25
|
+
join(getConfigDir(), "skills"),
|
|
26
|
+
join(cwd, ".ashlrcode", "skills"),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const dir of dirs) {
|
|
30
|
+
if (!existsSync(dir)) continue;
|
|
31
|
+
|
|
32
|
+
const files = await readdir(dir);
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
if (!file.endsWith(".md")) continue;
|
|
35
|
+
|
|
36
|
+
const content = await readFile(join(dir, file), "utf-8");
|
|
37
|
+
const skill = parseSkillFile(content);
|
|
38
|
+
if (skill) {
|
|
39
|
+
// Later sources override earlier ones
|
|
40
|
+
if (seen.has(skill.name)) {
|
|
41
|
+
const idx = skills.findIndex((s) => s.name === skill.name);
|
|
42
|
+
if (idx >= 0) skills[idx] = skill;
|
|
43
|
+
} else {
|
|
44
|
+
seen.add(skill.name);
|
|
45
|
+
skills.push(skill);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return skills;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSkillFile(content: string): SkillDefinition | null {
|
|
55
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
|
|
58
|
+
const frontmatter = match[1]!;
|
|
59
|
+
const prompt = match[2]!.trim();
|
|
60
|
+
|
|
61
|
+
const name = extractField(frontmatter, "name");
|
|
62
|
+
const description = extractField(frontmatter, "description");
|
|
63
|
+
const trigger = extractField(frontmatter, "trigger");
|
|
64
|
+
|
|
65
|
+
if (!name || !trigger) return null;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name,
|
|
69
|
+
description: description ?? name,
|
|
70
|
+
trigger,
|
|
71
|
+
prompt,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractField(frontmatter: string, field: string): string | null {
|
|
76
|
+
const match = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
|
|
77
|
+
return match?.[1]?.trim() ?? null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill registry — lookup and expansion of slash commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SkillDefinition } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export class SkillRegistry {
|
|
8
|
+
private skills = new Map<string, SkillDefinition>();
|
|
9
|
+
|
|
10
|
+
register(skill: SkillDefinition): void {
|
|
11
|
+
this.skills.set(skill.trigger, skill);
|
|
12
|
+
// Also register by name for convenience
|
|
13
|
+
if (!skill.trigger.startsWith("/")) {
|
|
14
|
+
this.skills.set(`/${skill.name}`, skill);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
registerAll(skills: SkillDefinition[]): void {
|
|
19
|
+
for (const skill of skills) {
|
|
20
|
+
this.register(skill);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Look up a skill by trigger (e.g. "/commit").
|
|
26
|
+
*/
|
|
27
|
+
get(trigger: string): SkillDefinition | undefined {
|
|
28
|
+
return this.skills.get(trigger);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if a string is a skill trigger.
|
|
33
|
+
*/
|
|
34
|
+
isSkill(input: string): boolean {
|
|
35
|
+
const trigger = input.split(" ")[0]!;
|
|
36
|
+
return this.skills.has(trigger);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Expand a skill invocation into its full prompt.
|
|
41
|
+
* Supports {{args}} template variable.
|
|
42
|
+
*/
|
|
43
|
+
expand(input: string): string | null {
|
|
44
|
+
const parts = input.split(" ");
|
|
45
|
+
const trigger = parts[0]!;
|
|
46
|
+
const args = parts.slice(1).join(" ").trim();
|
|
47
|
+
|
|
48
|
+
const skill = this.skills.get(trigger);
|
|
49
|
+
if (!skill) return null;
|
|
50
|
+
|
|
51
|
+
let prompt = skill.prompt;
|
|
52
|
+
if (args) {
|
|
53
|
+
prompt = prompt.replace(/\{\{args\}\}/g, args);
|
|
54
|
+
// Also append args if no template variable
|
|
55
|
+
if (!skill.prompt.includes("{{args}}")) {
|
|
56
|
+
prompt += `\n\nAdditional context: ${args}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return prompt;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* List all registered skills.
|
|
65
|
+
*/
|
|
66
|
+
getAll(): SkillDefinition[] {
|
|
67
|
+
// Deduplicate (same skill registered under trigger and /name)
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const skills: SkillDefinition[] = [];
|
|
70
|
+
for (const [, skill] of this.skills) {
|
|
71
|
+
if (!seen.has(skill.name)) {
|
|
72
|
+
seen.add(skill.name);
|
|
73
|
+
skills.push(skill);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return skills;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill types — slash commands that expand into full prompts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface SkillDefinition {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
trigger: string; // e.g. "/commit"
|
|
9
|
+
prompt: string; // The full prompt template
|
|
10
|
+
args?: string; // Optional args passed by user
|
|
11
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File snapshot/undo system — saves file state before edits.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Per-file snapshots before every Write/Edit operation
|
|
6
|
+
* - Multi-file undo (undo all changes from a turn)
|
|
7
|
+
* - Disk persistence at ~/.ashlrcode/file-history/<session-id>/
|
|
8
|
+
* - Handles newly-created files (undo = delete)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile, mkdir, readdir, unlink } from "fs/promises";
|
|
12
|
+
import { existsSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { getConfigDir } from "../config/settings.ts";
|
|
15
|
+
|
|
16
|
+
export interface FileSnapshot {
|
|
17
|
+
id: string;
|
|
18
|
+
filePath: string;
|
|
19
|
+
content: string; // Original content before modification ("" means file didn't exist)
|
|
20
|
+
timestamp: string;
|
|
21
|
+
tool: string; // Which tool made the change (Write, Edit, Bash)
|
|
22
|
+
turnNumber: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class FileHistoryStore {
|
|
26
|
+
private snapshots: FileSnapshot[] = [];
|
|
27
|
+
private sessionId: string;
|
|
28
|
+
private persistDir: string;
|
|
29
|
+
|
|
30
|
+
constructor(sessionId: string) {
|
|
31
|
+
this.sessionId = sessionId;
|
|
32
|
+
this.persistDir = join(getConfigDir(), "file-history", sessionId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Capture a snapshot before a file is modified.
|
|
37
|
+
* If the file doesn't exist yet, records an empty snapshot so undo can delete it.
|
|
38
|
+
*/
|
|
39
|
+
async capture(filePath: string, tool: string, turnNumber: number): Promise<void> {
|
|
40
|
+
const baseSnapshot = {
|
|
41
|
+
id: `snap-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
42
|
+
filePath,
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
tool,
|
|
45
|
+
turnNumber,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let content: string;
|
|
49
|
+
try {
|
|
50
|
+
content = await readFile(filePath, "utf-8");
|
|
51
|
+
} catch (err: any) {
|
|
52
|
+
if (err?.code === "ENOENT") {
|
|
53
|
+
// File doesn't exist (or was deleted between check and read) — undo means delete
|
|
54
|
+
const snapshot: FileSnapshot = { ...baseSnapshot, content: "" };
|
|
55
|
+
this.snapshots.push(snapshot);
|
|
56
|
+
this.persistSnapshot(snapshot).catch(() => {});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const snapshot: FileSnapshot = { ...baseSnapshot, content };
|
|
63
|
+
this.snapshots.push(snapshot);
|
|
64
|
+
this.persistSnapshot(snapshot).catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Undo the last file modification.
|
|
69
|
+
* If the snapshot content is empty, the file was newly created — delete it.
|
|
70
|
+
*/
|
|
71
|
+
async undoLast(): Promise<{ filePath: string; restored: boolean } | null> {
|
|
72
|
+
const snapshot = this.snapshots.pop();
|
|
73
|
+
if (!snapshot) return null;
|
|
74
|
+
|
|
75
|
+
if (snapshot.content === "") {
|
|
76
|
+
// File was newly created — delete it
|
|
77
|
+
await unlink(snapshot.filePath).catch(() => {});
|
|
78
|
+
} else {
|
|
79
|
+
await writeFile(snapshot.filePath, snapshot.content, "utf-8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Remove persisted snapshot
|
|
83
|
+
this.removePersistedSnapshot(snapshot.id).catch(() => {});
|
|
84
|
+
|
|
85
|
+
return { filePath: snapshot.filePath, restored: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Undo all changes from a specific turn (in reverse order).
|
|
90
|
+
*/
|
|
91
|
+
async undoTurn(turnNumber: number): Promise<string[]> {
|
|
92
|
+
const turnSnapshots = this.snapshots.filter(s => s.turnNumber === turnNumber);
|
|
93
|
+
const restored: string[] = [];
|
|
94
|
+
|
|
95
|
+
// Restore in reverse order so multi-edit sequences unwind correctly
|
|
96
|
+
for (const snap of turnSnapshots.reverse()) {
|
|
97
|
+
if (snap.content === "") {
|
|
98
|
+
await unlink(snap.filePath).catch(() => {});
|
|
99
|
+
} else {
|
|
100
|
+
await writeFile(snap.filePath, snap.content, "utf-8");
|
|
101
|
+
}
|
|
102
|
+
restored.push(snap.filePath);
|
|
103
|
+
this.snapshots = this.snapshots.filter(s => s.id !== snap.id);
|
|
104
|
+
this.removePersistedSnapshot(snap.id).catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return restored;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Restore a specific file to its most recent snapshot.
|
|
112
|
+
*/
|
|
113
|
+
async restore(filePath: string): Promise<boolean> {
|
|
114
|
+
// Find the most recent snapshot for this file
|
|
115
|
+
let idx = -1;
|
|
116
|
+
for (let i = this.snapshots.length - 1; i >= 0; i--) {
|
|
117
|
+
if (this.snapshots[i]!.filePath === filePath) {
|
|
118
|
+
idx = i;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (idx === -1) return false;
|
|
123
|
+
|
|
124
|
+
const snapshot = this.snapshots[idx]!;
|
|
125
|
+
if (snapshot.content === "") {
|
|
126
|
+
await unlink(snapshot.filePath).catch(() => {});
|
|
127
|
+
} else {
|
|
128
|
+
await writeFile(snapshot.filePath, snapshot.content, "utf-8");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.snapshots.splice(idx, 1);
|
|
132
|
+
this.removePersistedSnapshot(snapshot.id).catch(() => {});
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get list of files with snapshots available.
|
|
138
|
+
*/
|
|
139
|
+
getSnapshotFiles(): Array<{ path: string; count: number; lastModified: string }> {
|
|
140
|
+
const byFile = new Map<string, FileSnapshot[]>();
|
|
141
|
+
for (const snap of this.snapshots) {
|
|
142
|
+
const arr = byFile.get(snap.filePath) ?? [];
|
|
143
|
+
arr.push(snap);
|
|
144
|
+
byFile.set(snap.filePath, arr);
|
|
145
|
+
}
|
|
146
|
+
const files: Array<{ path: string; count: number; lastModified: string }> = [];
|
|
147
|
+
for (const [path, snaps] of byFile) {
|
|
148
|
+
files.push({
|
|
149
|
+
path,
|
|
150
|
+
count: snaps.length,
|
|
151
|
+
lastModified: snaps[snaps.length - 1]!.timestamp,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get undo history (most recent first).
|
|
159
|
+
*/
|
|
160
|
+
getHistory(): FileSnapshot[] {
|
|
161
|
+
return [...this.snapshots].reverse();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if a file has snapshots.
|
|
166
|
+
*/
|
|
167
|
+
hasSnapshot(filePath: string): boolean {
|
|
168
|
+
return this.snapshots.some(s => s.filePath === filePath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get number of undoable operations.
|
|
173
|
+
*/
|
|
174
|
+
get undoCount(): number {
|
|
175
|
+
return this.snapshots.length;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Clear all snapshots.
|
|
180
|
+
*/
|
|
181
|
+
clear(): void {
|
|
182
|
+
this.snapshots = [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Persist a snapshot to disk. */
|
|
186
|
+
private async persistSnapshot(snap: FileSnapshot): Promise<void> {
|
|
187
|
+
await mkdir(this.persistDir, { recursive: true });
|
|
188
|
+
await writeFile(
|
|
189
|
+
join(this.persistDir, `${snap.id}.json`),
|
|
190
|
+
JSON.stringify(snap),
|
|
191
|
+
"utf-8"
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Remove a persisted snapshot from disk. */
|
|
196
|
+
private async removePersistedSnapshot(id: string): Promise<void> {
|
|
197
|
+
const filePath = join(this.persistDir, `${id}.json`);
|
|
198
|
+
if (existsSync(filePath)) {
|
|
199
|
+
await unlink(filePath).catch(() => {});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Load persisted snapshots from disk (for session resume). */
|
|
204
|
+
async loadFromDisk(): Promise<void> {
|
|
205
|
+
if (!existsSync(this.persistDir)) return;
|
|
206
|
+
const files = await readdir(this.persistDir);
|
|
207
|
+
for (const file of files.filter(f => f.endsWith(".json"))) {
|
|
208
|
+
try {
|
|
209
|
+
const raw = await readFile(join(this.persistDir, file), "utf-8");
|
|
210
|
+
this.snapshots.push(JSON.parse(raw) as FileSnapshot);
|
|
211
|
+
} catch {
|
|
212
|
+
// Ignore corrupt snapshot files
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
this.snapshots.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Clean up all persisted snapshots for this session. */
|
|
219
|
+
async cleanup(): Promise<void> {
|
|
220
|
+
if (!existsSync(this.persistDir)) return;
|
|
221
|
+
const files = await readdir(this.persistDir);
|
|
222
|
+
for (const file of files) {
|
|
223
|
+
await unlink(join(this.persistDir, file)).catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Module-level singleton accessor ─────────────────────────────
|
|
229
|
+
|
|
230
|
+
let _instance: FileHistoryStore | null = null;
|
|
231
|
+
|
|
232
|
+
export function setFileHistory(store: FileHistoryStore): void {
|
|
233
|
+
_instance = store;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function getFileHistory(): FileHistoryStore | null {
|
|
237
|
+
return _instance;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @deprecated Use getFileHistory() instead. Kept for backward compatibility.
|
|
242
|
+
*/
|
|
243
|
+
export const fileHistory = {
|
|
244
|
+
async snapshot(filePath: string): Promise<void> {
|
|
245
|
+
if (_instance) {
|
|
246
|
+
await _instance.capture(filePath, "unknown", 0);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
async restore(filePath: string): Promise<boolean> {
|
|
250
|
+
if (_instance) {
|
|
251
|
+
return _instance.restore(filePath);
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
},
|
|
255
|
+
getSnapshotFiles() {
|
|
256
|
+
return _instance?.getSnapshotFiles() ?? [];
|
|
257
|
+
},
|
|
258
|
+
hasSnapshot(filePath: string): boolean {
|
|
259
|
+
return _instance?.hasSnapshot(filePath) ?? false;
|
|
260
|
+
},
|
|
261
|
+
clear(): void {
|
|
262
|
+
_instance?.clear();
|
|
263
|
+
},
|
|
264
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local event telemetry — ring-buffer event log for debugging.
|
|
3
|
+
* Events stored to ~/.ashlrcode/telemetry/events.jsonl
|
|
4
|
+
* No external transmission — purely local diagnostics.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { appendFile, mkdir, readFile, stat, rename, unlink } from "fs/promises";
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { getConfigDir } from "../config/settings.ts";
|
|
11
|
+
|
|
12
|
+
export type EventType =
|
|
13
|
+
| "session_start" | "session_end"
|
|
14
|
+
| "turn_start" | "turn_end"
|
|
15
|
+
| "tool_start" | "tool_end" | "tool_error"
|
|
16
|
+
| "agent_spawn" | "agent_complete"
|
|
17
|
+
| "compact" | "dream"
|
|
18
|
+
| "error" | "retry" | "circuit_breaker"
|
|
19
|
+
| "permission_granted" | "permission_denied"
|
|
20
|
+
| "kairos_tick" | "kairos_start" | "kairos_stop";
|
|
21
|
+
|
|
22
|
+
interface TelemetryEvent {
|
|
23
|
+
type: EventType;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
data?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
|
30
|
+
const MAX_FILES = 5; // Keep 5 rotated files
|
|
31
|
+
|
|
32
|
+
function getTelemetryDir(): string {
|
|
33
|
+
return join(getConfigDir(), "telemetry");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getEventLogPath(): string {
|
|
37
|
+
return join(getTelemetryDir(), "events.jsonl");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let _sessionId: string | null = null;
|
|
41
|
+
|
|
42
|
+
export function initTelemetry(sessionId: string): void {
|
|
43
|
+
_sessionId = sessionId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function logEvent(type: EventType, data?: Record<string, unknown>): Promise<void> {
|
|
47
|
+
const event: TelemetryEvent = {
|
|
48
|
+
type,
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
sessionId: _sessionId ?? undefined,
|
|
51
|
+
data,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const dir = getTelemetryDir();
|
|
56
|
+
await mkdir(dir, { recursive: true });
|
|
57
|
+
const path = getEventLogPath();
|
|
58
|
+
await appendFile(path, JSON.stringify(event) + "\n", "utf-8");
|
|
59
|
+
|
|
60
|
+
// Rotate if too large
|
|
61
|
+
if (existsSync(path)) {
|
|
62
|
+
const s = await stat(path);
|
|
63
|
+
if (s.size > MAX_FILE_SIZE) {
|
|
64
|
+
await rotateLog(path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Never let telemetry crash the app
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function rotateLog(path: string): Promise<void> {
|
|
73
|
+
const dir = getTelemetryDir();
|
|
74
|
+
// Delete the oldest file before shifting to prevent unbounded growth
|
|
75
|
+
const oldestPath = join(dir, `events.${MAX_FILES}.jsonl`);
|
|
76
|
+
if (existsSync(oldestPath)) await unlink(oldestPath).catch(() => {});
|
|
77
|
+
// Shift existing rotated files
|
|
78
|
+
for (let i = MAX_FILES - 1; i >= 1; i--) {
|
|
79
|
+
const from = join(dir, `events.${i}.jsonl`);
|
|
80
|
+
const to = join(dir, `events.${i + 1}.jsonl`);
|
|
81
|
+
if (existsSync(from)) {
|
|
82
|
+
await rename(from, to).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Move current to .1
|
|
86
|
+
await rename(path, join(dir, "events.1.jsonl")).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read recent events for debugging.
|
|
91
|
+
*/
|
|
92
|
+
export async function readRecentEvents(count: number = 100): Promise<TelemetryEvent[]> {
|
|
93
|
+
const path = getEventLogPath();
|
|
94
|
+
if (!existsSync(path)) return [];
|
|
95
|
+
|
|
96
|
+
const content = await readFile(path, "utf-8");
|
|
97
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
98
|
+
const events: TelemetryEvent[] = [];
|
|
99
|
+
|
|
100
|
+
for (const line of lines.slice(-count)) {
|
|
101
|
+
try { events.push(JSON.parse(line)); } catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return events;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format events for display.
|
|
109
|
+
*/
|
|
110
|
+
export function formatEvents(events: TelemetryEvent[]): string {
|
|
111
|
+
return events.map(e => {
|
|
112
|
+
const time = new Date(e.timestamp).toLocaleTimeString();
|
|
113
|
+
const data = e.data ? ` ${JSON.stringify(e.data).slice(0, 80)}` : "";
|
|
114
|
+
return ` ${time} ${e.type}${data}`;
|
|
115
|
+
}).join("\n");
|
|
116
|
+
}
|