@heysalad/cheri-cli 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cheri.js +7 -1
- package/package.json +4 -1
- package/src/commands/agent.js +317 -85
- package/src/lib/approval.js +120 -0
- package/src/lib/command-safety.js +170 -0
- package/src/lib/config-store.js +142 -37
- package/src/lib/context.js +103 -37
- package/src/lib/diff-tracker.js +157 -0
- package/src/lib/logger.js +15 -1
- package/src/lib/markdown.js +62 -0
- package/src/lib/mcp/client.js +239 -0
- package/src/lib/multi-agent.js +153 -0
- package/src/lib/providers/index.js +285 -0
- package/src/lib/sandbox.js +164 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Enhanced approval modes
|
|
2
|
+
// Modes: auto, suggest, ask, deny
|
|
3
|
+
// + command safety integration + network awareness
|
|
4
|
+
import readline from "readline";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { classifyCommand, getSafetyLabel } from "./command-safety.js";
|
|
7
|
+
import { getConfigValue } from "./config-store.js";
|
|
8
|
+
|
|
9
|
+
// Approval modes
|
|
10
|
+
export const ApprovalMode = {
|
|
11
|
+
AUTO: "auto", // Auto-approve everything (trust model)
|
|
12
|
+
SUGGEST: "suggest", // Auto-approve safe, suggest for moderate, ask for dangerous
|
|
13
|
+
ASK: "ask", // Ask for everything except read-only tools (default)
|
|
14
|
+
DENY: "deny", // Deny all tool execution
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Read-only tools that never need approval
|
|
18
|
+
const READ_ONLY_TOOLS = new Set([
|
|
19
|
+
"read_file", "list_directory", "search_files", "search_content",
|
|
20
|
+
"get_account_info", "list_workspaces", "get_memory", "get_usage",
|
|
21
|
+
"get_config", "get_workspace_status",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Tools that always need approval regardless of mode
|
|
25
|
+
const ALWAYS_ASK_TOOLS = new Set([
|
|
26
|
+
"clear_memory", "stop_workspace",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the current approval mode from config
|
|
31
|
+
*/
|
|
32
|
+
export function getApprovalMode() {
|
|
33
|
+
return getConfigValue("approval.mode") || ApprovalMode.ASK;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Determine if a tool call needs user approval
|
|
38
|
+
* @returns "allow" | "ask" | "deny" | "suggest"
|
|
39
|
+
*/
|
|
40
|
+
export function shouldApprove(toolName, input = {}) {
|
|
41
|
+
const mode = getApprovalMode();
|
|
42
|
+
|
|
43
|
+
// Deny mode blocks everything
|
|
44
|
+
if (mode === ApprovalMode.DENY) return "deny";
|
|
45
|
+
|
|
46
|
+
// Always-ask tools
|
|
47
|
+
if (ALWAYS_ASK_TOOLS.has(toolName)) return "ask";
|
|
48
|
+
|
|
49
|
+
// Read-only tools are always allowed
|
|
50
|
+
if (READ_ONLY_TOOLS.has(toolName)) return "allow";
|
|
51
|
+
|
|
52
|
+
// Auto mode approves everything
|
|
53
|
+
if (mode === ApprovalMode.AUTO) return "allow";
|
|
54
|
+
|
|
55
|
+
// For run_command, check command safety
|
|
56
|
+
if (toolName === "run_command" && input.command) {
|
|
57
|
+
const safety = classifyCommand(input.command);
|
|
58
|
+
|
|
59
|
+
if (mode === ApprovalMode.SUGGEST) {
|
|
60
|
+
if (safety === "safe") return "allow";
|
|
61
|
+
if (safety === "moderate") return "suggest";
|
|
62
|
+
return "ask";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ASK mode
|
|
66
|
+
if (safety === "safe") return "allow";
|
|
67
|
+
return "ask";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Write operations
|
|
71
|
+
if (toolName === "write_file" || toolName === "edit_file") {
|
|
72
|
+
if (mode === ApprovalMode.AUTO) return "allow";
|
|
73
|
+
if (mode === ApprovalMode.SUGGEST) return "suggest";
|
|
74
|
+
return "ask";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Default for ASK mode
|
|
78
|
+
return mode === ApprovalMode.SUGGEST ? "suggest" : "ask";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Prompt user for approval with safety context
|
|
83
|
+
*/
|
|
84
|
+
export async function promptApproval(toolName, input = {}, decision = "ask") {
|
|
85
|
+
const desc = toolName === "run_command"
|
|
86
|
+
? input.command
|
|
87
|
+
: JSON.stringify(input);
|
|
88
|
+
|
|
89
|
+
const truncated = desc.length > 80 ? desc.slice(0, 80) + "..." : desc;
|
|
90
|
+
|
|
91
|
+
// For "suggest" mode, show the action and auto-approve after a brief display
|
|
92
|
+
if (decision === "suggest") {
|
|
93
|
+
const safety = toolName === "run_command" ? getSafetyLabel(input.command) : null;
|
|
94
|
+
const safetyStr = safety ? chalk[safety.color](`[${safety.label}]`) + " " : "";
|
|
95
|
+
console.log(chalk.yellow(` → ${safetyStr}${chalk.cyan(toolName)}: ${chalk.dim(truncated)}`));
|
|
96
|
+
// Auto-approve suggest items (user can Ctrl+C to abort)
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Full approval prompt
|
|
101
|
+
const safety = toolName === "run_command" ? getSafetyLabel(input.command) : null;
|
|
102
|
+
const safetyStr = safety ? ` ${chalk[safety.color](`[${safety.label}]`)}` : "";
|
|
103
|
+
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
106
|
+
rl.question(
|
|
107
|
+
chalk.yellow(` Allow ${chalk.cyan(toolName)}${safetyStr}: ${chalk.dim(truncated)}? [Y/n/a] `),
|
|
108
|
+
(answer) => {
|
|
109
|
+
rl.close();
|
|
110
|
+
const a = answer.trim().toLowerCase();
|
|
111
|
+
if (a === "a") {
|
|
112
|
+
// "a" = always allow this session (switch to auto mode temporarily)
|
|
113
|
+
resolve("auto");
|
|
114
|
+
} else {
|
|
115
|
+
resolve(a !== "n");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Command safety database
|
|
2
|
+
// Categorizes shell commands as safe, moderate, or dangerous
|
|
3
|
+
|
|
4
|
+
// Safe commands: read-only, no side effects
|
|
5
|
+
const SAFE_COMMANDS = new Set([
|
|
6
|
+
"ls", "cat", "head", "tail", "less", "more", "wc", "file", "stat",
|
|
7
|
+
"find", "locate", "which", "whereis", "type", "whatis",
|
|
8
|
+
"grep", "rg", "ag", "ack", "fgrep", "egrep",
|
|
9
|
+
"pwd", "echo", "printf", "date", "cal", "uptime", "whoami", "id",
|
|
10
|
+
"uname", "hostname", "arch", "lsb_release",
|
|
11
|
+
"env", "printenv", "set",
|
|
12
|
+
"df", "du", "free", "top", "htop", "ps", "pgrep",
|
|
13
|
+
"tree", "realpath", "basename", "dirname", "readlink",
|
|
14
|
+
"diff", "cmp", "comm", "sort", "uniq", "cut", "tr", "sed", "awk",
|
|
15
|
+
"jq", "yq", "xmllint",
|
|
16
|
+
"git status", "git log", "git diff", "git show", "git branch",
|
|
17
|
+
"git tag", "git remote", "git stash list", "git blame",
|
|
18
|
+
"node --version", "npm --version", "python --version", "python3 --version",
|
|
19
|
+
"ruby --version", "go version", "rustc --version", "cargo --version",
|
|
20
|
+
"java -version", "javac -version", "dotnet --version",
|
|
21
|
+
"npm list", "npm ls", "npm outdated", "npm view", "npm info",
|
|
22
|
+
"pip list", "pip show", "pip freeze",
|
|
23
|
+
"cargo tree", "cargo metadata",
|
|
24
|
+
"docker ps", "docker images", "docker info",
|
|
25
|
+
"kubectl get", "kubectl describe", "kubectl logs",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Dangerous commands: destructive or high-risk
|
|
29
|
+
const DANGEROUS_PATTERNS = [
|
|
30
|
+
// System destruction
|
|
31
|
+
/^rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|.*-rf|.*--no-preserve-root)/,
|
|
32
|
+
/^rm\s+-[a-zA-Z]*f/,
|
|
33
|
+
/^rmdir\s/,
|
|
34
|
+
/^mkfs\b/,
|
|
35
|
+
/^dd\s/,
|
|
36
|
+
/^format\b/,
|
|
37
|
+
/^fdisk\b/,
|
|
38
|
+
/^parted\b/,
|
|
39
|
+
|
|
40
|
+
// System modification
|
|
41
|
+
/^chmod\s.*777/,
|
|
42
|
+
/^chown\s/,
|
|
43
|
+
/^chgrp\s/,
|
|
44
|
+
/^mount\b/,
|
|
45
|
+
/^umount\b/,
|
|
46
|
+
|
|
47
|
+
// Service/process control
|
|
48
|
+
/^kill\s/,
|
|
49
|
+
/^killall\s/,
|
|
50
|
+
/^pkill\s/,
|
|
51
|
+
/^shutdown\b/,
|
|
52
|
+
/^reboot\b/,
|
|
53
|
+
/^halt\b/,
|
|
54
|
+
/^poweroff\b/,
|
|
55
|
+
/^systemctl\s+(stop|disable|mask|restart)/,
|
|
56
|
+
/^service\s+\w+\s+(stop|restart)/,
|
|
57
|
+
|
|
58
|
+
// Network
|
|
59
|
+
/^iptables\b/,
|
|
60
|
+
/^ip6tables\b/,
|
|
61
|
+
/^ufw\s/,
|
|
62
|
+
/^firewall-cmd\b/,
|
|
63
|
+
/^nmap\b/,
|
|
64
|
+
|
|
65
|
+
// Package management (install can be dangerous)
|
|
66
|
+
/^apt(-get)?\s+(remove|purge|autoremove)/,
|
|
67
|
+
/^yum\s+(remove|erase)/,
|
|
68
|
+
/^dnf\s+(remove|erase)/,
|
|
69
|
+
/^pacman\s+-R/,
|
|
70
|
+
/^brew\s+uninstall/,
|
|
71
|
+
|
|
72
|
+
// Git destructive
|
|
73
|
+
/^git\s+(push\s+.*--force|reset\s+--hard|clean\s+-[a-zA-Z]*f|checkout\s+\.)/,
|
|
74
|
+
/^git\s+branch\s+-[dD]/,
|
|
75
|
+
|
|
76
|
+
// Database
|
|
77
|
+
/^drop\s+(database|table|index)/i,
|
|
78
|
+
/^truncate\s+table/i,
|
|
79
|
+
/^delete\s+from/i,
|
|
80
|
+
/^mysql\b.*-e/,
|
|
81
|
+
/^psql\b.*-c/,
|
|
82
|
+
|
|
83
|
+
// Credential/secret access
|
|
84
|
+
/^cat\s+.*\.(env|pem|key|crt|p12)/,
|
|
85
|
+
/^curl\s+.*(-u|--user)/,
|
|
86
|
+
/^wget\s+.*--password/,
|
|
87
|
+
|
|
88
|
+
// Shell escape / eval
|
|
89
|
+
/^eval\s/,
|
|
90
|
+
/^exec\s/,
|
|
91
|
+
/\$\(.*\)/, // command substitution in arguments
|
|
92
|
+
/\|\s*(sh|bash|zsh|dash)\b/, // piping to shell
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// Moderate commands: have side effects but generally safe with review
|
|
96
|
+
const MODERATE_PATTERNS = [
|
|
97
|
+
/^npm\s+(install|i|ci|run|test|build|start)\b/,
|
|
98
|
+
/^npx\s/,
|
|
99
|
+
/^yarn\s/,
|
|
100
|
+
/^pnpm\s/,
|
|
101
|
+
/^pip\s+install/,
|
|
102
|
+
/^pip3\s+install/,
|
|
103
|
+
/^python[23]?\s/,
|
|
104
|
+
/^node\s/,
|
|
105
|
+
/^cargo\s+(build|run|test)/,
|
|
106
|
+
/^go\s+(build|run|test)/,
|
|
107
|
+
/^make\b/,
|
|
108
|
+
/^cmake\b/,
|
|
109
|
+
/^gcc\b/,
|
|
110
|
+
/^g\+\+\b/,
|
|
111
|
+
/^clang\b/,
|
|
112
|
+
/^rustc\b/,
|
|
113
|
+
/^javac\b/,
|
|
114
|
+
/^mvn\b/,
|
|
115
|
+
/^gradle\b/,
|
|
116
|
+
/^docker\s+(build|run|exec|compose)/,
|
|
117
|
+
/^kubectl\s+(apply|create|delete)/,
|
|
118
|
+
/^git\s+(add|commit|push|pull|merge|rebase|stash|fetch)/,
|
|
119
|
+
/^mkdir\b/,
|
|
120
|
+
/^touch\b/,
|
|
121
|
+
/^cp\b/,
|
|
122
|
+
/^mv\b/,
|
|
123
|
+
/^ln\b/,
|
|
124
|
+
/^curl\b/,
|
|
125
|
+
/^wget\b/,
|
|
126
|
+
/^ssh\b/,
|
|
127
|
+
/^scp\b/,
|
|
128
|
+
/^rsync\b/,
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Classify a command's safety level
|
|
133
|
+
* @returns "safe" | "moderate" | "dangerous"
|
|
134
|
+
*/
|
|
135
|
+
export function classifyCommand(command) {
|
|
136
|
+
const trimmed = command.trim();
|
|
137
|
+
|
|
138
|
+
// Check exact safe commands first
|
|
139
|
+
if (SAFE_COMMANDS.has(trimmed)) return "safe";
|
|
140
|
+
|
|
141
|
+
// Check if it starts with a safe command (with arguments)
|
|
142
|
+
for (const safe of SAFE_COMMANDS) {
|
|
143
|
+
if (trimmed.startsWith(safe + " ") || trimmed === safe) return "safe";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check dangerous patterns
|
|
147
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
148
|
+
if (pattern.test(trimmed)) return "dangerous";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check moderate patterns
|
|
152
|
+
for (const pattern of MODERATE_PATTERNS) {
|
|
153
|
+
if (pattern.test(trimmed)) return "moderate";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Default: moderate (unknown commands need review)
|
|
157
|
+
return "moderate";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get a human-readable safety label with color hint
|
|
162
|
+
*/
|
|
163
|
+
export function getSafetyLabel(command) {
|
|
164
|
+
const level = classifyCommand(command);
|
|
165
|
+
switch (level) {
|
|
166
|
+
case "safe": return { level, label: "safe", color: "green" };
|
|
167
|
+
case "moderate": return { level, label: "needs review", color: "yellow" };
|
|
168
|
+
case "dangerous": return { level, label: "dangerous", color: "red" };
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/lib/config-store.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
// Multi-layer configuration system
|
|
2
|
+
// Layers (lowest to highest priority):
|
|
3
|
+
// 1. Defaults (built-in)
|
|
4
|
+
// 2. System config (~/.cheri/config.json)
|
|
5
|
+
// 3. Project config (.cheri/config.json)
|
|
6
|
+
// 4. Environment variables (CHERI_* prefix)
|
|
1
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
8
|
import { homedir } from "os";
|
|
3
9
|
import { join } from "path";
|
|
4
10
|
|
|
5
11
|
const CONFIG_DIR = join(homedir(), ".cheri");
|
|
6
12
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
13
|
+
const PROJECT_CONFIG_FILE = ".cheri/config.json";
|
|
14
|
+
|
|
15
|
+
// Environment variable mapping: CHERI_AI_PROVIDER → ai.provider
|
|
16
|
+
const ENV_PREFIX = "CHERI_";
|
|
7
17
|
|
|
8
18
|
function ensureConfigDir() {
|
|
9
19
|
if (!existsSync(CONFIG_DIR)) {
|
|
@@ -11,32 +21,140 @@ function ensureConfigDir() {
|
|
|
11
21
|
}
|
|
12
22
|
}
|
|
13
23
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
function getDefaultConfig() {
|
|
25
|
+
return {
|
|
26
|
+
apiUrl: "https://cheri.heysalad.app",
|
|
27
|
+
token: "",
|
|
28
|
+
ai: {
|
|
29
|
+
provider: "cheri", // cheri, openai, anthropic, deepseek, groq, ollama, etc.
|
|
30
|
+
model: "", // override default model per provider
|
|
31
|
+
keys: {}, // { openai: "sk-...", anthropic: "sk-ant-...", ... }
|
|
32
|
+
providers: {}, // custom provider configs: { myapi: { baseUrl, model } }
|
|
33
|
+
reasoning: {
|
|
34
|
+
effort: "medium", // low, medium, high
|
|
35
|
+
showSummary: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
approval: {
|
|
39
|
+
mode: "ask", // auto, suggest, ask, deny
|
|
40
|
+
},
|
|
41
|
+
sandbox: {
|
|
42
|
+
level: "basic", // none, basic, strict
|
|
43
|
+
allowNetwork: false,
|
|
44
|
+
},
|
|
45
|
+
agent: {
|
|
46
|
+
maxIterations: 15,
|
|
47
|
+
provider: "", // override ai.provider for agent specifically
|
|
48
|
+
model: "", // override ai.model for agent specifically
|
|
49
|
+
},
|
|
50
|
+
mcp: {
|
|
51
|
+
servers: {}, // { name: { command, args, env } }
|
|
52
|
+
},
|
|
53
|
+
workspace: {
|
|
54
|
+
defaultResources: { cpu: 2, ram: "4GB", disk: "10GB" },
|
|
55
|
+
idleTimeout: 600,
|
|
56
|
+
},
|
|
57
|
+
sync: {
|
|
58
|
+
ignore: ["node_modules", ".git", ".next", "dist", ".env", ".env.local"],
|
|
59
|
+
debounce: 300,
|
|
60
|
+
},
|
|
61
|
+
editor: { theme: "dark", fontSize: 14 },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function deepMerge(target, source) {
|
|
66
|
+
const output = { ...target };
|
|
67
|
+
for (const key in source) {
|
|
68
|
+
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
|
|
69
|
+
output[key] = deepMerge(output[key] || {}, source[key]);
|
|
70
|
+
} else {
|
|
71
|
+
output[key] = source[key];
|
|
72
|
+
}
|
|
18
73
|
}
|
|
74
|
+
return output;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadJsonFile(path) {
|
|
78
|
+
if (!existsSync(path)) return {};
|
|
19
79
|
try {
|
|
20
|
-
return JSON.parse(readFileSync(
|
|
80
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
21
81
|
} catch {
|
|
22
|
-
return
|
|
82
|
+
return {};
|
|
23
83
|
}
|
|
24
84
|
}
|
|
25
85
|
|
|
86
|
+
function getEnvOverrides() {
|
|
87
|
+
const overrides = {};
|
|
88
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
89
|
+
if (key.startsWith(ENV_PREFIX) && key !== "CHERI_SANDBOXED" && key !== "CHERI_SANDBOX_LEVEL") {
|
|
90
|
+
// Convert CHERI_AI_PROVIDER → ai.provider
|
|
91
|
+
const configKey = key.slice(ENV_PREFIX.length).toLowerCase().replace(/_/g, ".");
|
|
92
|
+
setNestedValue(overrides, configKey, value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return overrides;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setNestedValue(obj, dotKey, value) {
|
|
99
|
+
const keys = dotKey.split(".");
|
|
100
|
+
let current = obj;
|
|
101
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
102
|
+
if (!current[keys[i]] || typeof current[keys[i]] !== "object") {
|
|
103
|
+
current[keys[i]] = {};
|
|
104
|
+
}
|
|
105
|
+
current = current[keys[i]];
|
|
106
|
+
}
|
|
107
|
+
current[keys[keys.length - 1]] = value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get merged config from all layers
|
|
112
|
+
*/
|
|
113
|
+
export function getConfig() {
|
|
114
|
+
ensureConfigDir();
|
|
115
|
+
|
|
116
|
+
let config = getDefaultConfig();
|
|
117
|
+
|
|
118
|
+
// Layer 2: User config
|
|
119
|
+
config = deepMerge(config, loadJsonFile(CONFIG_FILE));
|
|
120
|
+
|
|
121
|
+
// Layer 3: Project config
|
|
122
|
+
const projectConfig = join(process.cwd(), PROJECT_CONFIG_FILE);
|
|
123
|
+
config = deepMerge(config, loadJsonFile(projectConfig));
|
|
124
|
+
|
|
125
|
+
// Layer 4: Environment variables
|
|
126
|
+
config = deepMerge(config, getEnvOverrides());
|
|
127
|
+
|
|
128
|
+
return config;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Save to user config file
|
|
133
|
+
*/
|
|
26
134
|
export function setConfig(config) {
|
|
27
135
|
ensureConfigDir();
|
|
28
136
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
29
137
|
}
|
|
30
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Get a config value by dot-notation key
|
|
141
|
+
*/
|
|
31
142
|
export function getConfigValue(key) {
|
|
32
143
|
const config = getConfig();
|
|
33
144
|
return key.split(".").reduce((obj, k) => obj?.[k], config);
|
|
34
145
|
}
|
|
35
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Set a config value by dot-notation key (saves to user config)
|
|
149
|
+
*/
|
|
36
150
|
export function setConfigValue(key, value) {
|
|
37
|
-
|
|
151
|
+
ensureConfigDir();
|
|
152
|
+
// Only load user config (not merged) to avoid persisting project/env overrides
|
|
153
|
+
const userConfig = loadJsonFile(CONFIG_FILE);
|
|
154
|
+
const merged = deepMerge(getDefaultConfig(), userConfig);
|
|
155
|
+
|
|
38
156
|
const keys = key.split(".");
|
|
39
|
-
let current =
|
|
157
|
+
let current = merged;
|
|
40
158
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
41
159
|
if (!current[keys[i]] || typeof current[keys[i]] !== "object") {
|
|
42
160
|
current[keys[i]] = {};
|
|
@@ -44,35 +162,22 @@ export function setConfigValue(key, value) {
|
|
|
44
162
|
current = current[keys[i]];
|
|
45
163
|
}
|
|
46
164
|
current[keys[keys.length - 1]] = value;
|
|
47
|
-
setConfig(
|
|
165
|
+
setConfig(merged);
|
|
48
166
|
}
|
|
49
167
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
".git",
|
|
66
|
-
".next",
|
|
67
|
-
"dist",
|
|
68
|
-
".env",
|
|
69
|
-
".env.local",
|
|
70
|
-
],
|
|
71
|
-
debounce: 300,
|
|
72
|
-
},
|
|
73
|
-
editor: {
|
|
74
|
-
theme: "dark",
|
|
75
|
-
fontSize: 14,
|
|
76
|
-
},
|
|
77
|
-
};
|
|
168
|
+
/**
|
|
169
|
+
* List all config as flat key-value pairs
|
|
170
|
+
*/
|
|
171
|
+
export function flattenConfig(obj = null, prefix = "") {
|
|
172
|
+
obj = obj || getConfig();
|
|
173
|
+
const result = {};
|
|
174
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
175
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
176
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
177
|
+
Object.assign(result, flattenConfig(value, fullKey));
|
|
178
|
+
} else {
|
|
179
|
+
result[fullKey] = value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
78
183
|
}
|
package/src/lib/context.js
CHANGED
|
@@ -1,56 +1,122 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Enhanced context management with better token estimation and smart compaction
|
|
2
|
+
|
|
3
|
+
// Better token estimation — uses word-based heuristic closer to actual BPE
|
|
2
4
|
function estimateTokens(text) {
|
|
3
|
-
|
|
5
|
+
if (!text) return 0;
|
|
6
|
+
const str = typeof text === "string" ? text : JSON.stringify(text);
|
|
7
|
+
// Approx: blend word count and char count for better estimate
|
|
8
|
+
const words = str.split(/\s+/).length;
|
|
9
|
+
const chars = str.length;
|
|
10
|
+
return Math.ceil((words * 1.3 + chars / 4) / 2);
|
|
4
11
|
}
|
|
5
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Estimate total tokens for a message array
|
|
15
|
+
*/
|
|
6
16
|
export function estimateMessagesTokens(messages) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const msg of messages) {
|
|
19
|
+
total += 4; // message overhead
|
|
20
|
+
if (typeof msg.content === "string") {
|
|
21
|
+
total += estimateTokens(msg.content);
|
|
22
|
+
} else if (msg.content) {
|
|
23
|
+
total += estimateTokens(JSON.stringify(msg.content));
|
|
24
|
+
}
|
|
25
|
+
if (msg.tool_calls) {
|
|
26
|
+
total += estimateTokens(JSON.stringify(msg.tool_calls));
|
|
27
|
+
}
|
|
28
|
+
if (msg.role) total += 1;
|
|
29
|
+
}
|
|
30
|
+
return total;
|
|
11
31
|
}
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Smart compaction that preserves context quality
|
|
35
|
+
*
|
|
36
|
+
* Strategy:
|
|
37
|
+
* 1. Always keep system message
|
|
38
|
+
* 2. Parse messages into turns (user → assistant → tool results)
|
|
39
|
+
* 3. Summarize old turns, keep recent turns in full
|
|
40
|
+
* 4. Preserve turns that had errors (learning context)
|
|
41
|
+
*/
|
|
42
|
+
export function compactMessages(messages, targetTokens = 60000) {
|
|
43
|
+
if (messages.length === 0) return { messages, compacted: false };
|
|
17
44
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
45
|
+
const current = estimateMessagesTokens(messages);
|
|
46
|
+
if (current <= targetTokens) return { messages, compacted: false };
|
|
47
|
+
|
|
48
|
+
const systemMsg = messages.find(m => m.role === "system");
|
|
49
|
+
const nonSystem = messages.filter(m => m.role !== "system");
|
|
50
|
+
|
|
51
|
+
// Parse into turns
|
|
52
|
+
const turns = [];
|
|
53
|
+
let currentTurn = [];
|
|
54
|
+
for (const msg of nonSystem) {
|
|
55
|
+
if (msg.role === "user" && currentTurn.length > 0) {
|
|
56
|
+
turns.push(currentTurn);
|
|
57
|
+
currentTurn = [];
|
|
58
|
+
}
|
|
59
|
+
currentTurn.push(msg);
|
|
60
|
+
}
|
|
61
|
+
if (currentTurn.length > 0) turns.push(currentTurn);
|
|
62
|
+
|
|
63
|
+
// Keep recent turns fully (last 5 or 40%)
|
|
64
|
+
const recentCount = Math.min(5, Math.max(2, Math.floor(turns.length * 0.4)));
|
|
65
|
+
const oldTurns = turns.slice(0, -recentCount);
|
|
66
|
+
const recentTurns = turns.slice(-recentCount);
|
|
67
|
+
|
|
68
|
+
// Summarize old turns
|
|
69
|
+
const summaries = [];
|
|
70
|
+
for (const turn of oldTurns) {
|
|
71
|
+
const userMsg = turn.find(m => m.role === "user");
|
|
72
|
+
const assistantMsg = turn.find(m => m.role === "assistant");
|
|
73
|
+
const toolMsgs = turn.filter(m => m.role === "tool");
|
|
74
|
+
const hadError = toolMsgs.some(m => {
|
|
75
|
+
try { return JSON.parse(m.content)?.error; } catch { return false; }
|
|
76
|
+
});
|
|
21
77
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
});
|
|
78
|
+
let summary = "";
|
|
79
|
+
if (userMsg) {
|
|
80
|
+
const text = typeof userMsg.content === "string" ? userMsg.content : "...";
|
|
81
|
+
summary += `User: ${text.slice(0, 100)}\n`;
|
|
41
82
|
}
|
|
83
|
+
if (assistantMsg?.tool_calls) {
|
|
84
|
+
const tools = assistantMsg.tool_calls.map(tc => tc.function.name).join(", ");
|
|
85
|
+
summary += `Tools: ${tools}\n`;
|
|
86
|
+
}
|
|
87
|
+
if (assistantMsg?.content) summary += `Response: ${assistantMsg.content.slice(0, 150)}\n`;
|
|
88
|
+
if (hadError) summary += `[Had errors]\n`;
|
|
89
|
+
summaries.push(summary);
|
|
90
|
+
}
|
|
42
91
|
|
|
43
|
-
|
|
92
|
+
const summaryText = summaries.length > 0
|
|
93
|
+
? `## Previous conversation summary (${summaries.length} turns):\n${summaries.join("\n---\n")}`
|
|
94
|
+
: "";
|
|
44
95
|
|
|
45
|
-
|
|
46
|
-
|
|
96
|
+
const compacted = [];
|
|
97
|
+
if (systemMsg) compacted.push(systemMsg);
|
|
98
|
+
if (summaryText) {
|
|
99
|
+
compacted.push({ role: "user", content: summaryText });
|
|
100
|
+
compacted.push({ role: "assistant", content: "Understood. I have context from our previous conversation. How can I continue helping?" });
|
|
47
101
|
}
|
|
102
|
+
for (const turn of recentTurns) compacted.push(...turn);
|
|
48
103
|
|
|
49
|
-
compacted.push(...recent);
|
|
50
104
|
return { messages: compacted, compacted: true };
|
|
51
105
|
}
|
|
52
106
|
|
|
53
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Check if compaction is needed
|
|
109
|
+
*/
|
|
54
110
|
export function shouldCompact(messages, threshold = 80000) {
|
|
55
111
|
return estimateMessagesTokens(messages) > threshold;
|
|
56
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get token usage stats
|
|
116
|
+
*/
|
|
117
|
+
export function getContextStats(messages) {
|
|
118
|
+
const tokens = estimateMessagesTokens(messages);
|
|
119
|
+
const turns = messages.filter(m => m.role === "user").length;
|
|
120
|
+
const toolCalls = messages.filter(m => m.role === "tool").length;
|
|
121
|
+
return { tokens, turns, toolCalls, messageCount: messages.length };
|
|
122
|
+
}
|