ashlrcode 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +73 -16
  2. package/package.json +28 -9
  3. package/prompts/skills/commit.md +36 -0
  4. package/prompts/skills/coordinate.md +21 -0
  5. package/prompts/skills/daily-review.md +65 -0
  6. package/prompts/skills/debug.md +23 -0
  7. package/prompts/skills/deep-work.md +129 -0
  8. package/prompts/skills/explore.md +24 -0
  9. package/prompts/skills/init.md +39 -0
  10. package/prompts/skills/kairos.md +19 -0
  11. package/prompts/skills/plan.md +19 -0
  12. package/prompts/skills/polish.md +94 -0
  13. package/prompts/skills/pr.md +30 -0
  14. package/prompts/skills/refactor.md +26 -0
  15. package/prompts/skills/resume-branch.md +27 -0
  16. package/prompts/skills/review.md +27 -0
  17. package/prompts/skills/ship.md +32 -0
  18. package/prompts/skills/simplify.md +25 -0
  19. package/prompts/skills/test.md +19 -0
  20. package/prompts/skills/verify.md +17 -0
  21. package/prompts/skills/weekly-plan.md +63 -0
  22. package/prompts/system.md +451 -0
  23. package/src/agent/away-summary.ts +138 -0
  24. package/src/agent/context.ts +6 -0
  25. package/src/agent/coordinator.ts +494 -0
  26. package/src/agent/dream.ts +149 -11
  27. package/src/agent/error-handler.ts +51 -35
  28. package/src/agent/kairos.ts +52 -4
  29. package/src/agent/loop.ts +153 -13
  30. package/src/agent/mailbox.ts +151 -0
  31. package/src/agent/model-patches.ts +28 -3
  32. package/src/agent/product-agent.ts +463 -0
  33. package/src/agent/speculation.ts +21 -18
  34. package/src/agent/sub-agent.ts +11 -1
  35. package/src/agent/system-prompt.ts +19 -0
  36. package/src/agent/tool-executor.ts +83 -3
  37. package/src/agent/verification.ts +223 -0
  38. package/src/agent/worktree-manager.ts +50 -1
  39. package/src/cli.ts +228 -36
  40. package/src/config/features.ts +8 -8
  41. package/src/config/keychain.ts +105 -0
  42. package/src/config/permissions.ts +3 -2
  43. package/src/config/settings.ts +73 -5
  44. package/src/config/upgrade-notice.ts +15 -2
  45. package/src/mcp/client.ts +392 -2
  46. package/src/mcp/manager.ts +129 -13
  47. package/src/mcp/types.ts +4 -1
  48. package/src/migrate.ts +228 -0
  49. package/src/persistence/session.ts +209 -5
  50. package/src/providers/anthropic.ts +112 -98
  51. package/src/providers/cost-tracker.ts +71 -2
  52. package/src/providers/retry.ts +2 -4
  53. package/src/providers/types.ts +5 -1
  54. package/src/providers/xai.ts +1 -0
  55. package/src/repl.tsx +514 -127
  56. package/src/setup.ts +37 -1
  57. package/src/tools/coordinate.ts +88 -0
  58. package/src/tools/grep.ts +9 -11
  59. package/src/tools/lsp.ts +44 -32
  60. package/src/tools/registry.ts +75 -9
  61. package/src/tools/send-message.ts +89 -30
  62. package/src/tools/types.ts +2 -0
  63. package/src/tools/verify.ts +88 -0
  64. package/src/tools/web-browser.ts +8 -5
  65. package/src/tools/workflow.ts +34 -10
  66. package/src/ui/AnimatedSpinner.tsx +302 -0
  67. package/src/ui/App.tsx +16 -15
  68. package/src/ui/BuddyPanel.tsx +27 -34
  69. package/src/ui/SlashInput.tsx +99 -0
  70. package/src/ui/banner.ts +10 -0
  71. package/src/ui/buddy.ts +5 -4
  72. package/src/ui/effort.ts +5 -1
  73. package/src/ui/markdown.ts +269 -88
  74. package/src/ui/message-renderer.ts +183 -35
  75. package/src/ui/quips.json +41 -0
  76. package/src/ui/speech-bubble.ts +35 -19
  77. package/src/utils/ring-buffer.ts +101 -0
  78. package/src/voice/voice-mode.ts +13 -2
  79. package/src/__tests__/branded-types.test.ts +0 -47
  80. package/src/__tests__/context.test.ts +0 -163
  81. package/src/__tests__/cost-tracker.test.ts +0 -274
  82. package/src/__tests__/cron.test.ts +0 -197
  83. package/src/__tests__/dream.test.ts +0 -204
  84. package/src/__tests__/error-handler.test.ts +0 -192
  85. package/src/__tests__/features.test.ts +0 -69
  86. package/src/__tests__/file-history.test.ts +0 -177
  87. package/src/__tests__/hooks.test.ts +0 -145
  88. package/src/__tests__/keybindings.test.ts +0 -159
  89. package/src/__tests__/model-patches.test.ts +0 -82
  90. package/src/__tests__/permissions-rules.test.ts +0 -121
  91. package/src/__tests__/permissions.test.ts +0 -108
  92. package/src/__tests__/project-config.test.ts +0 -63
  93. package/src/__tests__/retry.test.ts +0 -321
  94. package/src/__tests__/router.test.ts +0 -158
  95. package/src/__tests__/session-compact.test.ts +0 -191
  96. package/src/__tests__/session.test.ts +0 -145
  97. package/src/__tests__/skill-registry.test.ts +0 -130
  98. package/src/__tests__/speculation.test.ts +0 -196
  99. package/src/__tests__/tasks-v2.test.ts +0 -267
  100. package/src/__tests__/telemetry.test.ts +0 -149
  101. package/src/__tests__/tool-executor.test.ts +0 -141
  102. package/src/__tests__/tool-registry.test.ts +0 -166
  103. package/src/__tests__/undercover.test.ts +0 -93
  104. package/src/__tests__/workflow.test.ts +0 -195
@@ -3,13 +3,21 @@
3
3
  */
4
4
 
5
5
  import chalk from "chalk";
6
- import { MCPClient } from "./client.ts";
6
+ import { MCPClient, MCPSSEClient, MCPWebSocketClient, createMCPClient } from "./client.ts";
7
7
  import type { MCPServerConfig, MCPToolInfo } from "./types.ts";
8
8
  import { authorizeOAuth } from "./oauth.ts";
9
9
  import type { OAuthConfig } from "./oauth.ts";
10
10
 
11
+ type MCPClientType = MCPClient | MCPSSEClient | MCPWebSocketClient;
12
+
13
+ export type MCPConnectionState = "connected" | "disconnected" | "reconnecting";
14
+
11
15
  export class MCPManager {
12
- private clients = new Map<string, MCPClient>();
16
+ private clients = new Map<string, MCPClientType>();
17
+ private configs = new Map<string, MCPServerConfig>();
18
+ /** Original configs (pre-OAuth) for token refresh on reconnect */
19
+ private originalConfigs = new Map<string, MCPServerConfig>();
20
+ private connectionStates = new Map<string, MCPConnectionState>();
13
21
 
14
22
  /**
15
23
  * Connect to all configured MCP servers.
@@ -50,17 +58,22 @@ export class MCPManager {
50
58
  }
51
59
  }
52
60
 
53
- const client = new MCPClient(name, effectiveConfig);
61
+ this.originalConfigs.set(name, config);
62
+ this.configs.set(name, effectiveConfig);
63
+ const client = createMCPClient(name, effectiveConfig);
54
64
  try {
55
65
  await client.connect();
56
66
  this.clients.set(name, client);
67
+ this.connectionStates.set(name, "connected");
57
68
  console.log(
58
69
  chalk.dim(
59
70
  ` MCP: ${name} connected (${client.tools.length} tools)`
60
71
  )
61
72
  );
62
- } catch {
63
- // Silently skip failed MCP servers — they may not be running
73
+ } catch (err) {
74
+ this.connectionStates.set(name, "disconnected");
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ console.log(chalk.yellow(` MCP: ${name} failed to connect — ${msg.slice(0, 100)}`));
64
77
  }
65
78
  })
66
79
  );
@@ -81,6 +94,7 @@ export class MCPManager {
81
94
 
82
95
  /**
83
96
  * Call a tool on a specific server.
97
+ * On connection error, attempts one reconnect before failing.
84
98
  */
85
99
  async callTool(
86
100
  serverName: string,
@@ -92,18 +106,120 @@ export class MCPManager {
92
106
  return `MCP server "${serverName}" not connected`;
93
107
  }
94
108
 
95
- const result = await client.callTool(toolName, args);
109
+ try {
110
+ const result = await client.callTool(toolName, args);
111
+
112
+ // Extract text from result content blocks
113
+ const text = result.content
114
+ .map((c) => c.text ?? JSON.stringify(c))
115
+ .join("\n");
116
+
117
+ if (result.isError) {
118
+ return `MCP Error: ${text}`;
119
+ }
120
+
121
+ return text;
122
+ } catch (err) {
123
+ const msg = err instanceof Error ? err.message : String(err);
124
+ const isConnectionError =
125
+ msg.includes("connection closed") ||
126
+ msg.includes("not connected") ||
127
+ msg.includes("disconnected") ||
128
+ msg.includes("stream ended") ||
129
+ msg.includes("WebSocket connection closed") ||
130
+ msg.includes("WebSocket not connected");
131
+
132
+ if (!isConnectionError) {
133
+ return `MCP Error: ${msg}`;
134
+ }
135
+
136
+ // Attempt one reconnect
137
+ console.log(chalk.dim(` MCP: ${serverName} connection lost, attempting reconnect...`));
138
+ const reconnected = await this.reconnect(serverName);
139
+ if (!reconnected) {
140
+ return `MCP server "${serverName}" disconnected and reconnect failed`;
141
+ }
142
+
143
+ // Retry the tool call after successful reconnect
144
+ try {
145
+ const retryClient = this.clients.get(serverName);
146
+ if (!retryClient) return `MCP server "${serverName}" not connected after reconnect`;
147
+ const result = await retryClient.callTool(toolName, args);
148
+ const text = result.content
149
+ .map((c) => c.text ?? JSON.stringify(c))
150
+ .join("\n");
151
+ if (result.isError) return `MCP Error: ${text}`;
152
+ return text;
153
+ } catch (retryErr) {
154
+ const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
155
+ return `MCP Error (after reconnect): ${retryMsg}`;
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Reconnect to a specific MCP server.
162
+ * Returns true if reconnection succeeded, false otherwise.
163
+ */
164
+ async reconnect(serverName: string): Promise<boolean> {
165
+ const originalConfig = this.originalConfigs.get(serverName);
166
+ if (!originalConfig) {
167
+ console.log(chalk.yellow(` MCP: ${serverName} — no config found for reconnect`));
168
+ return false;
169
+ }
96
170
 
97
- // Extract text from result content blocks
98
- const text = result.content
99
- .map((c) => c.text ?? JSON.stringify(c))
100
- .join("\n");
171
+ this.connectionStates.set(serverName, "reconnecting");
101
172
 
102
- if (result.isError) {
103
- return `MCP Error: ${text}`;
173
+ // Disconnect existing client if any
174
+ const existing = this.clients.get(serverName);
175
+ if (existing) {
176
+ try { await existing.disconnect(); } catch { /* ignore */ }
177
+ this.clients.delete(serverName);
104
178
  }
105
179
 
106
- return text;
180
+ // Re-do OAuth if the original config had it (token may have expired)
181
+ let effectiveConfig = originalConfig;
182
+ if (originalConfig.oauth) {
183
+ try {
184
+ const oauthConfig: OAuthConfig = {
185
+ ...originalConfig.oauth,
186
+ scopes: originalConfig.oauth.scopes ?? [],
187
+ };
188
+ const token = await authorizeOAuth(serverName, oauthConfig);
189
+ effectiveConfig = {
190
+ ...originalConfig,
191
+ env: {
192
+ ...originalConfig.env,
193
+ MCP_AUTH_TOKEN: token.accessToken,
194
+ MCP_AUTH_TYPE: token.tokenType,
195
+ },
196
+ };
197
+ } catch {
198
+ console.log(chalk.yellow(` MCP: ${serverName} OAuth refresh failed during reconnect`));
199
+ }
200
+ }
201
+
202
+ try {
203
+ this.configs.set(serverName, effectiveConfig);
204
+ const client = createMCPClient(serverName, effectiveConfig);
205
+ await client.connect();
206
+ this.clients.set(serverName, client);
207
+ this.connectionStates.set(serverName, "connected");
208
+ console.log(chalk.dim(` MCP: ${serverName} reconnected (${client.tools.length} tools)`));
209
+ return true;
210
+ } catch (err) {
211
+ this.connectionStates.set(serverName, "disconnected");
212
+ const msg = err instanceof Error ? err.message : String(err);
213
+ console.log(chalk.yellow(` MCP: ${serverName} reconnect failed — ${msg.slice(0, 100)}`));
214
+ return false;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get the connection state for a server.
220
+ */
221
+ getConnectionState(serverName: string): MCPConnectionState {
222
+ return this.connectionStates.get(serverName) ?? "disconnected";
107
223
  }
108
224
 
109
225
  /**
package/src/mcp/types.ts CHANGED
@@ -3,9 +3,12 @@
3
3
  */
4
4
 
5
5
  export interface MCPServerConfig {
6
- command: string;
6
+ /** stdio transport: command to spawn */
7
+ command?: string;
7
8
  args?: string[];
8
9
  env?: Record<string, string>;
10
+ /** SSE transport: URL of MCP server (e.g. http://localhost:3000) */
11
+ url?: string;
9
12
  oauth?: {
10
13
  authorizationUrl: string;
11
14
  tokenUrl: string;
package/src/migrate.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Migrate from Claude Code — one-time config copy.
3
+ *
4
+ * Reads ~/.claude/settings.json and copies:
5
+ * - MCP server configurations
6
+ * - Permission rules (converted to AshlrCode format)
7
+ * - Custom slash commands / skills
8
+ *
9
+ * Does NOT copy: sessions (different format), API keys (different providers),
10
+ * or settings that don't apply (model names, IDE extensions, etc.)
11
+ *
12
+ * Usage: ac --migrate
13
+ */
14
+
15
+ import { existsSync } from "fs";
16
+ import { readFile, writeFile, mkdir, readdir, copyFile } from "fs/promises";
17
+ import { join } from "path";
18
+ import { homedir } from "os";
19
+ import chalk from "chalk";
20
+ import { getConfigDir, loadSettings, saveSettings, type Settings } from "./config/settings.ts";
21
+
22
+ const CLAUDE_DIR = join(homedir(), ".claude");
23
+ const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
24
+ const CLAUDE_COMMANDS_DIR = join(CLAUDE_DIR, "commands");
25
+
26
+ interface ClaudeSettings {
27
+ mcpServers?: Record<string, {
28
+ command?: string;
29
+ args?: string[];
30
+ env?: Record<string, string>;
31
+ url?: string;
32
+ // Claude Code may have additional fields we don't need
33
+ [key: string]: unknown;
34
+ }>;
35
+ permissions?: {
36
+ allow?: string[];
37
+ deny?: string[];
38
+ };
39
+ hooks?: {
40
+ preToolUse?: Array<{
41
+ matcher?: string;
42
+ command?: string;
43
+ [key: string]: unknown;
44
+ }>;
45
+ };
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ export async function runMigration(): Promise<void> {
50
+ console.log(chalk.cyan("\n 🔄 AshlrCode Migration from Claude Code\n"));
51
+
52
+ // Check Claude Code config exists
53
+ if (!existsSync(CLAUDE_DIR)) {
54
+ console.log(chalk.yellow(" ~/.claude/ directory not found."));
55
+ console.log(chalk.dim(" Make sure Claude Code is installed and has been run at least once.\n"));
56
+ process.exit(1);
57
+ }
58
+
59
+ // Load existing AshlrCode settings (or defaults)
60
+ const acSettings = await loadSettings();
61
+ let changesMade = false;
62
+
63
+ // ── Step 1: Migrate MCP Servers ──────────────────────────────
64
+ if (existsSync(CLAUDE_SETTINGS)) {
65
+ console.log(chalk.white(" Reading ~/.claude/settings.json..."));
66
+ try {
67
+ const raw = await readFile(CLAUDE_SETTINGS, "utf-8");
68
+ const claudeSettings = JSON.parse(raw) as ClaudeSettings;
69
+
70
+ // Also check project-level settings
71
+ const projectSettingsPath = join(CLAUDE_DIR, "settings.local.json");
72
+ let projectSettings: ClaudeSettings = {};
73
+ if (existsSync(projectSettingsPath)) {
74
+ try {
75
+ projectSettings = JSON.parse(await readFile(projectSettingsPath, "utf-8")) as ClaudeSettings;
76
+ } catch { /* ignore */ }
77
+ }
78
+
79
+ // Merge MCP servers from both global and project settings
80
+ const allMcpServers = {
81
+ ...claudeSettings.mcpServers,
82
+ ...projectSettings.mcpServers,
83
+ };
84
+
85
+ if (Object.keys(allMcpServers).length > 0) {
86
+ acSettings.mcpServers = acSettings.mcpServers ?? {};
87
+
88
+ let migrated = 0;
89
+ let skipped = 0;
90
+
91
+ for (const [name, config] of Object.entries(allMcpServers)) {
92
+ if (acSettings.mcpServers[name]) {
93
+ console.log(chalk.dim(` ⊘ ${name} — already exists, skipping`));
94
+ skipped++;
95
+ continue;
96
+ }
97
+
98
+ // Convert to AshlrCode MCPServerConfig format
99
+ // We only copy fields our config supports
100
+ const acConfig: Record<string, unknown> = {};
101
+ if (config.command) acConfig.command = config.command;
102
+ if (config.args) acConfig.args = config.args;
103
+ if (config.env) acConfig.env = config.env;
104
+ if (config.url) acConfig.url = config.url;
105
+
106
+ acSettings.mcpServers[name] = acConfig as any;
107
+ console.log(chalk.green(` ✓ ${name} — migrated (${config.command ? "stdio" : config.url ? "SSE" : "unknown transport"})`));
108
+ migrated++;
109
+ }
110
+
111
+ if (migrated > 0) {
112
+ changesMade = true;
113
+ console.log(chalk.cyan(`\n MCP: ${migrated} servers migrated, ${skipped} skipped\n`));
114
+ } else {
115
+ console.log(chalk.dim(`\n MCP: All ${skipped} servers already exist\n`));
116
+ }
117
+ } else {
118
+ console.log(chalk.dim(" No MCP servers found in Claude Code config\n"));
119
+ }
120
+
121
+ // ── Step 2: Migrate Permission Rules ──────────────────────
122
+ const claudePerms = claudeSettings.permissions;
123
+ if (claudePerms) {
124
+ acSettings.permissionRules = acSettings.permissionRules ?? [];
125
+ let permsMigrated = 0;
126
+
127
+ if (claudePerms.allow) {
128
+ for (const pattern of claudePerms.allow) {
129
+ // Claude Code allow rules are tool name patterns
130
+ const exists = acSettings.permissionRules.some(
131
+ (r) => r.tool === pattern && r.action === "allow"
132
+ );
133
+ if (!exists) {
134
+ acSettings.permissionRules.push({ tool: pattern, action: "allow" });
135
+ console.log(chalk.green(` ✓ Allow rule: ${pattern}`));
136
+ permsMigrated++;
137
+ }
138
+ }
139
+ }
140
+
141
+ if (claudePerms.deny) {
142
+ for (const pattern of claudePerms.deny) {
143
+ const exists = acSettings.permissionRules.some(
144
+ (r) => r.tool === pattern && r.action === "deny"
145
+ );
146
+ if (!exists) {
147
+ acSettings.permissionRules.push({ tool: pattern, action: "deny" });
148
+ console.log(chalk.green(` ✓ Deny rule: ${pattern}`));
149
+ permsMigrated++;
150
+ }
151
+ }
152
+ }
153
+
154
+ if (permsMigrated > 0) {
155
+ changesMade = true;
156
+ console.log(chalk.cyan(`\n Permissions: ${permsMigrated} rules migrated\n`));
157
+ }
158
+ }
159
+ } catch (err) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ console.log(chalk.red(` Failed to read Claude Code settings: ${msg}\n`));
162
+ }
163
+ } else {
164
+ console.log(chalk.dim(" No ~/.claude/settings.json found\n"));
165
+ }
166
+
167
+ // ── Step 3: Migrate Custom Commands → Skills ──────────────
168
+ if (existsSync(CLAUDE_COMMANDS_DIR)) {
169
+ const skillsDir = join(getConfigDir(), "skills");
170
+ await mkdir(skillsDir, { recursive: true });
171
+
172
+ try {
173
+ const files = await readdir(CLAUDE_COMMANDS_DIR);
174
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
175
+ let skillsMigrated = 0;
176
+
177
+ for (const file of mdFiles) {
178
+ const destPath = join(skillsDir, file);
179
+ if (existsSync(destPath)) {
180
+ console.log(chalk.dim(` ⊘ ${file} — already exists, skipping`));
181
+ continue;
182
+ }
183
+
184
+ const content = await readFile(join(CLAUDE_COMMANDS_DIR, file), "utf-8");
185
+
186
+ // Convert Claude Code command format to AshlrCode skill format
187
+ // Claude uses $ARGUMENTS, AshlrCode uses {{args}}
188
+ let converted = content.replace(/\$ARGUMENTS/g, "{{args}}");
189
+ converted = converted.replace(/\$arguments/g, "{{args}}");
190
+
191
+ // If the file has no frontmatter, add basic frontmatter
192
+ if (!converted.startsWith("---")) {
193
+ const name = file.replace(".md", "");
194
+ converted = `---\nname: ${name}\ndescription: Migrated from Claude Code\ntrigger: /${name}\n---\n\n${converted}`;
195
+ }
196
+
197
+ await writeFile(destPath, converted, "utf-8");
198
+ console.log(chalk.green(` ✓ ${file} → skills/${file}`));
199
+ skillsMigrated++;
200
+ changesMade = true;
201
+ }
202
+
203
+ if (skillsMigrated > 0) {
204
+ console.log(chalk.cyan(`\n Skills: ${skillsMigrated} commands migrated\n`));
205
+ } else if (mdFiles.length > 0) {
206
+ console.log(chalk.dim(`\n Skills: All commands already migrated\n`));
207
+ }
208
+ } catch (err) {
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ console.log(chalk.dim(` Could not read Claude Code commands: ${msg}\n`));
211
+ }
212
+ }
213
+
214
+ // ── Step 4: Save ──────────────────────────────────────────
215
+ if (changesMade) {
216
+ await saveSettings(acSettings);
217
+ console.log(chalk.green(" ✓ Settings saved to ~/.ashlrcode/settings.json"));
218
+ console.log(chalk.dim(" Restart AshlrCode for changes to take effect.\n"));
219
+ } else {
220
+ console.log(chalk.dim(" No changes needed — config is up to date.\n"));
221
+ }
222
+
223
+ // ── Summary ───────────────────────────────────────────────
224
+ console.log(chalk.white(" Next steps:"));
225
+ console.log(chalk.dim(" 1. Set your API key: export XAI_API_KEY=your-key"));
226
+ console.log(chalk.dim(" 2. Run: ac"));
227
+ console.log(chalk.dim(" 3. Your MCP servers and skills will be available automatically\n"));
228
+ }
@@ -8,11 +8,12 @@
8
8
  */
9
9
 
10
10
  import { existsSync } from "fs";
11
- import { readFile, writeFile, appendFile, mkdir, readdir } from "fs/promises";
11
+ import { readFile, appendFile, mkdir, readdir, unlink, stat } from "fs/promises";
12
12
  import { join } from "path";
13
13
  import { randomUUID } from "crypto";
14
14
  import { getConfigDir } from "../config/settings.ts";
15
15
  import type { Message } from "../providers/types.ts";
16
+ import type { SessionId } from "../types/branded.ts";
16
17
 
17
18
  export interface SessionMetadata {
18
19
  id: string;
@@ -39,6 +40,12 @@ export class Session {
39
40
  readonly id: string;
40
41
  private filePath: string;
41
42
  private metadata: SessionMetadata;
43
+ /**
44
+ * Fire-and-forget write queue. Assistant messages are appended via this
45
+ * queue for low latency — writes are ordered but non-blocking. User messages
46
+ * bypass this queue and await directly (crash recovery requires durability).
47
+ */
48
+ private writeQueue: Promise<void> = Promise.resolve();
42
49
 
43
50
  constructor(id?: string) {
44
51
  this.id = id ?? randomUUID().slice(0, 8);
@@ -65,14 +72,32 @@ export class Session {
65
72
  });
66
73
  }
67
74
 
75
+ /**
76
+ * Append a message to the session log.
77
+ * User messages are written with blocking I/O (crash recovery).
78
+ * Assistant messages use fire-and-forget with ordering guarantees.
79
+ */
68
80
  async appendMessage(message: Message): Promise<void> {
69
81
  this.metadata.messageCount++;
70
82
  this.metadata.updatedAt = new Date().toISOString();
71
- await this.appendEntry({
83
+ const entry: SessionEntry = {
72
84
  type: "message",
73
85
  timestamp: new Date().toISOString(),
74
86
  data: message,
75
- });
87
+ };
88
+
89
+ if (message.role === "user") {
90
+ // Blocking write — if we crash, user input is preserved
91
+ await this.appendEntry(entry);
92
+ } else {
93
+ // Fire-and-forget with ordering — chain onto write queue
94
+ this.writeQueue = this.writeQueue.then(() =>
95
+ this.appendEntry(entry)
96
+ ).catch((err) => {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ console.error("[session] Fire-and-forget write failed:", msg);
99
+ });
100
+ }
76
101
  }
77
102
 
78
103
  async appendMessages(messages: Message[]): Promise<void> {
@@ -81,6 +106,11 @@ export class Session {
81
106
  }
82
107
  }
83
108
 
109
+ /** Wait for all queued writes to complete (call before exit). */
110
+ async flush(): Promise<void> {
111
+ await this.writeQueue;
112
+ }
113
+
84
114
  async insertCompactBoundary(summary: string, messageCountBefore: number): Promise<void> {
85
115
  await this.appendEntry({
86
116
  type: "compact",
@@ -178,8 +208,13 @@ export class Session {
178
208
  }
179
209
 
180
210
  private async appendEntry(entry: SessionEntry): Promise<void> {
181
- await mkdir(getSessionsDir(), { recursive: true });
182
- await appendFile(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
211
+ try {
212
+ await mkdir(getSessionsDir(), { recursive: true });
213
+ await appendFile(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
214
+ } catch (error: unknown) {
215
+ const msg = error instanceof Error ? error.message : String(error);
216
+ console.error("[session] Failed to persist entry:", msg);
217
+ }
183
218
  }
184
219
  }
185
220
 
@@ -275,6 +310,9 @@ export async function forkSession(
275
310
  export async function compactSession(id: string): Promise<{ messagesBefore: number; summary: string }> {
276
311
  const session = new Session(id);
277
312
  const allMessages = await session.loadAllMessages();
313
+ if (allMessages.length === 0) {
314
+ throw new Error(`Session ${id} not found or empty`);
315
+ }
278
316
  const messagesBefore = allMessages.length;
279
317
 
280
318
  // Generate summary from recent messages
@@ -287,3 +325,169 @@ export async function compactSession(id: string): Promise<{ messagesBefore: numb
287
325
  await session.insertCompactBoundary(summary, messagesBefore);
288
326
  return { messagesBefore, summary };
289
327
  }
328
+
329
+ /**
330
+ * Import a Claude Code JSONL session file into AshlrCode format.
331
+ *
332
+ * Claude Code sessions use JSONL with entries like:
333
+ * {"type":"human","message":{"role":"user","content":"..."}}
334
+ * {"type":"assistant","message":{"role":"assistant","content":[...]}}
335
+ *
336
+ * This is a best-effort parser — unparseable lines are skipped.
337
+ */
338
+ export async function importClaudeCodeSession(
339
+ claudeSessionPath: string,
340
+ provider = "imported",
341
+ model = "claude-code"
342
+ ): Promise<Session> {
343
+ if (!existsSync(claudeSessionPath)) {
344
+ throw new Error(`Session file not found: ${claudeSessionPath}`);
345
+ }
346
+
347
+ const content = await readFile(claudeSessionPath, "utf-8");
348
+ const lines = content.trim().split("\n").filter(Boolean);
349
+ const messages: Message[] = [];
350
+
351
+ for (const line of lines) {
352
+ try {
353
+ const entry = JSON.parse(line);
354
+
355
+ // Claude Code format: {type: "human"|"assistant", message: {role, content}}
356
+ if (entry.message && entry.message.role && entry.message.content !== undefined) {
357
+ const role = entry.message.role;
358
+ if (role === "user" || role === "assistant") {
359
+ // Normalize content — Claude Code may use content blocks or strings
360
+ let normalizedContent: string;
361
+ if (typeof entry.message.content === "string") {
362
+ normalizedContent = entry.message.content;
363
+ } else if (Array.isArray(entry.message.content)) {
364
+ // Extract text from content blocks
365
+ const textParts: string[] = [];
366
+ for (const block of entry.message.content) {
367
+ if (block.type === "text" && block.text) {
368
+ textParts.push(block.text);
369
+ } else if (typeof block === "string") {
370
+ textParts.push(block);
371
+ }
372
+ }
373
+ normalizedContent = textParts.join("\n");
374
+ } else {
375
+ continue; // Skip entries with unrecognized content format
376
+ }
377
+
378
+ if (normalizedContent.trim()) {
379
+ messages.push({ role, content: normalizedContent });
380
+ }
381
+ }
382
+ }
383
+
384
+ // Alternative format: direct {role, content} entries (some Claude Code variants)
385
+ else if (entry.role && entry.content !== undefined && !entry.type) {
386
+ const role = entry.role;
387
+ if (role === "user" || role === "assistant") {
388
+ const normalizedContent = typeof entry.content === "string"
389
+ ? entry.content
390
+ : Array.isArray(entry.content)
391
+ ? entry.content
392
+ .filter((b: Record<string, unknown>) => b.type === "text" && b.text)
393
+ .map((b: Record<string, unknown>) => b.text as string)
394
+ .join("\n")
395
+ : "";
396
+
397
+ if (normalizedContent.trim()) {
398
+ messages.push({ role, content: normalizedContent });
399
+ }
400
+ }
401
+ }
402
+
403
+ // AshlrCode's own format: {type: "message", data: {role, content}}
404
+ else if (entry.type === "message" && entry.data?.role) {
405
+ const role = entry.data.role;
406
+ if (role === "user" || role === "assistant") {
407
+ const normalizedContent = typeof entry.data.content === "string"
408
+ ? entry.data.content
409
+ : "";
410
+ if (normalizedContent.trim()) {
411
+ messages.push({ role, content: normalizedContent });
412
+ }
413
+ }
414
+ }
415
+ } catch {
416
+ // Skip unparseable lines
417
+ }
418
+ }
419
+
420
+ if (messages.length === 0) {
421
+ throw new Error("No parseable messages found in session file.");
422
+ }
423
+
424
+ const session = new Session();
425
+ await session.init(provider, model);
426
+ await session.appendMessages(messages);
427
+ await session.setTitle(`Imported from ${claudeSessionPath.split("/").pop() ?? "claude-code"}`);
428
+
429
+ return session;
430
+ }
431
+
432
+ /**
433
+ * Prune old session files. Keeps at most `maxCount` sessions,
434
+ * deleting the oldest by modification time. Optionally deletes
435
+ * sessions older than `maxAgeDays`.
436
+ *
437
+ * Returns the number of files deleted.
438
+ */
439
+ export async function pruneOldSessions(
440
+ maxCount = 100,
441
+ maxAgeDays?: number
442
+ ): Promise<number> {
443
+ const sessionsDir = getSessionsDir();
444
+ if (!existsSync(sessionsDir)) return 0;
445
+
446
+ const files = await readdir(sessionsDir);
447
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
448
+
449
+ if (jsonlFiles.length === 0) return 0;
450
+
451
+ // Get modification times for all session files
452
+ const fileStats = await Promise.all(
453
+ jsonlFiles.map(async (f) => {
454
+ const filePath = join(sessionsDir, f);
455
+ const s = await stat(filePath);
456
+ return { path: filePath, mtime: s.mtimeMs };
457
+ })
458
+ );
459
+
460
+ // Sort oldest first
461
+ fileStats.sort((a, b) => a.mtime - b.mtime);
462
+
463
+ const toDelete = new Set<string>();
464
+
465
+ // Mark files older than maxAgeDays
466
+ if (maxAgeDays !== undefined) {
467
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
468
+ for (const f of fileStats) {
469
+ if (f.mtime < cutoff) {
470
+ toDelete.add(f.path);
471
+ }
472
+ }
473
+ }
474
+
475
+ // If still over maxCount after age pruning, trim oldest
476
+ const remaining = fileStats.filter((f) => !toDelete.has(f.path));
477
+ if (remaining.length > maxCount) {
478
+ const excess = remaining.length - maxCount;
479
+ for (let i = 0; i < excess; i++) {
480
+ toDelete.add(remaining[i]!.path);
481
+ }
482
+ }
483
+
484
+ for (const filePath of toDelete) {
485
+ try {
486
+ await unlink(filePath);
487
+ } catch {
488
+ // Ignore deletion errors (file may already be gone)
489
+ }
490
+ }
491
+
492
+ return toDelete.size;
493
+ }