codemini-cli 0.3.9 → 0.4.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,148 @@
1
+ import { collectCommandTokens, firstToken } from './command-policy.js';
2
+
3
+ /* ── 只读命令 token ───────────────────────────────────────────── */
4
+ const READ_ONLY_TOKENS = new Set([
5
+ 'ls', 'cat', 'head', 'tail', 'pwd', 'wc', 'sort', 'uniq',
6
+ 'cut', 'tr', 'basename', 'dirname', 'test', 'true', 'false',
7
+ 'whoami', 'uname', 'date', 'env', 'printenv', 'hostname',
8
+ 'rg', 'find', 'grep', 'ag', 'ack', 'fd', 'bat',
9
+ 'git', 'node', 'npm', 'npx', 'python', 'python3', 'py', 'pip', 'pip3',
10
+ 'echo', 'printf', 'seq', 'yes'
11
+ ]);
12
+
13
+ /* 只读时需要检查子命令的 token */
14
+ const READ_ONLY_SUBCOMMANDS = {
15
+ git: new Set([
16
+ 'status', 'log', 'diff', 'branch', 'show', 'tag', 'stash',
17
+ 'list', 'remote', 'rev-parse', 'describe', 'blame',
18
+ 'shortlog', 'count', 'ls-files', 'ls-remote', 'ls-tree',
19
+ 'config', '--version', 'var', 'for-each-ref', 'name-rev',
20
+ 'merge-base', 'cherry'
21
+ ]),
22
+ node: new Set(['--version', '-v', '-e', '--eval', '--print', '-p', '--help']),
23
+ npm: new Set([
24
+ '--version', '-v', 'view', 'info', 'list', 'ls', 'll', 'la',
25
+ 'outdated', 'audit', 'pack', 'cache', 'config', 'doctor',
26
+ 'help', 'explore', 'run', 'run-script', 'start', 'test',
27
+ 'restart', 'stop', 'version', 'whoami'
28
+ ]),
29
+ npx: new Set(['--version', '-v', '--help']),
30
+ python: new Set(['--version', '-V', '--help', '-c', '-m']),
31
+ python3: new Set(['--version', '-V', '--help', '-c', '-m']),
32
+ py: new Set(['--version', '-V', '--help', '-c', '-m']),
33
+ pip: new Set(['--version', '-V', 'list', 'show', 'search', 'check', 'debug', 'help']),
34
+ pip3: new Set(['--version', '-V', 'list', 'show', 'search', 'check', 'debug', 'help'])
35
+ };
36
+
37
+ /* ── 高风险 pattern ────────────────────────────────────────────── */
38
+ const HIGH_RISK_PATTERNS = [
39
+ /\binstall\b/i,
40
+ /\bpublish\b/i,
41
+ /\bpush\b/i,
42
+ /\bcommit\b/i,
43
+ /\brebase\b/i,
44
+ /\breset\s/i,
45
+ /\bcheckout\s+--/i,
46
+ /\brm\b/i,
47
+ /\bdel\b/i,
48
+ /\bmkdi[ri]\b/i,
49
+ /\btouch\b/i,
50
+ /\bcp\b/i,
51
+ /\bmv\b/i,
52
+ /\bchmod\b/i,
53
+ /\bchown\b/i,
54
+ /\bmktemp\b/i,
55
+ /\btee\b/i,
56
+ /\bsudo\b/i,
57
+ /\bsu\b/,
58
+ /\bkill\b/i,
59
+ /\bpkill\b/i,
60
+ /\bcurl\s+.*-[A-Z]\s*(POST|PUT|DELETE|PATCH)/i,
61
+ /\bwget\b/i,
62
+ /\bdocker\s+(rm|stop|kill|rmi)\b/i,
63
+ /\bsystemctl\b/i,
64
+ /\bservice\b/i,
65
+ /\blaunchctl\b/i,
66
+ />\s*\S/,
67
+ />>\s*\S/,
68
+ /\|&\s*\S/
69
+ ];
70
+
71
+ /* ── 核心分类逻辑 ──────────────────────────────────────────────── */
72
+
73
+ /**
74
+ * 判断单个 token 是否为只读命令(含子命令检查)。
75
+ */
76
+ function isReadOnlyToken(token, rawSegment) {
77
+ if (!READ_ONLY_TOKENS.has(token)) return false;
78
+
79
+ /* 需要 子命令 校验的 token */
80
+ const allowedSubs = READ_ONLY_SUBCOMMANDS[token];
81
+ if (!allowedSubs) return true; // 如 ls, pwd 等本身只读
82
+
83
+ /* 提取子命令:去掉 token 后第一个非 flag 参数 */
84
+ const rest = String(rawSegment || '').trim().slice(token.length).trim();
85
+ const parts = rest.split(/\s+/).filter(Boolean);
86
+ /* 以 - 开头的 flag 视为安全,取第一个非 flag 参数 */
87
+ let subcmd = '';
88
+ for (const part of parts) {
89
+ if (part.startsWith('-')) continue;
90
+ subcmd = part;
91
+ break;
92
+ }
93
+ /* 只有 token 本身或全部是 flags → 视为安全 */
94
+ if (!subcmd) return true;
95
+ if (allowedSubs.has(subcmd)) return true;
96
+ /* 子命令 不在白名单 → 不确定 */
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * 对命令文本做快速 高风险 pattern 扫描。
102
+ */
103
+ function matchesHighRiskPattern(text) {
104
+ return HIGH_RISK_PATTERNS.some((p) => p.test(text));
105
+ }
106
+
107
+ /**
108
+ * 分类命令风险等级。
109
+ * @param {string} command
110
+ * @param {string} [shellName='bash']
111
+ * @returns {'read-only'|'write-high-risk'|'ambiguous'}
112
+ */
113
+ export function classifyCommandRisk(command, shellName = 'bash') {
114
+ const cmd = String(command || '').trim();
115
+ if (!cmd) return 'read-only';
116
+
117
+ /* 高风险 pattern 优先判断 */
118
+ if (matchesHighRiskPattern(cmd)) return 'write-high-risk';
119
+
120
+ /* 解析链式命令的每个 segment */
121
+ const tokens = collectCommandTokens(cmd);
122
+ if (tokens.length === 0) return 'ambiguous';
123
+
124
+ let highestRisk = 'read-only';
125
+ const RISK_ORDER = { 'read-only': 0, ambiguous: 1, 'write-high-risk': 2 };
126
+
127
+ for (const { token, raw } of tokens) {
128
+ if (isReadOnlyToken(token, raw)) {
129
+ /* 保持当前级别 */
130
+ } else {
131
+ /* 不在只读集合 → 至少 ambiguous */
132
+ const segRisk = matchesHighRiskPattern(raw) ? 'write-high-risk' : 'ambiguous';
133
+ if (RISK_ORDER[segRisk] > RISK_ORDER[highestRisk]) {
134
+ highestRisk = segRisk;
135
+ }
136
+ }
137
+ }
138
+
139
+ return highestRisk;
140
+ }
141
+
142
+ /**
143
+ * 是否需要进入审批评估流程。
144
+ * 只读命令跳过,其余都需要。
145
+ */
146
+ export function requiresApprovalEvaluation(command, shellName = 'bash') {
147
+ return classifyCommandRisk(command, shellName) !== 'read-only';
148
+ }
@@ -18,7 +18,6 @@ export const INDEX_SKIP_DIRS = new Set([
18
18
  '.git',
19
19
  'node_modules',
20
20
  '.codemini',
21
- '.codemini-project',
22
21
  '.codemini-global',
23
22
  'dist',
24
23
  'coverage',
@@ -45,9 +45,16 @@ User: add a notes file
45
45
  Assistant: create the file directly
46
46
  Tool: write({"file":"${cwd}/notes.txt","text":"todo\\n"})
47
47
 
48
- 6. Capture a high-signal observation during work
49
- When you notice a reusable pattern, a user correction, a repeated failure, or a stable preference — capture it to the dream loop inbox for later consolidation.
50
- Tool: capture_memory({"summary":"User prefers tab size 2 for all JSON files","scope":"global","type":"preference"})
48
+ 6. Save a high-signal observation to memory
49
+ When you notice a reusable pattern, a user correction, a repeated failure, or a stable preference — save it to persistent memory. Choose scope carefully:
50
+ - scope "user" for personal preferences (language, reply style, interaction habits)
51
+ - scope "global" for cross-project lessons (environment quirks, general tool workflows)
52
+ - scope "project" for project-specific knowledge (architecture conventions, local config, test commands, file locations)
53
+
54
+ Examples:
55
+ Tool: save_memory({"content":"User prefers tab size 2 for all JSON files","scope":"user","kind":"preference"})
56
+ Tool: save_memory({"content":"This project uses vitest, not jest — run tests with npx vitest run","scope":"project","kind":"pattern"})
57
+ Tool: save_memory({"content":"WSL2 bash exec prefix does not support cd as a command","scope":"global","kind":"correction"})
51
58
 
52
59
  7. Run a dream loop consolidation pass
53
60
  When you want to review and consolidate inbox entries into long-term memory.
@@ -1,5 +1,6 @@
1
1
  import { listMemories, listInbox, archiveEntry, promoteMemory } from './memory-store.js';
2
2
  import { writeDreamAuditReport } from './dream-audit.js';
3
+ import { evaluateInboxBatch } from './dream-evaluator.js';
3
4
 
4
5
  const LONGTERM_TYPES = new Set(['preference', 'pattern', 'win', 'decision']);
5
6
  const OPERATIONAL_TYPES = new Set(['correction', 'failure', 'gap', 'observation']);
@@ -8,14 +9,6 @@ function normalizeText(value) {
8
9
  return String(value || '').trim().toLowerCase();
9
10
  }
10
11
 
11
- function mapInboxScopeToMemoryScope(scope) {
12
- const value = normalizeText(scope);
13
- if (value === 'repo' || value === 'project') return 'project';
14
- if (value === 'thread') return 'global';
15
- if (value === 'user') return 'user';
16
- return 'global';
17
- }
18
-
19
12
  function chooseLifecycle(type) {
20
13
  const value = normalizeText(type);
21
14
  if (LONGTERM_TYPES.has(value)) return 'longterm';
@@ -55,7 +48,10 @@ export async function runDreamConsolidation({
55
48
  const filesRead = ['memory/inbox/*', 'memory/global.json', 'memory/user.json', 'memory/project/*.json'];
56
49
  const filesChanged = [];
57
50
 
51
+ /* ── Phase 1: 规则预过滤(快速剔除明显垃圾) ─────────────────── */
52
+ const candidates = [];
58
53
  const seen = new Map();
54
+
59
55
  for (const entry of inbox) {
60
56
  const summaryKey = normalizeText(entry.summary);
61
57
  if (!summaryKey) {
@@ -78,14 +74,58 @@ export async function runDreamConsolidation({
78
74
  continue;
79
75
  }
80
76
 
81
- const lifecycle = chooseLifecycle(entry.type);
82
- const promoteScope = mapInboxScopeToMemoryScope(entry.scope);
77
+ candidates.push(entry);
78
+ }
79
+
80
+ if (candidates.length === 0) {
81
+ const report = { timestamp: new Date().toISOString(), filesRead, filesChanged: [], candidatesGenerated: inbox.length, promotions, rejections, archives };
82
+ if (!dryRun && writeAudit) {
83
+ const reportPath = await writeDreamAuditReport(report);
84
+ report.auditReport = reportPath;
85
+ }
86
+ return { ok: true, dryRun, ...report };
87
+ }
88
+
89
+ /* ── Phase 2: LLM 批量评估(质量门控 + scope 分类 + 内容提炼) ── */
90
+ const llmResults = dryRun
91
+ ? candidates.map((e) => ({ id: e.id, action: 'keep', scope: 'global', kind: e.type || 'observation', content: e.details || e.summary, summary: e.summary, confidence: 0.9 }))
92
+ : await evaluateInboxBatch({ entries: candidates, config, workspaceRoot });
93
+
94
+ const resultMap = new Map(llmResults.map((r) => [r.id, r]));
95
+
96
+ /* ── Phase 3: 按评估结果 promote 或 archive ─────────────────── */
97
+ for (const entry of candidates) {
98
+ const evaluation = resultMap.get(entry.id);
99
+
100
+ if (!evaluation || evaluation.action === 'discard') {
101
+ const reason = evaluation?.reason || 'LLM discarded';
102
+ if (!dryRun) await archiveEntry(entry, 'discarded-by-evaluator', reason);
103
+ rejections.push({ summary: entry.summary, reason: `evaluator-discard: ${reason}` });
104
+ continue;
105
+ }
106
+
107
+ const promoteScope = evaluation.scope || 'global';
108
+ const lifecycle = chooseLifecycle(evaluation.kind);
109
+ const enrichedEntry = {
110
+ ...entry,
111
+ /* 用 LLM 提炼后的内容覆盖原始报错 */
112
+ summary: evaluation.summary || entry.summary,
113
+ details: evaluation.content || entry.details || entry.summary,
114
+ type: evaluation.kind || entry.type || 'observation'
115
+ };
83
116
 
84
117
  if (!dryRun) {
85
118
  try {
86
- await promoteMemory({ entry, scope: promoteScope, lifecycle, workspaceRoot, config });
87
- filesChanged.push({ file: `memory/${promoteScope}.json`, why: `Promoted "${entry.summary}" as ${lifecycle}` });
88
- promotions.push({ summary: entry.summary, scope: promoteScope, lifecycle, rationale: entry.type });
119
+ await promoteMemory({
120
+ entry: enrichedEntry,
121
+ scope: promoteScope,
122
+ lifecycle,
123
+ workspaceRoot,
124
+ config,
125
+ confidence: evaluation.confidence || 0.8
126
+ });
127
+ filesChanged.push({ file: `memory/${promoteScope}.json`, why: `Promoted "${enrichedEntry.summary}" as ${lifecycle} (${promoteScope})` });
128
+ promotions.push({ summary: enrichedEntry.summary, scope: promoteScope, lifecycle, rationale: evaluation.kind, confidence: evaluation.confidence });
89
129
  } catch (error) {
90
130
  const reason = String(error?.message || error || 'promotion failed').slice(0, 180);
91
131
  await archiveEntry(entry, 'promotion-failed', reason);
@@ -95,7 +135,7 @@ export async function runDreamConsolidation({
95
135
  continue;
96
136
  }
97
137
 
98
- promotions.push({ summary: entry.summary, scope: promoteScope, lifecycle, rationale: entry.type, dryRun: true });
138
+ promotions.push({ summary: enrichedEntry.summary, scope: promoteScope, lifecycle, rationale: evaluation.kind, confidence: evaluation.confidence, dryRun: true });
99
139
  }
100
140
 
101
141
  const report = {
@@ -0,0 +1,99 @@
1
+ import { createChatCompletion } from './provider/index.js';
2
+
3
+ const EVAL_TIMEOUT_MS = 30000;
4
+
5
+ const SYSTEM_PROMPT = `You are a memory consolidation evaluator for a coding assistant. You receive a batch of inbox items (tool errors, observations, etc.) and decide for each one:
6
+
7
+ 1. **keep or discard** — Does this contain a reusable, durable insight? Discard transient errors, one-off issues, and noise.
8
+ 2. **scope** — "global" for cross-project knowledge (e.g., "WSL bash exec does not support cd"), "project" for project-specific context (e.g., "this project uses vitest for testing").
9
+ 3. **kind** — One of: pattern, observation, correction, decision, failure
10
+ 4. **content** — A refined, actionable sentence describing the insight. NOT the raw error text.
11
+ 5. **summary** — A short label (under 80 chars) for quick scanning.
12
+ 6. **confidence** — 0.5–1.0 based on how certain and durable the insight is.
13
+
14
+ Respond with valid JSON only, no markdown fences:
15
+ {"results":[{"id":"<inbox-id>","action":"keep","scope":"global|project","kind":"pattern|observation|correction|decision|failure","content":"...","summary":"...","confidence":0.8},{"id":"<inbox-id>","action":"discard","reason":"..."}]}
16
+
17
+ Rules:
18
+ - Raw tool error messages are NOT insights by themselves. Only keep if they reveal a reusable lesson.
19
+ - "exit 127", "command not found", "permission denied", "blocked by policy" → always discard (transient/config issues)
20
+ - A repeated pattern across multiple errors → keep as a "pattern" or "correction"
21
+ - Project-specific paths, file names, or commands → scope "project"
22
+ - General coding/environment knowledge → scope "global"
23
+ - If in doubt, discard. Memory is expensive; only promote what future sessions will genuinely benefit from.`;
24
+
25
+ function parseResults(text) {
26
+ try {
27
+ const json = JSON.parse(text);
28
+ if (!json?.results || !Array.isArray(json.results)) return [];
29
+ return json.results.map((r) => ({
30
+ id: String(r.id || ''),
31
+ action: r.action === 'keep' ? 'keep' : 'discard',
32
+ scope: r.scope === 'project' ? 'project' : 'global',
33
+ kind: ['pattern', 'observation', 'correction', 'decision', 'failure'].includes(r.kind) ? r.kind : 'observation',
34
+ content: String(r.content || '').slice(0, 300),
35
+ summary: String(r.summary || '').slice(0, 120),
36
+ confidence: Math.min(1, Math.max(0.5, Number(r.confidence) || 0.7)),
37
+ reason: String(r.reason || '')
38
+ }));
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 用 LLM 批量评估 inbox 条目,决定保留/丢弃、scope、内容提炼。
46
+ * @param {{ entries: Array, config: object, workspaceRoot?: string }} params
47
+ * @returns {Promise<Array<{ id, action, scope?, kind?, content?, summary?, confidence?, reason? }>>}
48
+ */
49
+ export async function evaluateInboxBatch({ entries, config, workspaceRoot }) {
50
+ if (!entries || entries.length === 0) return [];
51
+
52
+ const batch = entries.map((e) => ({
53
+ id: e.id,
54
+ type: e.type || '',
55
+ source: e.source || '',
56
+ summary: (e.summary || '').slice(0, 150),
57
+ details: (e.details || '').slice(0, 400)
58
+ }));
59
+
60
+ try {
61
+ const result = await createChatCompletion({
62
+ sdkProvider: config?.sdk?.provider,
63
+ baseUrl: config?.gateway?.base_url,
64
+ apiKey: config?.gateway?.api_key,
65
+ model: config?.model?.name,
66
+ messages: [
67
+ { role: 'system', content: SYSTEM_PROMPT },
68
+ {
69
+ role: 'user',
70
+ content: `Evaluate these ${batch.length} inbox items. Workspace: ${workspaceRoot || process.cwd()}\n\n${JSON.stringify(batch, null, 2)}`
71
+ }
72
+ ],
73
+ temperature: 0,
74
+ timeoutMs: EVAL_TIMEOUT_MS
75
+ });
76
+
77
+ const text = result?.text || '';
78
+ const parsed = parseResults(text);
79
+ /* 确保每个 entry 都有结果,LLM 没返回的一律 discard */
80
+ const covered = new Set(parsed.map((r) => r.id));
81
+ for (const entry of entries) {
82
+ if (!covered.has(entry.id)) {
83
+ parsed.push({
84
+ id: entry.id,
85
+ action: 'discard',
86
+ reason: 'LLM did not return a result for this entry'
87
+ });
88
+ }
89
+ }
90
+ return parsed;
91
+ } catch {
92
+ /* LLM 调用失败 → 全部 discard(fail-safe) */
93
+ return entries.map((e) => ({
94
+ id: e.id,
95
+ action: 'discard',
96
+ reason: 'LLM evaluation failed'
97
+ }));
98
+ }
99
+ }
@@ -111,7 +111,7 @@ class FffMcpClient {
111
111
  capabilities: {},
112
112
  clientInfo: {
113
113
  name: 'codemini-cli',
114
- version: '0.3.9'
114
+ version: '0.4.0'
115
115
  }
116
116
  });
117
117
  this.sendNotification('notifications/initialized', {});
@@ -375,7 +375,8 @@ export async function promoteMemory({
375
375
  lifecycle = 'operational',
376
376
  workspaceRoot = process.cwd(),
377
377
  projectAlias = '',
378
- config = {}
378
+ config = {},
379
+ confidence = 0.9
379
380
  } = {}) {
380
381
  if (!entry?.summary) throw new Error('Entry with summary is required for promotion');
381
382
  const lc = validateLifecycle(lifecycle);
@@ -386,7 +387,7 @@ export async function promoteMemory({
386
387
  kind: entry.type || 'note',
387
388
  summary: normalizeMemoryText(entry.summary),
388
389
  source: `dream-promote:${entry.id}`,
389
- confidence: 0.9,
390
+ confidence: Math.min(1, Math.max(0.5, confidence)),
390
391
  replaceSimilar: true,
391
392
  workspaceRoot,
392
393
  projectAlias,
package/src/core/paths.js CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  const GLOBAL_APP_DIR = 'codemini-global';
5
5
  const PROJECT_APP_DIR = '.codemini';
6
- const PROJECT_INDEX_DIR = '.codemini-project';
6
+ const PROJECT_INDEX_DIR = '.codemini';
7
7
 
8
8
  export function getBaseConfigDir() {
9
9
  if (process.env.CODEMINI_GLOBAL_DIR) {
@@ -387,7 +387,7 @@ export async function initializeProjectIndex(cwd = process.cwd()) {
387
387
  projectRoot: targetRoot,
388
388
  projectMap,
389
389
  fileIndex,
390
- summary: `initialized ${path.basename(targetRoot) || '.'}/.codemini-project (${Array.isArray(fileIndex?.files) ? fileIndex.files.length : 0} files)`
390
+ summary: `initialized ${path.basename(targetRoot) || '.'}/.codemini (${Array.isArray(fileIndex?.files) ? fileIndex.files.length : 0} files)`
391
391
  };
392
392
  })();
393
393
  initCache.set(cacheKey, promise);
@@ -447,7 +447,7 @@ export async function refreshIndexedFile(cwd = process.cwd(), relativePath = '')
447
447
  path: projectRelativePath,
448
448
  projectRoot,
449
449
  action,
450
- summary: `${action} ${path.basename(projectRoot) || '.'}/.codemini-project for ${projectRelativePath}`
450
+ summary: `${action} ${path.basename(projectRoot) || '.'}/.codemini for ${projectRelativePath}`
451
451
  };
452
452
  }
453
453
 
@@ -23,8 +23,10 @@ const SHELL_PROFILES = {
23
23
  'npm',
24
24
  'npx',
25
25
  'python',
26
+ 'python3',
26
27
  'py',
27
28
  'pip',
29
+ 'pip3',
28
30
  'get-childitem',
29
31
  'get-content',
30
32
  'select-string',
@@ -70,7 +72,9 @@ const SHELL_PROFILES = {
70
72
  'npm',
71
73
  'npx',
72
74
  'python',
75
+ 'python3',
73
76
  'pip',
77
+ 'pip3',
74
78
  'ls',
75
79
  'cat',
76
80
  'sed',
@@ -164,7 +168,7 @@ Some tools are loaded on demand through tool_search. Common examples:
164
168
  - glob for pattern-based file lookup
165
169
  - ast_query and read_ast_node for advanced AST-scoped reads and edits
166
170
  - list_background_tasks, get_background_task, and stop_background_task for managing long-running background commands
167
- - remember_user, remember_global, remember_project, list_memory, search_memory, and forget_memory for persistent memory operations
171
+ - save_memory, list_memory, search_memory, and forget_memory for persistent memory operations
168
172
 
169
173
  For structural code edits (functions, classes, methods), prefer AST-scoped reads before editing:
170
174
  - Common one-shot workflow: read(path, query=..., capture_name=...) → edit with symbol or ast_target
@@ -0,0 +1,184 @@
1
+ import cliTruncate from 'cli-truncate';
2
+ import stripAnsi from 'strip-ansi';
3
+ import { classifyCommandIntent } from './shell.js';
4
+
5
+ const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
6
+
7
+ export function sanitizeTextForModel(
8
+ value,
9
+ {
10
+ maxChars = 0,
11
+ maxLineLength = 220,
12
+ maxConsecutiveBlankLines = 1
13
+ } = {}
14
+ ) {
15
+ if (value == null) return '';
16
+
17
+ const lines = String(value)
18
+ .replace(/\r\n?/g, '\n')
19
+ .split('\n');
20
+ const output = [];
21
+ let blankRun = 0;
22
+
23
+ for (const rawLine of lines) {
24
+ const line = stripAnsi(rawLine).replace(CONTROL_CHARS_RE, '').replace(/[ \t]+$/g, '');
25
+ if (!line.trim()) {
26
+ blankRun += 1;
27
+ if (blankRun > maxConsecutiveBlankLines) continue;
28
+ output.push('');
29
+ continue;
30
+ }
31
+
32
+ blankRun = 0;
33
+ output.push(
34
+ maxLineLength > 0
35
+ ? cliTruncate(line, maxLineLength, { position: 'end' })
36
+ : line
37
+ );
38
+ }
39
+
40
+ let sanitized = output.join('\n').trimEnd();
41
+ if (maxChars > 0 && sanitized.length > maxChars) {
42
+ sanitized = `${sanitized.slice(0, maxChars)}\n... [sanitized output truncated ${sanitized.length - maxChars} chars]`;
43
+ }
44
+ return sanitized;
45
+ }
46
+
47
+ export function getToolOutputSanitizeOptions(toolName) {
48
+ const name = String(toolName || '').trim();
49
+ if (name === 'read' || name === 'read_ast_node' || name === 'run' || name === 'web_fetch') {
50
+ return {
51
+ maxLineLength: 0
52
+ };
53
+ }
54
+ return {};
55
+ }
56
+
57
+ export function sanitizePreviewLines(value, { maxLineLength = 220 } = {}) {
58
+ const sanitized = sanitizeTextForModel(value, {
59
+ maxLineLength,
60
+ maxConsecutiveBlankLines: 0
61
+ });
62
+ if (!sanitized) return [];
63
+ return sanitized
64
+ .split('\n')
65
+ .map((line) => line.trim())
66
+ .filter(Boolean);
67
+ }
68
+
69
+ function summarizeGitStatusPorcelain(stdout) {
70
+ const modified = [];
71
+ const added = [];
72
+ const deleted = [];
73
+ const untracked = [];
74
+
75
+ for (const line of String(stdout || '').split('\n')) {
76
+ const trimmed = line.trimEnd();
77
+ if (!trimmed) continue;
78
+ const status = trimmed.slice(0, 2);
79
+ const file = trimmed.slice(3).trim();
80
+ if (!file) continue;
81
+ if (status === '??') {
82
+ untracked.push(file);
83
+ continue;
84
+ }
85
+ if (status.includes('A')) added.push(file);
86
+ else if (status.includes('D')) deleted.push(file);
87
+ else modified.push(file);
88
+ }
89
+
90
+ const total = modified.length + added.length + deleted.length + untracked.length;
91
+ if (total === 0) return '';
92
+ const lines = [`[git status: ${total} file(s)]`];
93
+ if (modified.length) lines.push(`modified: ${modified.join(', ')}`);
94
+ if (added.length) lines.push(`added: ${added.join(', ')}`);
95
+ if (deleted.length) lines.push(`deleted: ${deleted.join(', ')}`);
96
+ if (untracked.length) lines.push(`untracked: ${untracked.join(', ')}`);
97
+ return lines.join('\n');
98
+ }
99
+
100
+ function summarizeTestFailure(command, code, stdout, stderr) {
101
+ if (classifyCommandIntent(command).kind !== 'test') {
102
+ return '';
103
+ }
104
+ if (Number(code ?? 0) === 0) return '';
105
+
106
+ const lines = sanitizePreviewLines([stdout, stderr].filter(Boolean).join('\n'), { maxLineLength: 220 });
107
+ const kept = [];
108
+
109
+ for (const line of lines) {
110
+ if (
111
+ /^FAIL\b/.test(line) ||
112
+ /^Test Suites:/.test(line) ||
113
+ /^Tests:/.test(line) ||
114
+ /AssertionError|Error:|Expected|expected .* to /i.test(line) ||
115
+ /^\s*at\b/.test(line) ||
116
+ /:\d+:\d+\)?$/.test(line)
117
+ ) {
118
+ kept.push(line);
119
+ }
120
+ }
121
+
122
+ if (kept.length === 0) return '';
123
+ return [`[test failure: exit ${code ?? 1}]`, ...kept.slice(0, 8)].join('\n');
124
+ }
125
+
126
+ function summarizeInstallOutput(command, code, stdout) {
127
+ if (classifyCommandIntent(command).kind !== 'install') return '';
128
+
129
+ const lines = sanitizePreviewLines(stdout, { maxLineLength: 220 });
130
+ const kept = [];
131
+ for (const line of lines) {
132
+ if (
133
+ /\b(?:added|removed|changed|audited) \d+ package/i.test(line) ||
134
+ /\bvulnerabilit(?:y|ies)\b/i.test(line) ||
135
+ /looking for funding/i.test(line)
136
+ ) {
137
+ kept.push(line);
138
+ }
139
+ }
140
+ if (kept.length === 0) return '';
141
+ return [`[install summary: exit ${code ?? 0}]`, ...kept.slice(0, 6)].join('\n');
142
+ }
143
+
144
+ function summarizeBuildOutput(command, code, stdout, stderr) {
145
+ if (classifyCommandIntent(command).kind !== 'build') return '';
146
+ if (Number(code ?? 0) === 0) return '';
147
+
148
+ const lines = sanitizePreviewLines([stdout, stderr].filter(Boolean).join('\n'), { maxLineLength: 220 });
149
+ const kept = [];
150
+ for (const line of lines) {
151
+ if (
152
+ /\berror\b/i.test(line) ||
153
+ /Build failed/i.test(line) ||
154
+ /failed with/i.test(line)
155
+ ) {
156
+ kept.push(line);
157
+ }
158
+ }
159
+ if (kept.length === 0) return '';
160
+ return [`[build failure: exit ${code ?? 1}]`, ...kept.slice(0, 8)].join('\n');
161
+ }
162
+
163
+ export function summarizeRunOutput(result) {
164
+ const command = String(result?.command || '').trim();
165
+ const stdout = String(result?.stdout || '');
166
+ const stderr = String(result?.stderr || '');
167
+ const code = result?.code ?? 0;
168
+
169
+ if (/^git\s+status\b.*(?:--short|-s)\b/i.test(command)) {
170
+ const gitSummary = summarizeGitStatusPorcelain(stdout);
171
+ if (gitSummary) return gitSummary;
172
+ }
173
+
174
+ const installSummary = summarizeInstallOutput(command, code, stdout);
175
+ if (installSummary) return installSummary;
176
+
177
+ const buildSummary = summarizeBuildOutput(command, code, stdout, stderr);
178
+ if (buildSummary) return buildSummary;
179
+
180
+ const testSummary = summarizeTestFailure(command, code, stdout, stderr);
181
+ if (testSummary) return testSummary;
182
+
183
+ return '';
184
+ }