@heysalad/cheri-cli 0.7.0 → 0.9.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/package.json +1 -1
- package/src/commands/agent.js +227 -174
- package/src/lib/context.js +56 -0
- package/src/lib/hooks/index.js +82 -0
- package/src/lib/memory.js +73 -0
- package/src/lib/permissions.js +69 -0
- package/src/lib/plugins/index.js +138 -0
- package/src/lib/sessions/index.js +56 -0
- package/src/lib/tools/command-tools.js +34 -0
- package/src/lib/tools/file-tools.js +72 -0
- package/src/lib/tools/index.js +32 -0
- package/src/lib/tools/search-tools.js +95 -0
- package/src/repl.js +13 -1
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1,151 +1,72 @@
|
|
|
1
1
|
import { apiClient } from "../lib/api-client.js";
|
|
2
2
|
import { log } from "../lib/logger.js";
|
|
3
|
+
import { getToolDefinitions, executeTool as executeLocalTool, requiresConfirmation as toolRequiresConfirmation } from "../lib/tools/index.js";
|
|
4
|
+
import { generateSessionId, saveSession, loadSession, listSessions } from "../lib/sessions/index.js";
|
|
5
|
+
import { compactMessages, shouldCompact, estimateMessagesTokens } from "../lib/context.js";
|
|
6
|
+
import { loadHooks, runHooks } from "../lib/hooks/index.js";
|
|
7
|
+
import { loadPlugins, getSkillContext, getSlashCommand } from "../lib/plugins/index.js";
|
|
8
|
+
import { checkPermission, loadPermissions } from "../lib/permissions.js";
|
|
9
|
+
import { getMemoryContext } from "../lib/memory.js";
|
|
3
10
|
import chalk from "chalk";
|
|
11
|
+
import readline from "readline";
|
|
4
12
|
|
|
5
|
-
const SYSTEM_PROMPT = `You are Cheri
|
|
13
|
+
const SYSTEM_PROMPT = `You are Cheri, an AI coding assistant by HeySalad. You are a powerful agentic coding tool that can read, write, edit, and search code, execute shell commands, and manage cloud workspaces.
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
description: "Stop and delete a running workspace",
|
|
41
|
-
parameters: {
|
|
42
|
-
type: "object",
|
|
43
|
-
properties: { id: { type: "string", description: "Workspace ID to stop" } },
|
|
44
|
-
required: ["id"],
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
type: "function",
|
|
50
|
-
function: {
|
|
51
|
-
name: "get_workspace_status",
|
|
52
|
-
description: "Get the status of a specific workspace",
|
|
53
|
-
parameters: {
|
|
54
|
-
type: "object",
|
|
55
|
-
properties: { id: { type: "string", description: "Workspace ID" } },
|
|
56
|
-
required: ["id"],
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
type: "function",
|
|
62
|
-
function: {
|
|
63
|
-
name: "get_memory",
|
|
64
|
-
description: "Retrieve all stored memory entries",
|
|
65
|
-
parameters: { type: "object", properties: {}, required: [] },
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
type: "function",
|
|
70
|
-
function: {
|
|
71
|
-
name: "add_memory",
|
|
72
|
-
description: "Add a new memory entry for the user",
|
|
73
|
-
parameters: {
|
|
74
|
-
type: "object",
|
|
75
|
-
properties: {
|
|
76
|
-
content: { type: "string", description: "Memory content to store" },
|
|
77
|
-
category: { type: "string", description: "Optional category (defaults to 'general')" },
|
|
78
|
-
},
|
|
79
|
-
required: ["content"],
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
type: "function",
|
|
85
|
-
function: {
|
|
86
|
-
name: "clear_memory",
|
|
87
|
-
description: "Clear all stored memory entries",
|
|
88
|
-
parameters: { type: "object", properties: {}, required: [] },
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
type: "function",
|
|
93
|
-
function: {
|
|
94
|
-
name: "get_usage",
|
|
95
|
-
description: "Get the user's API usage and rate limit statistics",
|
|
96
|
-
parameters: { type: "object", properties: {}, required: [] },
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
{
|
|
15
|
+
You have two categories of tools:
|
|
16
|
+
1. LOCAL CODING TOOLS — read_file, write_file, edit_file, run_command, search_files, search_content, list_directory. Use these to work directly with the user's local codebase.
|
|
17
|
+
2. CLOUD PLATFORM TOOLS — get_account_info, list_workspaces, create_workspace, stop_workspace, get_workspace_status, get_memory, add_memory, clear_memory, get_usage, get_config, set_config. Use these to manage the Cheri cloud platform.
|
|
18
|
+
|
|
19
|
+
Guidelines:
|
|
20
|
+
- Read files before editing them. Understand existing code before making changes.
|
|
21
|
+
- Use search_files and search_content to explore unfamiliar codebases.
|
|
22
|
+
- Use list_directory to understand project structure.
|
|
23
|
+
- When writing code, match existing style and patterns.
|
|
24
|
+
- For shell commands, prefer specific commands over broad ones.
|
|
25
|
+
- Be concise. Show what you did and the result.
|
|
26
|
+
- Never guess file contents — always read first.
|
|
27
|
+
- Current working directory: ${process.cwd()}`;
|
|
28
|
+
|
|
29
|
+
// Cloud platform tools
|
|
30
|
+
const CLOUD_TOOLS = [
|
|
31
|
+
{ name: "get_account_info", description: "Get the current user's account information", parameters: { type: "object", properties: {}, required: [] } },
|
|
32
|
+
{ name: "list_workspaces", description: "List all cloud workspaces for the current user", parameters: { type: "object", properties: {}, required: [] } },
|
|
33
|
+
{ name: "create_workspace", description: "Launch a new cloud workspace for a GitHub repository", parameters: { type: "object", properties: { repo: { type: "string", description: "GitHub repo in owner/name format" } }, required: ["repo"] } },
|
|
34
|
+
{ name: "stop_workspace", description: "Stop and delete a running workspace", parameters: { type: "object", properties: { id: { type: "string", description: "Workspace ID to stop" } }, required: ["id"] } },
|
|
35
|
+
{ name: "get_workspace_status", description: "Get the status of a specific workspace", parameters: { type: "object", properties: { id: { type: "string", description: "Workspace ID" } }, required: ["id"] } },
|
|
36
|
+
{ name: "get_memory", description: "Retrieve all stored memory entries", parameters: { type: "object", properties: {}, required: [] } },
|
|
37
|
+
{ name: "add_memory", description: "Add a new memory entry for the user", parameters: { type: "object", properties: { content: { type: "string", description: "Memory content to store" }, category: { type: "string", description: "Optional category (defaults to 'general')" } }, required: ["content"] } },
|
|
38
|
+
{ name: "clear_memory", description: "Clear all stored memory entries", parameters: { type: "object", properties: {}, required: [] } },
|
|
39
|
+
{ name: "get_usage", description: "Get the user's API usage and rate limit statistics", parameters: { type: "object", properties: {}, required: [] } },
|
|
40
|
+
{ name: "get_config", description: "Get a configuration value by key (dot notation supported)", parameters: { type: "object", properties: { key: { type: "string", description: "Config key, e.g. 'ai.provider'" } }, required: ["key"] } },
|
|
41
|
+
{ name: "set_config", description: "Set a configuration value", parameters: { type: "object", properties: { key: { type: "string", description: "Config key" }, value: { type: "string", description: "Value to set" } }, required: ["key", "value"] } },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const CLOUD_TOOL_NAMES = new Set(CLOUD_TOOLS.map((t) => t.name));
|
|
45
|
+
|
|
46
|
+
function buildTools() {
|
|
47
|
+
const localDefs = getToolDefinitions().map((t) => ({
|
|
100
48
|
type: "function",
|
|
101
|
-
function: {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
parameters: {
|
|
105
|
-
type: "object",
|
|
106
|
-
properties: { key: { type: "string", description: "Config key, e.g. 'ai.provider'" } },
|
|
107
|
-
required: ["key"],
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
{
|
|
49
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
50
|
+
}));
|
|
51
|
+
const cloudDefs = CLOUD_TOOLS.map((t) => ({
|
|
112
52
|
type: "function",
|
|
113
|
-
function: {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
type: "object",
|
|
118
|
-
properties: {
|
|
119
|
-
key: { type: "string", description: "Config key" },
|
|
120
|
-
value: { type: "string", description: "Value to set" },
|
|
121
|
-
},
|
|
122
|
-
required: ["key", "value"],
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
];
|
|
53
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
54
|
+
}));
|
|
55
|
+
return [...localDefs, ...cloudDefs];
|
|
56
|
+
}
|
|
127
57
|
|
|
128
|
-
async function
|
|
58
|
+
async function executeCloudTool(name, args) {
|
|
129
59
|
try {
|
|
130
60
|
switch (name) {
|
|
131
|
-
case "get_account_info":
|
|
132
|
-
|
|
133
|
-
case "
|
|
134
|
-
|
|
135
|
-
case "
|
|
136
|
-
|
|
137
|
-
case "
|
|
138
|
-
|
|
139
|
-
case "
|
|
140
|
-
return await apiClient.getWorkspaceStatus(args.id);
|
|
141
|
-
case "get_memory":
|
|
142
|
-
return await apiClient.getMemory();
|
|
143
|
-
case "add_memory":
|
|
144
|
-
return await apiClient.addMemory(args.content, args.category);
|
|
145
|
-
case "clear_memory":
|
|
146
|
-
return await apiClient.clearMemory();
|
|
147
|
-
case "get_usage":
|
|
148
|
-
return await apiClient.getUsage();
|
|
61
|
+
case "get_account_info": return await apiClient.getMe();
|
|
62
|
+
case "list_workspaces": return await apiClient.listWorkspaces();
|
|
63
|
+
case "create_workspace": return await apiClient.createWorkspace(args.repo);
|
|
64
|
+
case "stop_workspace": return await apiClient.deleteWorkspace(args.id);
|
|
65
|
+
case "get_workspace_status": return await apiClient.getWorkspaceStatus(args.id);
|
|
66
|
+
case "get_memory": return await apiClient.getMemory();
|
|
67
|
+
case "add_memory": return await apiClient.addMemory(args.content, args.category);
|
|
68
|
+
case "clear_memory": return await apiClient.clearMemory();
|
|
69
|
+
case "get_usage": return await apiClient.getUsage();
|
|
149
70
|
case "get_config": {
|
|
150
71
|
const { getConfigValue } = await import("../lib/config-store.js");
|
|
151
72
|
return { key: args.key, value: getConfigValue(args.key) };
|
|
@@ -155,15 +76,55 @@ async function executeTool(name, args) {
|
|
|
155
76
|
setConfigValue(args.key, args.value);
|
|
156
77
|
return { key: args.key, value: args.value, status: "updated" };
|
|
157
78
|
}
|
|
158
|
-
default:
|
|
159
|
-
return { error: `Unknown tool: ${name}` };
|
|
79
|
+
default: return { error: `Unknown cloud tool: ${name}` };
|
|
160
80
|
}
|
|
161
81
|
} catch (err) {
|
|
162
82
|
return { error: err.message };
|
|
163
83
|
}
|
|
164
84
|
}
|
|
165
85
|
|
|
166
|
-
|
|
86
|
+
async function confirmAction(toolName, input) {
|
|
87
|
+
const desc = toolName === "run_command" ? input.command : JSON.stringify(input);
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
90
|
+
rl.question(chalk.yellow(` Allow ${chalk.cyan(toolName)}: ${chalk.dim(truncate(desc, 60))}? [Y/n] `), (answer) => {
|
|
91
|
+
rl.close();
|
|
92
|
+
resolve(answer.trim().toLowerCase() !== "n");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function executeTool(name, args) {
|
|
98
|
+
// Check permission rules
|
|
99
|
+
const permission = checkPermission(name, args);
|
|
100
|
+
if (permission === "deny") {
|
|
101
|
+
return { error: `Tool ${name} is denied by permission rules` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Run PreToolUse hooks
|
|
105
|
+
const hookResult = await runHooks("PreToolUse", { toolName: name, input: args });
|
|
106
|
+
if (!hookResult.allowed) {
|
|
107
|
+
return { error: hookResult.reason || `Blocked by hook` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cloud tools
|
|
111
|
+
if (CLOUD_TOOL_NAMES.has(name)) {
|
|
112
|
+
const result = await executeCloudTool(name, args);
|
|
113
|
+
await runHooks("PostToolUse", { toolName: name, input: args, result });
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Local tools — check if confirmation needed
|
|
118
|
+
if (permission === "ask" || (permission !== "allow" && toolRequiresConfirmation(name))) {
|
|
119
|
+
const allowed = await confirmAction(name, args);
|
|
120
|
+
if (!allowed) return { error: "User denied execution" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = await executeLocalTool(name, args);
|
|
124
|
+
await runHooks("PostToolUse", { toolName: name, input: args, result });
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
167
128
|
async function* parseSSEStream(response) {
|
|
168
129
|
const reader = response.body.getReader();
|
|
169
130
|
const decoder = new TextDecoder();
|
|
@@ -182,9 +143,7 @@ async function* parseSSEStream(response) {
|
|
|
182
143
|
if (line.startsWith("data: ")) {
|
|
183
144
|
const data = line.slice(6).trim();
|
|
184
145
|
if (data === "[DONE]") return;
|
|
185
|
-
try {
|
|
186
|
-
yield JSON.parse(data);
|
|
187
|
-
} catch {}
|
|
146
|
+
try { yield JSON.parse(data); } catch {}
|
|
188
147
|
}
|
|
189
148
|
}
|
|
190
149
|
}
|
|
@@ -193,16 +152,108 @@ async function* parseSSEStream(response) {
|
|
|
193
152
|
}
|
|
194
153
|
}
|
|
195
154
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
155
|
+
// Active session state
|
|
156
|
+
let currentSession = null;
|
|
157
|
+
|
|
158
|
+
export async function runAgent(userRequest, options = {}) {
|
|
159
|
+
// Initialize systems
|
|
160
|
+
loadHooks();
|
|
161
|
+
loadPermissions();
|
|
162
|
+
const plugins = loadPlugins();
|
|
163
|
+
const memoryContext = getMemoryContext();
|
|
164
|
+
const skillContext = getSkillContext(plugins.skills, userRequest);
|
|
201
165
|
|
|
202
|
-
|
|
166
|
+
// Check for slash commands
|
|
167
|
+
if (userRequest.startsWith("/")) {
|
|
168
|
+
const cmdName = userRequest.slice(1).split(/\s+/)[0];
|
|
169
|
+
const cmdArgs = userRequest.slice(1 + cmdName.length).trim();
|
|
170
|
+
|
|
171
|
+
// Built-in slash commands
|
|
172
|
+
if (cmdName === "sessions" || cmdName === "history") {
|
|
173
|
+
const sessions = listSessions();
|
|
174
|
+
if (sessions.length === 0) { log.info("No saved sessions."); return; }
|
|
175
|
+
log.brand("Sessions");
|
|
176
|
+
sessions.slice(0, 10).forEach((s) => {
|
|
177
|
+
console.log(` ${chalk.dim(s.id.slice(0, 12))} ${s.title} ${chalk.dim(`(${s.messageCount} msgs)`)}`);
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (cmdName === "resume") {
|
|
182
|
+
const sessions = listSessions();
|
|
183
|
+
if (sessions.length === 0) { log.info("No sessions to resume."); return; }
|
|
184
|
+
const target = cmdArgs ? sessions.find((s) => s.id.includes(cmdArgs)) : sessions[0];
|
|
185
|
+
if (!target) { log.error("Session not found."); return; }
|
|
186
|
+
const data = loadSession(target.id);
|
|
187
|
+
if (data) {
|
|
188
|
+
currentSession = { id: target.id, messages: data.messages, title: data.title };
|
|
189
|
+
log.success(`Resumed session: ${target.title}`);
|
|
190
|
+
log.dim(`${data.messages.length} messages loaded`);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (cmdName === "compact") {
|
|
195
|
+
if (!currentSession) { log.info("No active session to compact."); return; }
|
|
196
|
+
const before = estimateMessagesTokens(currentSession.messages);
|
|
197
|
+
const { messages } = compactMessages(currentSession.messages, 50000);
|
|
198
|
+
currentSession.messages = messages;
|
|
199
|
+
const after = estimateMessagesTokens(messages);
|
|
200
|
+
log.success(`Compacted: ~${Math.round(before / 1000)}k → ~${Math.round(after / 1000)}k tokens`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (cmdName === "new") {
|
|
204
|
+
currentSession = null;
|
|
205
|
+
log.success("Started new session.");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Plugin slash commands
|
|
210
|
+
const cmd = getSlashCommand(plugins.commands, cmdName);
|
|
211
|
+
if (cmd) {
|
|
212
|
+
log.info(`Running /${cmdName}`);
|
|
213
|
+
userRequest = cmd.content.replace(/\$ARGUMENTS/g, cmdArgs).replace(/\$1/g, cmdArgs);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Run SessionStart hooks on first message
|
|
218
|
+
if (!currentSession) {
|
|
219
|
+
await runHooks("SessionStart", { cwd: process.cwd() });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Build or continue session
|
|
223
|
+
if (!currentSession) {
|
|
224
|
+
currentSession = {
|
|
225
|
+
id: generateSessionId(),
|
|
226
|
+
messages: [{ role: "system", content: SYSTEM_PROMPT + memoryContext + skillContext }],
|
|
227
|
+
title: userRequest.slice(0, 60),
|
|
228
|
+
createdAt: Date.now(),
|
|
229
|
+
};
|
|
230
|
+
} else {
|
|
231
|
+
// Inject fresh skill context if relevant
|
|
232
|
+
if (skillContext) {
|
|
233
|
+
const sysMsg = currentSession.messages.find((m) => m.role === "system");
|
|
234
|
+
if (sysMsg && !sysMsg.content.includes(skillContext)) {
|
|
235
|
+
sysMsg.content += skillContext;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
currentSession.messages.push({ role: "user", content: userRequest });
|
|
241
|
+
currentSession.updatedAt = Date.now();
|
|
242
|
+
|
|
243
|
+
// Auto-compact if conversation is too long
|
|
244
|
+
if (shouldCompact(currentSession.messages)) {
|
|
245
|
+
const { messages, compacted } = compactMessages(currentSession.messages);
|
|
246
|
+
if (compacted) {
|
|
247
|
+
currentSession.messages = messages;
|
|
248
|
+
log.dim("Context auto-compacted to fit window.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ALL_TOOLS = buildTools();
|
|
253
|
+
const MAX_ITERATIONS = 15;
|
|
203
254
|
|
|
204
255
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
205
|
-
const response = await apiClient.chatStream(messages,
|
|
256
|
+
const response = await apiClient.chatStream(currentSession.messages, ALL_TOOLS);
|
|
206
257
|
|
|
207
258
|
let fullText = "";
|
|
208
259
|
const toolCalls = {};
|
|
@@ -219,9 +270,7 @@ export async function runAgent(userRequest) {
|
|
|
219
270
|
if (delta?.tool_calls) {
|
|
220
271
|
for (const tc of delta.tool_calls) {
|
|
221
272
|
const idx = tc.index;
|
|
222
|
-
if (!toolCalls[idx]) {
|
|
223
|
-
toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
|
|
224
|
-
}
|
|
273
|
+
if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
|
|
225
274
|
if (tc.id) toolCalls[idx].id = tc.id;
|
|
226
275
|
if (tc.function?.name) toolCalls[idx].name = tc.function.name;
|
|
227
276
|
if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
|
|
@@ -233,29 +282,29 @@ export async function runAgent(userRequest) {
|
|
|
233
282
|
|
|
234
283
|
const toolCallList = Object.values(toolCalls);
|
|
235
284
|
|
|
236
|
-
// No tool calls — final text response, done
|
|
237
285
|
if (toolCallList.length === 0) {
|
|
238
286
|
if (fullText) process.stdout.write("\n");
|
|
287
|
+
// Save assistant response to session
|
|
288
|
+
currentSession.messages.push({ role: "assistant", content: fullText });
|
|
289
|
+
saveSession(currentSession.id, currentSession);
|
|
239
290
|
return;
|
|
240
291
|
}
|
|
241
292
|
|
|
242
293
|
if (fullText) process.stdout.write("\n");
|
|
243
294
|
|
|
244
|
-
// Build assistant message with tool calls
|
|
245
295
|
const assistantMsg = { role: "assistant", content: fullText || null };
|
|
246
296
|
assistantMsg.tool_calls = toolCallList.map((tc) => ({
|
|
247
|
-
id: tc.id,
|
|
248
|
-
type: "function",
|
|
249
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
297
|
+
id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
|
|
250
298
|
}));
|
|
251
|
-
messages.push(assistantMsg);
|
|
299
|
+
currentSession.messages.push(assistantMsg);
|
|
252
300
|
|
|
253
|
-
// Execute each tool and add results
|
|
254
301
|
for (const tc of toolCallList) {
|
|
255
302
|
let input = {};
|
|
256
303
|
try { input = JSON.parse(tc.arguments); } catch {}
|
|
257
304
|
|
|
258
|
-
|
|
305
|
+
const isLocal = !CLOUD_TOOL_NAMES.has(tc.name);
|
|
306
|
+
const prefix = isLocal ? chalk.magenta("local") : chalk.blue("cloud");
|
|
307
|
+
log.info(`${prefix} ${chalk.cyan(tc.name)}${Object.keys(input).length ? chalk.dim(" " + truncate(JSON.stringify(input), 80)) : ""}`);
|
|
259
308
|
|
|
260
309
|
const result = await executeTool(tc.name, input);
|
|
261
310
|
|
|
@@ -265,28 +314,32 @@ export async function runAgent(userRequest) {
|
|
|
265
314
|
log.success(tc.name);
|
|
266
315
|
}
|
|
267
316
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
});
|
|
317
|
+
const resultStr = JSON.stringify(result);
|
|
318
|
+
const truncatedResult = resultStr.length > 8000 ? resultStr.slice(0, 8000) + "...(truncated)" : resultStr;
|
|
319
|
+
|
|
320
|
+
currentSession.messages.push({ role: "tool", tool_call_id: tc.id, content: truncatedResult });
|
|
273
321
|
}
|
|
322
|
+
|
|
323
|
+
// Save session after each tool round
|
|
324
|
+
saveSession(currentSession.id, currentSession);
|
|
274
325
|
}
|
|
275
326
|
|
|
276
|
-
log.warn("Agent reached maximum iterations (
|
|
327
|
+
log.warn("Agent reached maximum iterations (15). Stopping.");
|
|
328
|
+
await runHooks("Stop", { reason: "max_iterations" });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function truncate(str, max) {
|
|
332
|
+
return str.length > max ? str.slice(0, max) + "..." : str;
|
|
277
333
|
}
|
|
278
334
|
|
|
279
335
|
export function registerAgentCommand(program) {
|
|
280
336
|
program
|
|
281
337
|
.command("agent")
|
|
282
338
|
.argument("<request...>")
|
|
283
|
-
.description("AI agent — natural language command interface")
|
|
339
|
+
.description("AI coding agent — natural language command interface")
|
|
284
340
|
.action(async (requestParts) => {
|
|
285
341
|
const request = requestParts.join(" ");
|
|
286
|
-
try {
|
|
287
|
-
|
|
288
|
-
} catch (err) {
|
|
289
|
-
log.error(err.message);
|
|
290
|
-
}
|
|
342
|
+
try { await runAgent(request); }
|
|
343
|
+
catch (err) { log.error(err.message); }
|
|
291
344
|
});
|
|
292
345
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Simple token estimation (~4 chars per token for English)
|
|
2
|
+
function estimateTokens(text) {
|
|
3
|
+
return Math.ceil((text || "").length / 4);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function estimateMessagesTokens(messages) {
|
|
7
|
+
return messages.reduce((sum, m) => {
|
|
8
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content || "");
|
|
9
|
+
return sum + estimateTokens(content) + 4; // 4 tokens overhead per message
|
|
10
|
+
}, 0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Compress conversation by summarizing old tool results and trimming history
|
|
14
|
+
export function compactMessages(messages, maxTokens = 100000) {
|
|
15
|
+
const currentTokens = estimateMessagesTokens(messages);
|
|
16
|
+
if (currentTokens <= maxTokens) return { messages, compacted: false };
|
|
17
|
+
|
|
18
|
+
const compacted = [];
|
|
19
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
20
|
+
if (systemMsg) compacted.push(systemMsg);
|
|
21
|
+
|
|
22
|
+
// Keep recent messages (last 20), compress older ones
|
|
23
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
24
|
+
const keepRecent = 20;
|
|
25
|
+
const old = nonSystem.slice(0, -keepRecent);
|
|
26
|
+
const recent = nonSystem.slice(-keepRecent);
|
|
27
|
+
|
|
28
|
+
if (old.length > 0) {
|
|
29
|
+
// Build a summary of old messages
|
|
30
|
+
const userMessages = old.filter((m) => m.role === "user" && typeof m.content === "string");
|
|
31
|
+
const toolResults = old.filter((m) => m.role === "tool");
|
|
32
|
+
|
|
33
|
+
let summary = "[Conversation history compacted]\n";
|
|
34
|
+
summary += `Previous turns: ${old.length} messages\n`;
|
|
35
|
+
|
|
36
|
+
if (userMessages.length > 0) {
|
|
37
|
+
summary += "Topics discussed:\n";
|
|
38
|
+
userMessages.forEach((m) => {
|
|
39
|
+
summary += `- ${m.content.slice(0, 100)}\n`;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
summary += `Tool calls made: ${toolResults.length}\n`;
|
|
44
|
+
|
|
45
|
+
compacted.push({ role: "user", content: summary });
|
|
46
|
+
compacted.push({ role: "assistant", content: "Understood. I have context from the previous conversation. Let's continue." });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
compacted.push(...recent);
|
|
50
|
+
return { messages: compacted, compacted: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if we should auto-compact
|
|
54
|
+
export function shouldCompact(messages, threshold = 80000) {
|
|
55
|
+
return estimateMessagesTokens(messages) > threshold;
|
|
56
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
|
|
6
|
+
// Hook events: PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd
|
|
7
|
+
// Hooks are loaded from .cheri/hooks.json in the project directory
|
|
8
|
+
|
|
9
|
+
const HOOKS_FILE = ".cheri/hooks.json";
|
|
10
|
+
|
|
11
|
+
let loadedHooks = null;
|
|
12
|
+
|
|
13
|
+
export function loadHooks(projectDir = process.cwd()) {
|
|
14
|
+
const hooksPath = join(projectDir, HOOKS_FILE);
|
|
15
|
+
if (!existsSync(hooksPath)) {
|
|
16
|
+
loadedHooks = {};
|
|
17
|
+
return loadedHooks;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
loadedHooks = JSON.parse(readFileSync(hooksPath, "utf-8"));
|
|
21
|
+
} catch {
|
|
22
|
+
loadedHooks = {};
|
|
23
|
+
}
|
|
24
|
+
return loadedHooks;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getHooks(event) {
|
|
28
|
+
if (!loadedHooks) loadHooks();
|
|
29
|
+
return loadedHooks[event] || [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Run hooks for an event. Returns { allowed: bool, reason?: string }
|
|
33
|
+
export async function runHooks(event, context = {}) {
|
|
34
|
+
const hooks = getHooks(event);
|
|
35
|
+
if (hooks.length === 0) return { allowed: true };
|
|
36
|
+
|
|
37
|
+
for (const hook of hooks) {
|
|
38
|
+
// Check matcher
|
|
39
|
+
if (hook.matcher && context.toolName) {
|
|
40
|
+
const matchers = hook.matcher.split("|");
|
|
41
|
+
if (!matchers.some((m) => context.toolName.match(new RegExp(m)))) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const h of hook.hooks || [hook]) {
|
|
47
|
+
try {
|
|
48
|
+
if (h.type === "command") {
|
|
49
|
+
const input = JSON.stringify(context);
|
|
50
|
+
const timeout = (h.timeout || 10) * 1000;
|
|
51
|
+
const result = execSync(h.command, {
|
|
52
|
+
input,
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
timeout,
|
|
55
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
56
|
+
cwd: process.cwd(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const output = JSON.parse(result.trim());
|
|
61
|
+
if (output.decision === "block") {
|
|
62
|
+
return { allowed: false, reason: output.reason || "Blocked by hook" };
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Non-JSON output is fine, treat as allow
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err.status === 2) {
|
|
70
|
+
// Exit code 2 = block
|
|
71
|
+
return { allowed: false, reason: err.stderr || "Blocked by hook" };
|
|
72
|
+
}
|
|
73
|
+
// Exit code 1 = warn but allow
|
|
74
|
+
if (err.status === 1 && err.stderr) {
|
|
75
|
+
log.warn(`Hook: ${err.stderr.trim()}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { allowed: true };
|
|
82
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
// Memory scopes:
|
|
6
|
+
// - user: ~/.cheri/memory.json (global, follows user)
|
|
7
|
+
// - project: .cheri/memory.json (per-project, in git)
|
|
8
|
+
// - local: .cheri/memory.local.json (per-project, gitignored)
|
|
9
|
+
|
|
10
|
+
const USER_MEMORY_PATH = join(homedir(), ".cheri", "memory.json");
|
|
11
|
+
const PROJECT_MEMORY_FILE = ".cheri/memory.json";
|
|
12
|
+
const LOCAL_MEMORY_FILE = ".cheri/memory.local.json";
|
|
13
|
+
|
|
14
|
+
function loadMemoryFile(filePath) {
|
|
15
|
+
if (!existsSync(filePath)) return [];
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveMemoryFile(filePath, entries) {
|
|
24
|
+
const dir = join(filePath, "..");
|
|
25
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(filePath, JSON.stringify(entries, null, 2), "utf-8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getMemory(scope = "all", projectDir = process.cwd()) {
|
|
30
|
+
const memories = {};
|
|
31
|
+
|
|
32
|
+
if (scope === "all" || scope === "user") {
|
|
33
|
+
memories.user = loadMemoryFile(USER_MEMORY_PATH);
|
|
34
|
+
}
|
|
35
|
+
if (scope === "all" || scope === "project") {
|
|
36
|
+
memories.project = loadMemoryFile(join(projectDir, PROJECT_MEMORY_FILE));
|
|
37
|
+
}
|
|
38
|
+
if (scope === "all" || scope === "local") {
|
|
39
|
+
memories.local = loadMemoryFile(join(projectDir, LOCAL_MEMORY_FILE));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return memories;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function addMemory(content, scope = "project", category = "general", projectDir = process.cwd()) {
|
|
46
|
+
const filePath = scope === "user" ? USER_MEMORY_PATH :
|
|
47
|
+
scope === "local" ? join(projectDir, LOCAL_MEMORY_FILE) :
|
|
48
|
+
join(projectDir, PROJECT_MEMORY_FILE);
|
|
49
|
+
|
|
50
|
+
const entries = loadMemoryFile(filePath);
|
|
51
|
+
entries.push({ content, category, scope, createdAt: new Date().toISOString() });
|
|
52
|
+
saveMemoryFile(filePath, entries);
|
|
53
|
+
return entries.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function clearMemory(scope = "all", projectDir = process.cwd()) {
|
|
57
|
+
if (scope === "all" || scope === "user") saveMemoryFile(USER_MEMORY_PATH, []);
|
|
58
|
+
if (scope === "all" || scope === "project") saveMemoryFile(join(projectDir, PROJECT_MEMORY_FILE), []);
|
|
59
|
+
if (scope === "all" || scope === "local") saveMemoryFile(join(projectDir, LOCAL_MEMORY_FILE), []);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build memory context string for system prompt
|
|
63
|
+
export function getMemoryContext(projectDir = process.cwd()) {
|
|
64
|
+
const all = getMemory("all", projectDir);
|
|
65
|
+
const entries = [...(all.user || []), ...(all.project || []), ...(all.local || [])];
|
|
66
|
+
if (entries.length === 0) return "";
|
|
67
|
+
|
|
68
|
+
let context = "\n\n## Memory\nThe following are remembered preferences and context:\n";
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
context += `- [${entry.scope}/${entry.category}] ${entry.content}\n`;
|
|
71
|
+
}
|
|
72
|
+
return context;
|
|
73
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
// Permission rules loaded from .cheri/permissions.json
|
|
5
|
+
// Format: { "allow": ["read_file", "list_directory"], "ask": ["run_command", "write_file"], "deny": ["clear_memory"] }
|
|
6
|
+
// Bash patterns: "run_command(npm:*)" allows npm commands
|
|
7
|
+
|
|
8
|
+
const PERMISSIONS_FILE = ".cheri/permissions.json";
|
|
9
|
+
let rules = null;
|
|
10
|
+
|
|
11
|
+
export function loadPermissions(projectDir = process.cwd()) {
|
|
12
|
+
const filePath = join(projectDir, PERMISSIONS_FILE);
|
|
13
|
+
if (!existsSync(filePath)) {
|
|
14
|
+
rules = { allow: [], ask: [], deny: [] };
|
|
15
|
+
return rules;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
rules = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
19
|
+
} catch {
|
|
20
|
+
rules = { allow: [], ask: [], deny: [] };
|
|
21
|
+
}
|
|
22
|
+
return rules;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function checkPermission(toolName, input = {}) {
|
|
26
|
+
if (!rules) loadPermissions();
|
|
27
|
+
|
|
28
|
+
// Check deny first
|
|
29
|
+
for (const pattern of rules.deny || []) {
|
|
30
|
+
if (matchesPattern(toolName, input, pattern)) {
|
|
31
|
+
return "deny";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check allow
|
|
36
|
+
for (const pattern of rules.allow || []) {
|
|
37
|
+
if (matchesPattern(toolName, input, pattern)) {
|
|
38
|
+
return "allow";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check ask
|
|
43
|
+
for (const pattern of rules.ask || []) {
|
|
44
|
+
if (matchesPattern(toolName, input, pattern)) {
|
|
45
|
+
return "ask";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Default: allow for read-only tools, ask for others
|
|
50
|
+
const readOnlyTools = ["read_file", "list_directory", "search_files", "search_content", "get_account_info", "list_workspaces", "get_memory", "get_usage", "get_config", "get_workspace_status"];
|
|
51
|
+
return readOnlyTools.includes(toolName) ? "allow" : "ask";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function matchesPattern(toolName, input, pattern) {
|
|
55
|
+
// Simple match: "read_file"
|
|
56
|
+
if (pattern === toolName) return true;
|
|
57
|
+
|
|
58
|
+
// Pattern match: "run_command(npm:*)"
|
|
59
|
+
const match = pattern.match(/^(\w+)\((.+)\)$/);
|
|
60
|
+
if (match && match[1] === toolName) {
|
|
61
|
+
const argPattern = match[2];
|
|
62
|
+
const command = input.command || "";
|
|
63
|
+
// Convert glob to regex
|
|
64
|
+
const regex = new RegExp("^" + argPattern.replace(/\*/g, ".*").replace(/:/g, "\\s*") + "$");
|
|
65
|
+
return regex.test(command);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { log } from "../logger.js";
|
|
4
|
+
|
|
5
|
+
const PLUGIN_DIR = ".cheri";
|
|
6
|
+
const SKILLS_DIR = "skills";
|
|
7
|
+
const COMMANDS_DIR = "commands";
|
|
8
|
+
const AGENTS_DIR = "agents";
|
|
9
|
+
|
|
10
|
+
// Load all plugins from .cheri/ directory
|
|
11
|
+
export function loadPlugins(projectDir = process.cwd()) {
|
|
12
|
+
const cheriDir = join(projectDir, PLUGIN_DIR);
|
|
13
|
+
if (!existsSync(cheriDir)) return { skills: [], commands: [], agents: [] };
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
skills: loadSkills(cheriDir),
|
|
17
|
+
commands: loadCommands(cheriDir),
|
|
18
|
+
agents: loadAgents(cheriDir),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Load skills from .cheri/skills/
|
|
23
|
+
function loadSkills(cheriDir) {
|
|
24
|
+
const skillsDir = join(cheriDir, SKILLS_DIR);
|
|
25
|
+
if (!existsSync(skillsDir)) return [];
|
|
26
|
+
|
|
27
|
+
const skills = [];
|
|
28
|
+
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
const skillFile = join(skillsDir, entry.name, "SKILL.md");
|
|
31
|
+
if (existsSync(skillFile)) {
|
|
32
|
+
const parsed = parseMarkdownWithFrontmatter(readFileSync(skillFile, "utf-8"));
|
|
33
|
+
skills.push({
|
|
34
|
+
name: parsed.frontmatter.name || entry.name,
|
|
35
|
+
description: parsed.frontmatter.description || "",
|
|
36
|
+
trigger: parsed.frontmatter.trigger || "",
|
|
37
|
+
content: parsed.body,
|
|
38
|
+
dir: join(skillsDir, entry.name),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return skills;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Load slash commands from .cheri/commands/
|
|
47
|
+
function loadCommands(cheriDir) {
|
|
48
|
+
const commandsDir = join(cheriDir, COMMANDS_DIR);
|
|
49
|
+
if (!existsSync(commandsDir)) return [];
|
|
50
|
+
|
|
51
|
+
const commands = [];
|
|
52
|
+
for (const file of readdirSync(commandsDir)) {
|
|
53
|
+
if (file.endsWith(".md")) {
|
|
54
|
+
const content = readFileSync(join(commandsDir, file), "utf-8");
|
|
55
|
+
const parsed = parseMarkdownWithFrontmatter(content);
|
|
56
|
+
commands.push({
|
|
57
|
+
name: file.replace(".md", ""),
|
|
58
|
+
description: parsed.frontmatter.description || "",
|
|
59
|
+
allowedTools: parsed.frontmatter["allowed-tools"] || "",
|
|
60
|
+
content: parsed.body,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return commands;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Load agent definitions from .cheri/agents/
|
|
68
|
+
function loadAgents(cheriDir) {
|
|
69
|
+
const agentsDir = join(cheriDir, AGENTS_DIR);
|
|
70
|
+
if (!existsSync(agentsDir)) return [];
|
|
71
|
+
|
|
72
|
+
const agents = [];
|
|
73
|
+
for (const file of readdirSync(agentsDir)) {
|
|
74
|
+
if (file.endsWith(".md")) {
|
|
75
|
+
const content = readFileSync(join(agentsDir, file), "utf-8");
|
|
76
|
+
const parsed = parseMarkdownWithFrontmatter(content);
|
|
77
|
+
agents.push({
|
|
78
|
+
name: parsed.frontmatter.name || file.replace(".md", ""),
|
|
79
|
+
description: parsed.frontmatter.description || "",
|
|
80
|
+
model: parsed.frontmatter.model || "inherit",
|
|
81
|
+
tools: parsed.frontmatter.tools || [],
|
|
82
|
+
systemPrompt: parsed.body,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return agents;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Parse markdown with YAML frontmatter (--- delimited)
|
|
90
|
+
function parseMarkdownWithFrontmatter(content) {
|
|
91
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
92
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
93
|
+
|
|
94
|
+
const frontmatter = {};
|
|
95
|
+
for (const line of match[1].split("\n")) {
|
|
96
|
+
const colonIdx = line.indexOf(":");
|
|
97
|
+
if (colonIdx > 0) {
|
|
98
|
+
const key = line.slice(0, colonIdx).trim();
|
|
99
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
100
|
+
// Parse arrays
|
|
101
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
102
|
+
try { value = JSON.parse(value); } catch {}
|
|
103
|
+
}
|
|
104
|
+
frontmatter[key] = value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { frontmatter, body: match[2].trim() };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Get extra system prompt context from skills that match the user's request
|
|
112
|
+
export function getSkillContext(skills, userMessage) {
|
|
113
|
+
const matched = [];
|
|
114
|
+
const lowerMsg = userMessage.toLowerCase();
|
|
115
|
+
|
|
116
|
+
for (const skill of skills) {
|
|
117
|
+
// Match by trigger keyword or description
|
|
118
|
+
const triggers = skill.trigger ? skill.trigger.split(",").map((t) => t.trim().toLowerCase()) : [];
|
|
119
|
+
const descWords = skill.description.toLowerCase().split(/\s+/);
|
|
120
|
+
const nameMatch = lowerMsg.includes(skill.name.toLowerCase());
|
|
121
|
+
const triggerMatch = triggers.some((t) => lowerMsg.includes(t));
|
|
122
|
+
const descMatch = descWords.filter((w) => w.length > 4).some((w) => lowerMsg.includes(w));
|
|
123
|
+
|
|
124
|
+
if (nameMatch || triggerMatch || descMatch) {
|
|
125
|
+
matched.push(skill);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (matched.length === 0) return "";
|
|
130
|
+
|
|
131
|
+
return "\n\n## Active Skills\n" +
|
|
132
|
+
matched.map((s) => `### ${s.name}\n${s.content}`).join("\n\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Execute a slash command
|
|
136
|
+
export function getSlashCommand(commands, name) {
|
|
137
|
+
return commands.find((c) => c.name === name);
|
|
138
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
const SESSIONS_DIR = join(homedir(), ".cheri", "sessions");
|
|
6
|
+
|
|
7
|
+
function ensureDir() {
|
|
8
|
+
if (!existsSync(SESSIONS_DIR)) {
|
|
9
|
+
mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function generateSessionId() {
|
|
14
|
+
return `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveSession(sessionId, data) {
|
|
18
|
+
ensureDir();
|
|
19
|
+
const filePath = join(SESSIONS_DIR, `${sessionId}.json`);
|
|
20
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadSession(sessionId) {
|
|
24
|
+
const filePath = join(SESSIONS_DIR, `${sessionId}.json`);
|
|
25
|
+
if (!existsSync(filePath)) return null;
|
|
26
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listSessions() {
|
|
30
|
+
ensureDir();
|
|
31
|
+
const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
32
|
+
return files.map((f) => {
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(readFileSync(join(SESSIONS_DIR, f), "utf-8"));
|
|
35
|
+
return {
|
|
36
|
+
id: f.replace(".json", ""),
|
|
37
|
+
title: data.title || data.messages?.[1]?.content?.slice(0, 60) || "Untitled",
|
|
38
|
+
messageCount: data.messages?.length || 0,
|
|
39
|
+
createdAt: data.createdAt,
|
|
40
|
+
updatedAt: data.updatedAt,
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return { id: f.replace(".json", ""), title: "Corrupted", messageCount: 0 };
|
|
44
|
+
}
|
|
45
|
+
}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function deleteSession(sessionId) {
|
|
49
|
+
const filePath = join(SESSIONS_DIR, `${sessionId}.json`);
|
|
50
|
+
if (existsSync(filePath)) {
|
|
51
|
+
const { unlinkSync } = require("fs");
|
|
52
|
+
unlinkSync(filePath);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
|
|
3
|
+
export const runCommand = {
|
|
4
|
+
name: "run_command",
|
|
5
|
+
description: "Execute a shell command and return its output. Use for running builds, tests, git commands, installing packages, etc.",
|
|
6
|
+
parameters: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
command: { type: "string", description: "The shell command to execute" },
|
|
10
|
+
cwd: { type: "string", description: "Working directory (optional, defaults to current directory)" },
|
|
11
|
+
},
|
|
12
|
+
required: ["command"],
|
|
13
|
+
},
|
|
14
|
+
requiresConfirmation: true,
|
|
15
|
+
handler: async ({ command, cwd }) => {
|
|
16
|
+
try {
|
|
17
|
+
const output = execSync(command, {
|
|
18
|
+
cwd: cwd || process.cwd(),
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
timeout: 120_000,
|
|
21
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
22
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
23
|
+
});
|
|
24
|
+
return { command, exitCode: 0, stdout: output, stderr: "" };
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return {
|
|
27
|
+
command,
|
|
28
|
+
exitCode: err.status ?? 1,
|
|
29
|
+
stdout: err.stdout || "",
|
|
30
|
+
stderr: err.stderr || err.message,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { dirname, resolve } from "path";
|
|
3
|
+
|
|
4
|
+
export const readFile = {
|
|
5
|
+
name: "read_file",
|
|
6
|
+
description: "Read the contents of a file at the given path. Returns the file contents as a string.",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
path: { type: "string", description: "Absolute or relative file path to read" },
|
|
11
|
+
},
|
|
12
|
+
required: ["path"],
|
|
13
|
+
},
|
|
14
|
+
handler: async ({ path }) => {
|
|
15
|
+
const resolved = resolve(path);
|
|
16
|
+
if (!existsSync(resolved)) {
|
|
17
|
+
return { error: `File not found: ${resolved}` };
|
|
18
|
+
}
|
|
19
|
+
const content = readFileSync(resolved, "utf-8");
|
|
20
|
+
return { path: resolved, content, lines: content.split("\n").length };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const writeFile = {
|
|
25
|
+
name: "write_file",
|
|
26
|
+
description: "Create or overwrite a file with the given content. Creates parent directories if needed.",
|
|
27
|
+
parameters: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
path: { type: "string", description: "Absolute or relative file path to write" },
|
|
31
|
+
content: { type: "string", description: "The content to write to the file" },
|
|
32
|
+
},
|
|
33
|
+
required: ["path", "content"],
|
|
34
|
+
},
|
|
35
|
+
handler: async ({ path, content }) => {
|
|
36
|
+
const resolved = resolve(path);
|
|
37
|
+
const dir = dirname(resolved);
|
|
38
|
+
if (!existsSync(dir)) {
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
writeFileSync(resolved, content, "utf-8");
|
|
42
|
+
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const editFile = {
|
|
47
|
+
name: "edit_file",
|
|
48
|
+
description: "Edit a file by replacing an exact string match with new content. The old_string must match exactly (including whitespace and indentation).",
|
|
49
|
+
parameters: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
path: { type: "string", description: "Absolute or relative file path to edit" },
|
|
53
|
+
old_string: { type: "string", description: "The exact string to find and replace" },
|
|
54
|
+
new_string: { type: "string", description: "The replacement string" },
|
|
55
|
+
},
|
|
56
|
+
required: ["path", "old_string", "new_string"],
|
|
57
|
+
},
|
|
58
|
+
handler: async ({ path, old_string, new_string }) => {
|
|
59
|
+
const resolved = resolve(path);
|
|
60
|
+
if (!existsSync(resolved)) {
|
|
61
|
+
return { error: `File not found: ${resolved}` };
|
|
62
|
+
}
|
|
63
|
+
const content = readFileSync(resolved, "utf-8");
|
|
64
|
+
if (!content.includes(old_string)) {
|
|
65
|
+
return { error: "old_string not found in file. Make sure it matches exactly, including whitespace." };
|
|
66
|
+
}
|
|
67
|
+
const count = content.split(old_string).length - 1;
|
|
68
|
+
const newContent = content.replace(old_string, new_string);
|
|
69
|
+
writeFileSync(resolved, newContent, "utf-8");
|
|
70
|
+
return { path: resolved, replacements: 1, totalOccurrences: count };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile, writeFile, editFile } from "./file-tools.js";
|
|
2
|
+
import { runCommand } from "./command-tools.js";
|
|
3
|
+
import { searchFiles, searchContent, listDirectory } from "./search-tools.js";
|
|
4
|
+
|
|
5
|
+
const tools = [readFile, writeFile, editFile, runCommand, searchFiles, searchContent, listDirectory];
|
|
6
|
+
|
|
7
|
+
const toolMap = new Map(tools.map((t) => [t.name, t]));
|
|
8
|
+
|
|
9
|
+
export function getToolDefinitions() {
|
|
10
|
+
return tools.map(({ name, description, parameters }) => ({ name, description, parameters }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getTool(name) {
|
|
14
|
+
return toolMap.get(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function executeTool(name, input) {
|
|
18
|
+
const tool = toolMap.get(name);
|
|
19
|
+
if (!tool) {
|
|
20
|
+
return { error: `Unknown tool: ${name}` };
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return await tool.handler(input);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { error: err.message };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function requiresConfirmation(name) {
|
|
30
|
+
const tool = toolMap.get(name);
|
|
31
|
+
return tool?.requiresConfirmation === true;
|
|
32
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readdirSync, statSync, existsSync } from "fs";
|
|
2
|
+
import { resolve, join } from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
|
|
5
|
+
export const searchFiles = {
|
|
6
|
+
name: "search_files",
|
|
7
|
+
description: "Search for files matching a glob/name pattern. Returns matching file paths.",
|
|
8
|
+
parameters: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
pattern: { type: "string", description: "File name pattern to search for (e.g., '*.js', 'config*', 'test')" },
|
|
12
|
+
path: { type: "string", description: "Directory to search in (defaults to current directory)" },
|
|
13
|
+
},
|
|
14
|
+
required: ["pattern"],
|
|
15
|
+
},
|
|
16
|
+
handler: async ({ pattern, path }) => {
|
|
17
|
+
const dir = resolve(path || ".");
|
|
18
|
+
try {
|
|
19
|
+
const result = execSync(`find ${JSON.stringify(dir)} -name ${JSON.stringify(pattern)} -not -path '*/node_modules/*' -not -path '*/.git/*' 2>/dev/null | head -50`, {
|
|
20
|
+
encoding: "utf-8",
|
|
21
|
+
timeout: 10_000,
|
|
22
|
+
});
|
|
23
|
+
const files = result.trim().split("\n").filter(Boolean);
|
|
24
|
+
return { pattern, searchPath: dir, matches: files, count: files.length };
|
|
25
|
+
} catch {
|
|
26
|
+
return { pattern, searchPath: dir, matches: [], count: 0 };
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const searchContent = {
|
|
32
|
+
name: "search_content",
|
|
33
|
+
description: "Search for a text pattern inside files (like grep). Returns matching lines with file paths and line numbers.",
|
|
34
|
+
parameters: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
pattern: { type: "string", description: "Text or regex pattern to search for" },
|
|
38
|
+
path: { type: "string", description: "Directory to search in (defaults to current directory)" },
|
|
39
|
+
include: { type: "string", description: "File glob to filter (e.g., '*.js')" },
|
|
40
|
+
},
|
|
41
|
+
required: ["pattern"],
|
|
42
|
+
},
|
|
43
|
+
handler: async ({ pattern, path, include }) => {
|
|
44
|
+
const dir = resolve(path || ".");
|
|
45
|
+
try {
|
|
46
|
+
let cmd = `grep -rn --include='${include || "*"}' ${JSON.stringify(pattern)} ${JSON.stringify(dir)} 2>/dev/null | head -50`;
|
|
47
|
+
const result = execSync(cmd, { encoding: "utf-8", timeout: 10_000 });
|
|
48
|
+
const lines = result.trim().split("\n").filter(Boolean);
|
|
49
|
+
const matches = lines.map((line) => {
|
|
50
|
+
const match = line.match(/^(.+?):(\d+):(.*)$/);
|
|
51
|
+
if (match) return { file: match[1], line: parseInt(match[2]), content: match[3].trim() };
|
|
52
|
+
return { raw: line };
|
|
53
|
+
});
|
|
54
|
+
return { pattern, searchPath: dir, matches, count: matches.length };
|
|
55
|
+
} catch {
|
|
56
|
+
return { pattern, searchPath: dir, matches: [], count: 0 };
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const listDirectory = {
|
|
62
|
+
name: "list_directory",
|
|
63
|
+
description: "List files and directories at the given path. Shows names, types, and sizes.",
|
|
64
|
+
parameters: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
path: { type: "string", description: "Directory path to list (defaults to current directory)" },
|
|
68
|
+
},
|
|
69
|
+
required: [],
|
|
70
|
+
},
|
|
71
|
+
handler: async ({ path }) => {
|
|
72
|
+
const dir = resolve(path || ".");
|
|
73
|
+
if (!existsSync(dir)) {
|
|
74
|
+
return { error: `Directory not found: ${dir}` };
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const entries = readdirSync(dir).map((name) => {
|
|
78
|
+
try {
|
|
79
|
+
const fullPath = join(dir, name);
|
|
80
|
+
const stat = statSync(fullPath);
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
type: stat.isDirectory() ? "directory" : "file",
|
|
84
|
+
size: stat.isDirectory() ? undefined : stat.size,
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
return { name, type: "unknown" };
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return { path: dir, entries, count: entries.length };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return { error: err.message };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
package/src/repl.js
CHANGED
|
@@ -54,6 +54,11 @@ function showHelp() {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function dispatch(input) {
|
|
57
|
+
// Strip "cheri " prefix if user types it inside the REPL
|
|
58
|
+
if (input.startsWith("cheri ")) {
|
|
59
|
+
input = input.slice(6);
|
|
60
|
+
}
|
|
61
|
+
|
|
57
62
|
const parts = input.split(/\s+/);
|
|
58
63
|
const cmd = parts[0];
|
|
59
64
|
const sub = parts[1];
|
|
@@ -179,9 +184,16 @@ async function dispatch(input) {
|
|
|
179
184
|
return;
|
|
180
185
|
}
|
|
181
186
|
|
|
182
|
-
default:
|
|
187
|
+
default: {
|
|
183
188
|
// Treat any unrecognized input as an agent request
|
|
189
|
+
const { getConfigValue } = await import("./lib/config-store.js");
|
|
190
|
+
const token = getConfigValue("token");
|
|
191
|
+
if (!token) {
|
|
192
|
+
log.warn("Not logged in. Type " + chalk.cyan("login") + " first to authenticate.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
184
195
|
await runAgent(input);
|
|
196
|
+
}
|
|
185
197
|
}
|
|
186
198
|
}
|
|
187
199
|
|