@heysalad/cheri-cli 0.8.0 → 0.10.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/bin/cheri.js +7 -1
- package/package.json +1 -1
- package/src/commands/agent.js +142 -46
- package/src/lib/context.js +56 -0
- package/src/lib/hooks/index.js +82 -0
- package/src/lib/logger.js +15 -1
- 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/repl.js +13 -1
package/bin/cheri.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { dirname, join } from "path";
|
|
3
6
|
import { program } from "commander";
|
|
4
7
|
import { registerInitCommand } from "../src/commands/init.js";
|
|
5
8
|
import { registerLoginCommand } from "../src/commands/login.js";
|
|
@@ -10,10 +13,13 @@ import { registerWorkspaceCommand } from "../src/commands/workspace.js";
|
|
|
10
13
|
import { registerUsageCommand } from "../src/commands/usage.js";
|
|
11
14
|
import { registerAgentCommand } from "../src/commands/agent.js";
|
|
12
15
|
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
18
|
+
|
|
13
19
|
program
|
|
14
20
|
.name("cheri")
|
|
15
21
|
.description("Cheri CLI - AI-powered cloud IDE by HeySalad")
|
|
16
|
-
.version(
|
|
22
|
+
.version(pkg.version);
|
|
17
23
|
|
|
18
24
|
registerLoginCommand(program);
|
|
19
25
|
registerInitCommand(program);
|
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { apiClient } from "../lib/api-client.js";
|
|
2
2
|
import { log } from "../lib/logger.js";
|
|
3
|
-
import { getToolDefinitions, executeTool as executeLocalTool, requiresConfirmation } from "../lib/tools/index.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";
|
|
4
10
|
import chalk from "chalk";
|
|
5
11
|
import readline from "readline";
|
|
6
12
|
|
|
@@ -20,7 +26,7 @@ Guidelines:
|
|
|
20
26
|
- Never guess file contents — always read first.
|
|
21
27
|
- Current working directory: ${process.cwd()}`;
|
|
22
28
|
|
|
23
|
-
// Cloud platform tools
|
|
29
|
+
// Cloud platform tools
|
|
24
30
|
const CLOUD_TOOLS = [
|
|
25
31
|
{ name: "get_account_info", description: "Get the current user's account information", parameters: { type: "object", properties: {}, required: [] } },
|
|
26
32
|
{ name: "list_workspaces", description: "List all cloud workspaces for the current user", parameters: { type: "object", properties: {}, required: [] } },
|
|
@@ -37,7 +43,6 @@ const CLOUD_TOOLS = [
|
|
|
37
43
|
|
|
38
44
|
const CLOUD_TOOL_NAMES = new Set(CLOUD_TOOLS.map((t) => t.name));
|
|
39
45
|
|
|
40
|
-
// Build unified tool list in OpenAI function-calling format
|
|
41
46
|
function buildTools() {
|
|
42
47
|
const localDefs = getToolDefinitions().map((t) => ({
|
|
43
48
|
type: "function",
|
|
@@ -50,7 +55,6 @@ function buildTools() {
|
|
|
50
55
|
return [...localDefs, ...cloudDefs];
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
// Execute a cloud platform tool via the API
|
|
54
58
|
async function executeCloudTool(name, args) {
|
|
55
59
|
try {
|
|
56
60
|
switch (name) {
|
|
@@ -79,36 +83,48 @@ async function executeCloudTool(name, args) {
|
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
// Ask user for confirmation before destructive operations
|
|
83
86
|
async function confirmAction(toolName, input) {
|
|
84
87
|
const desc = toolName === "run_command" ? input.command : JSON.stringify(input);
|
|
85
88
|
return new Promise((resolve) => {
|
|
86
89
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
87
|
-
rl.question(chalk.yellow(` Allow ${chalk.cyan(toolName)}: ${chalk.dim(desc)}? [Y/n] `), (answer) => {
|
|
90
|
+
rl.question(chalk.yellow(` Allow ${chalk.cyan(toolName)}: ${chalk.dim(truncate(desc, 60))}? [Y/n] `), (answer) => {
|
|
88
91
|
rl.close();
|
|
89
92
|
resolve(answer.trim().toLowerCase() !== "n");
|
|
90
93
|
});
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
// Unified tool executor
|
|
95
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
|
|
96
111
|
if (CLOUD_TOOL_NAMES.has(name)) {
|
|
97
|
-
|
|
112
|
+
const result = await executeCloudTool(name, args);
|
|
113
|
+
await runHooks("PostToolUse", { toolName: name, input: args, result });
|
|
114
|
+
return result;
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
// Local
|
|
101
|
-
if (
|
|
117
|
+
// Local tools — check if confirmation needed
|
|
118
|
+
if (permission === "ask" || (permission !== "allow" && toolRequiresConfirmation(name))) {
|
|
102
119
|
const allowed = await confirmAction(name, args);
|
|
103
|
-
if (!allowed) {
|
|
104
|
-
return { error: "User denied execution" };
|
|
105
|
-
}
|
|
120
|
+
if (!allowed) return { error: "User denied execution" };
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
|
|
123
|
+
const result = await executeLocalTool(name, args);
|
|
124
|
+
await runHooks("PostToolUse", { toolName: name, input: args, result });
|
|
125
|
+
return result;
|
|
109
126
|
}
|
|
110
127
|
|
|
111
|
-
// Parse SSE stream from the cloud proxy
|
|
112
128
|
async function* parseSSEStream(response) {
|
|
113
129
|
const reader = response.body.getReader();
|
|
114
130
|
const decoder = new TextDecoder();
|
|
@@ -127,9 +143,7 @@ async function* parseSSEStream(response) {
|
|
|
127
143
|
if (line.startsWith("data: ")) {
|
|
128
144
|
const data = line.slice(6).trim();
|
|
129
145
|
if (data === "[DONE]") return;
|
|
130
|
-
try {
|
|
131
|
-
yield JSON.parse(data);
|
|
132
|
-
} catch {}
|
|
146
|
+
try { yield JSON.parse(data); } catch {}
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
149
|
}
|
|
@@ -138,18 +152,108 @@ async function* parseSSEStream(response) {
|
|
|
138
152
|
}
|
|
139
153
|
}
|
|
140
154
|
|
|
141
|
-
|
|
142
|
-
|
|
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);
|
|
165
|
+
|
|
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
|
+
}
|
|
143
208
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|
|
148
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();
|
|
149
253
|
const MAX_ITERATIONS = 15;
|
|
150
254
|
|
|
151
255
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
152
|
-
const response = await apiClient.chatStream(messages, ALL_TOOLS);
|
|
256
|
+
const response = await apiClient.chatStream(currentSession.messages, ALL_TOOLS);
|
|
153
257
|
|
|
154
258
|
let fullText = "";
|
|
155
259
|
const toolCalls = {};
|
|
@@ -166,9 +270,7 @@ export async function runAgent(userRequest) {
|
|
|
166
270
|
if (delta?.tool_calls) {
|
|
167
271
|
for (const tc of delta.tool_calls) {
|
|
168
272
|
const idx = tc.index;
|
|
169
|
-
if (!toolCalls[idx]) {
|
|
170
|
-
toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
|
|
171
|
-
}
|
|
273
|
+
if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
|
|
172
274
|
if (tc.id) toolCalls[idx].id = tc.id;
|
|
173
275
|
if (tc.function?.name) toolCalls[idx].name = tc.function.name;
|
|
174
276
|
if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
|
|
@@ -180,24 +282,22 @@ export async function runAgent(userRequest) {
|
|
|
180
282
|
|
|
181
283
|
const toolCallList = Object.values(toolCalls);
|
|
182
284
|
|
|
183
|
-
// No tool calls — final text response, done
|
|
184
285
|
if (toolCallList.length === 0) {
|
|
185
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);
|
|
186
290
|
return;
|
|
187
291
|
}
|
|
188
292
|
|
|
189
293
|
if (fullText) process.stdout.write("\n");
|
|
190
294
|
|
|
191
|
-
// Build assistant message with tool calls
|
|
192
295
|
const assistantMsg = { role: "assistant", content: fullText || null };
|
|
193
296
|
assistantMsg.tool_calls = toolCallList.map((tc) => ({
|
|
194
|
-
id: tc.id,
|
|
195
|
-
type: "function",
|
|
196
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
297
|
+
id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
|
|
197
298
|
}));
|
|
198
|
-
messages.push(assistantMsg);
|
|
299
|
+
currentSession.messages.push(assistantMsg);
|
|
199
300
|
|
|
200
|
-
// Execute each tool and add results
|
|
201
301
|
for (const tc of toolCallList) {
|
|
202
302
|
let input = {};
|
|
203
303
|
try { input = JSON.parse(tc.arguments); } catch {}
|
|
@@ -214,19 +314,18 @@ export async function runAgent(userRequest) {
|
|
|
214
314
|
log.success(tc.name);
|
|
215
315
|
}
|
|
216
316
|
|
|
217
|
-
// Truncate large tool results to avoid blowing context
|
|
218
317
|
const resultStr = JSON.stringify(result);
|
|
219
318
|
const truncatedResult = resultStr.length > 8000 ? resultStr.slice(0, 8000) + "...(truncated)" : resultStr;
|
|
220
319
|
|
|
221
|
-
messages.push({
|
|
222
|
-
role: "tool",
|
|
223
|
-
tool_call_id: tc.id,
|
|
224
|
-
content: truncatedResult,
|
|
225
|
-
});
|
|
320
|
+
currentSession.messages.push({ role: "tool", tool_call_id: tc.id, content: truncatedResult });
|
|
226
321
|
}
|
|
322
|
+
|
|
323
|
+
// Save session after each tool round
|
|
324
|
+
saveSession(currentSession.id, currentSession);
|
|
227
325
|
}
|
|
228
326
|
|
|
229
327
|
log.warn("Agent reached maximum iterations (15). Stopping.");
|
|
328
|
+
await runHooks("Stop", { reason: "max_iterations" });
|
|
230
329
|
}
|
|
231
330
|
|
|
232
331
|
function truncate(str, max) {
|
|
@@ -240,10 +339,7 @@ export function registerAgentCommand(program) {
|
|
|
240
339
|
.description("AI coding agent — natural language command interface")
|
|
241
340
|
.action(async (requestParts) => {
|
|
242
341
|
const request = requestParts.join(" ");
|
|
243
|
-
try {
|
|
244
|
-
|
|
245
|
-
} catch (err) {
|
|
246
|
-
log.error(err.message);
|
|
247
|
-
}
|
|
342
|
+
try { await runAgent(request); }
|
|
343
|
+
catch (err) { log.error(err.message); }
|
|
248
344
|
});
|
|
249
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
|
+
}
|
package/src/lib/logger.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
let _version;
|
|
8
|
+
function getVersion() {
|
|
9
|
+
if (!_version) {
|
|
10
|
+
try { _version = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")).version; }
|
|
11
|
+
catch { _version = "0.0.0"; }
|
|
12
|
+
}
|
|
13
|
+
return _version;
|
|
14
|
+
}
|
|
2
15
|
|
|
3
16
|
export const log = {
|
|
4
17
|
info(msg) {
|
|
@@ -36,7 +49,8 @@ export const log = {
|
|
|
36
49
|
console.log(chalk.dim(prefix) + " " + item);
|
|
37
50
|
});
|
|
38
51
|
},
|
|
39
|
-
banner(version
|
|
52
|
+
banner(version) {
|
|
53
|
+
if (!version) version = getVersion();
|
|
40
54
|
console.log();
|
|
41
55
|
console.log(` ${chalk.red("🍒")} ${chalk.red.bold("Cheri")}`);
|
|
42
56
|
console.log(` ${chalk.dim("AI-powered cloud IDE by HeySalad")}`);
|
|
@@ -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
|
+
}
|
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
|
|