@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.
@@ -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
+ }
@@ -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
- export function getConfig() {
15
- ensureConfigDir();
16
- if (!existsSync(CONFIG_FILE)) {
17
- return getDefaultConfig();
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(CONFIG_FILE, "utf-8"));
80
+ return JSON.parse(readFileSync(path, "utf-8"));
21
81
  } catch {
22
- return getDefaultConfig();
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
- const config = getConfig();
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 = config;
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(config);
165
+ setConfig(merged);
48
166
  }
49
167
 
50
- function getDefaultConfig() {
51
- return {
52
- apiUrl: "https://cheri.heysalad.app",
53
- token: "",
54
- workspace: {
55
- defaultResources: {
56
- cpu: 2,
57
- ram: "4GB",
58
- disk: "10GB",
59
- },
60
- idleTimeout: 600,
61
- },
62
- sync: {
63
- ignore: [
64
- "node_modules",
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
  }
@@ -1,56 +1,122 @@
1
- // Simple token estimation (~4 chars per token for English)
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
- return Math.ceil((text || "").length / 4);
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
- 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);
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
- // 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 };
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 compacted = [];
19
- const systemMsg = messages.find((m) => m.role === "system");
20
- if (systemMsg) compacted.push(systemMsg);
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
- // 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
- });
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
- summary += `Tool calls made: ${toolResults.length}\n`;
92
+ const summaryText = summaries.length > 0
93
+ ? `## Previous conversation summary (${summaries.length} turns):\n${summaries.join("\n---\n")}`
94
+ : "";
44
95
 
45
- compacted.push({ role: "user", content: summary });
46
- compacted.push({ role: "assistant", content: "Understood. I have context from the previous conversation. Let's continue." });
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
- // Check if we should auto-compact
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
+ }