codemini-cli 0.3.5 → 0.3.6

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.
@@ -1,6 +1,26 @@
1
1
  import path from 'node:path';
2
2
  import { getEffectivePolicy } from './shell-profile.js';
3
3
 
4
+ const SHELL_KEYWORDS = new Set([
5
+ 'if',
6
+ 'then',
7
+ 'elif',
8
+ 'else',
9
+ 'fi',
10
+ 'for',
11
+ 'while',
12
+ 'until',
13
+ 'do',
14
+ 'done',
15
+ 'case',
16
+ 'esac',
17
+ 'in',
18
+ 'function',
19
+ 'time',
20
+ '{',
21
+ '}'
22
+ ]);
23
+
4
24
  function firstToken(command) {
5
25
  const m = String(command || '').trim().match(/^"([^"]+)"|^'([^']+)'|^(\S+)/);
6
26
  const raw = (m && (m[1] || m[2] || m[3])) || '';
@@ -51,6 +71,11 @@ function splitCommandSegments(command) {
51
71
  continue;
52
72
  }
53
73
 
74
+ if (ch === '&' && text[i - 1] === '>') {
75
+ current += ch;
76
+ continue;
77
+ }
78
+
54
79
  if (ch === '|' || ch === ';' || ch === '&') {
55
80
  if (current.trim()) segments.push(current.trim());
56
81
  current = '';
@@ -157,6 +182,30 @@ function suggestionForToken(token, config) {
157
182
  return 'Prefer structured tools like read, edit, write, grep, glob, and list first. If you need shell fallback, use allowed shell commands for search and local context such as rg, find, grep, sed, cat, or ls.';
158
183
  }
159
184
 
185
+ function validateCdSegment(command, workspaceRoot) {
186
+ const tokens = tokenizeTopLevel(command);
187
+ if (tokens.length === 1) {
188
+ return { allowed: false, reason: 'cd requires a target path in safe mode' };
189
+ }
190
+ if (tokens.length !== 2) {
191
+ return { allowed: false, reason: 'cd only supports a single target path in safe mode' };
192
+ }
193
+
194
+ const rawTarget = String(tokens[1] || '').trim();
195
+ if (!rawTarget || rawTarget.startsWith('-')) {
196
+ return { allowed: false, reason: 'cd target is not allowed in safe mode' };
197
+ }
198
+
199
+ const resolvedRoot = path.resolve(workspaceRoot);
200
+ const resolvedTarget = path.resolve(resolvedRoot, rawTarget);
201
+ const relative = path.relative(resolvedRoot, resolvedTarget);
202
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
203
+ return { allowed: false, reason: `cd escapes workspace: ${rawTarget}` };
204
+ }
205
+
206
+ return { allowed: true };
207
+ }
208
+
160
209
  export function evaluateCommandPolicy(command, config, workspaceRoot = process.cwd()) {
161
210
  const policy = getEffectivePolicy(config);
162
211
  const cmd = String(command || '').trim();
@@ -181,6 +230,13 @@ export function evaluateCommandPolicy(command, config, workspaceRoot = process.c
181
230
  const inspectedTokens = collectCommandTokens(cmd);
182
231
  const allowlist = Array.isArray(policy.command_allowlist) ? policy.command_allowlist : [];
183
232
  for (const item of inspectedTokens) {
233
+ if (SHELL_KEYWORDS.has(item.token)) continue;
234
+ if (item.token === 'cd') {
235
+ const cdCheck = validateCdSegment(item.raw, workspaceRoot);
236
+ if (!cdCheck.allowed) {
237
+ return { allowed: false, reason: cdCheck.reason, suggestion: suggestionForToken(item.token, config) };
238
+ }
239
+ }
184
240
  if (includesAny(item.token, policy.blocked_commands)) {
185
241
  return { allowed: false, reason: `blocked command: ${item.token}`, suggestion: suggestionForToken(item.token, config) };
186
242
  }
@@ -44,8 +44,6 @@ const DEFAULT_CONFIG = {
44
44
  'edit',
45
45
  'write',
46
46
  'run',
47
- 'patch',
48
- 'generate_diff',
49
47
  'list_background_tasks',
50
48
  'get_background_task',
51
49
  'stop_background_task'
@@ -151,7 +149,6 @@ function normalizePolicyLists(config) {
151
149
  'edit',
152
150
  'write',
153
151
  'run',
154
- 'generate_diff',
155
152
  'list_background_tasks',
156
153
  'get_background_task',
157
154
  'stop_background_task',
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * 共享加密工具函数。
3
- * 统一 sha1 / sha256 的实现,避免在各模块中重复定义。
3
+ * 统一使用 sha256 作为默认哈希算法,避免在各模块中重复定义。
4
4
  */
5
5
 
6
6
  import crypto from 'node:crypto';
7
7
 
8
+ /**
9
+ * 向后兼容别名,内部已迁移到 sha256。
10
+ * @deprecated 请使用 sha256() 替代。
11
+ */
8
12
  export function sha1(input) {
9
- return crypto.createHash('sha1').update(String(input || '')).digest('hex');
13
+ return sha256(input);
10
14
  }
11
15
 
12
16
  export function sha256(input) {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { sha1 } from './crypto-utils.js';
3
+ import { sha256 } from './crypto-utils.js';
4
4
  import { getMemoryDir, getProjectMemoryDir } from './paths.js';
5
5
  import { assertSafeMemoryContent, normalizeMemoryText, summarizeMemoryContent } from './memory-policy.js';
6
6
 
@@ -23,7 +23,7 @@ export function getProjectMemoryKey(workspaceRoot = process.cwd(), projectAlias
23
23
  if (alias) return slugify(alias);
24
24
  const root = path.resolve(workspaceRoot || process.cwd());
25
25
  const base = path.basename(root);
26
- return `${slugify(base)}-${sha1(root).slice(0, 10)}`;
26
+ return `${slugify(base)}-${sha256(root).slice(0, 10)}`;
27
27
  }
28
28
 
29
29
  function ensureScope(scope) {
@@ -63,7 +63,7 @@ function normalizeMemoryItem(item, scope, projectKey = '') {
63
63
  const now = nowIso();
64
64
  const content = normalizeMemoryText(item?.content || '');
65
65
  return {
66
- id: String(item?.id || `mem_${sha1(`${scope}:${projectKey}:${content}:${now}:${Math.random()}`).slice(0, 12)}`),
66
+ id: String(item?.id || `mem_${sha256(`${scope}:${projectKey}:${content}:${now}:${Math.random()}`).slice(0, 12)}`),
67
67
  scope,
68
68
  projectKey: projectKey || undefined,
69
69
  kind: String(item?.kind || 'note').trim() || 'note',
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { getFileIndexPath, getProjectIndexDir, getProjectMapPath, getProjectWorkspaceDir } from './paths.js';
4
4
  import { INDEX_SKIP_DIRS as SKIP_DIRS, SOURCE_EXTENSIONS, EXTENSION_LANGUAGE_MAP } from './constants.js';
5
- import { sha1 } from './crypto-utils.js';
5
+ import { sha256 } from './crypto-utils.js';
6
6
  import { BoundedCache } from './bounded-cache.js';
7
+ import { trimInline, normalizeRelativePath, escapeRegex } from './string-utils.js';
7
8
 
8
9
  const PROJECT_MARKER_FILES = new Set([
9
10
  'package.json',
@@ -31,11 +32,7 @@ function clipList(values, max = 32) {
31
32
  }
32
33
 
33
34
  function rel(cwd, filePath) {
34
- return path.relative(cwd, filePath).replace(/\\/g, '/');
35
- }
36
-
37
- function normalizeRelativePath(value) {
38
- return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/^\/+/, '');
35
+ return normalizeRelativePath(path.relative(cwd, filePath));
39
36
  }
40
37
 
41
38
  async function safeStat(filePath) {
@@ -58,13 +55,6 @@ function tokenizeQuery(text) {
58
55
  return [...new Set(String(text || '').toLowerCase().match(/[a-z0-9_./-]+/g) || [])].filter(Boolean);
59
56
  }
60
57
 
61
- function trimInline(value, max = 240) {
62
- const text = String(value || '').replace(/\s+/g, ' ').trim();
63
- if (!text) return '';
64
- if (text.length <= max) return text;
65
- return `${text.slice(0, max - 3)}...`;
66
- }
67
-
68
58
  function trimMultiline(value, max = 1800) {
69
59
  const text = String(value || '').trim();
70
60
  if (!text) return '';
@@ -77,10 +67,6 @@ async function writeJson(filePath, value) {
77
67
  await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
78
68
  }
79
69
 
80
- function escapeRegex(value) {
81
- return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
- }
83
-
84
70
  function gitignorePatternToRegex(pattern) {
85
71
  const normalized = normalizeRelativePath(pattern);
86
72
  let regexBody = '';
@@ -282,7 +268,7 @@ function buildFileEntry(relativePath, content, stat) {
282
268
  return {
283
269
  file: relativePath,
284
270
  language: LANGUAGE_BY_EXT[ext] || 'text',
285
- hash: sha1(content),
271
+ hash: sha256(content),
286
272
  size: Number(stat?.size || content.length || 0),
287
273
  mtimeMs: Number(stat?.mtimeMs || 0),
288
274
  imports,
@@ -318,14 +318,27 @@ export async function createChatCompletionStream({
318
318
  onTextDelta,
319
319
  onToolCallDelta,
320
320
  timeoutMs = 90000,
321
- maxTokens = 4096
321
+ maxTokens = 4096,
322
+ signal: externalSignal
322
323
  }) {
324
+ // 合并超时信号与外部中止信号
325
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
326
+ const controller = new AbortController();
327
+ const onAbort = () => controller.abort();
328
+ timeoutSignal.addEventListener('abort', onAbort, { once: true });
329
+ if (externalSignal) {
330
+ if (externalSignal.aborted) {
331
+ controller.abort();
332
+ } else {
333
+ externalSignal.addEventListener('abort', onAbort, { once: true });
334
+ }
335
+ }
323
336
  const payload = buildPayload({ model, temperature, messages, tools, stream: true, maxTokens });
324
337
  const response = await fetch(buildMessagesUrl(baseUrl), {
325
338
  method: 'POST',
326
339
  headers: createHeaders(apiKey),
327
340
  body: JSON.stringify(payload),
328
- signal: AbortSignal.timeout(timeoutMs)
341
+ signal: controller.signal
329
342
  });
330
343
 
331
344
  if (!response.ok || !response.body) {
@@ -370,14 +370,27 @@ export async function createChatCompletionStream({
370
370
  onTextDelta,
371
371
  onToolCallDelta,
372
372
  timeoutMs = 90000,
373
- maxRetries = 2
373
+ maxRetries = 2,
374
+ signal: externalSignal
374
375
  }) {
376
+ // 合并超时信号与外部中止信号,任一触发都会中止请求
377
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
378
+ const controller = new AbortController();
379
+ const onAbort = () => controller.abort();
380
+ timeoutSignal.addEventListener('abort', onAbort, { once: true });
381
+ if (externalSignal) {
382
+ if (externalSignal.aborted) {
383
+ controller.abort();
384
+ } else {
385
+ externalSignal.addEventListener('abort', onAbort, { once: true });
386
+ }
387
+ }
375
388
  const payload = buildPayload({ model, temperature, messages, tools, stream: true });
376
389
  const response = await fetch(buildChatCompletionsUrl(baseUrl), {
377
390
  method: 'POST',
378
391
  headers: createHeaders(apiKey),
379
392
  body: JSON.stringify(payload),
380
- signal: AbortSignal.timeout(timeoutMs)
393
+ signal: controller.signal
381
394
  });
382
395
  if (!response.ok || !response.body) {
383
396
  const text = await response.text().catch(() => '');
@@ -4,6 +4,8 @@ import { getSessionsDir } from './paths.js';
4
4
  import { normalizeTodos } from './todo-state.js';
5
5
 
6
6
  const ALLOWED_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
7
+ const SESSION_LEGACY_EXT = '.json';
8
+ const SESSION_JSONL_EXT = '.jsonl';
7
9
 
8
10
  function createSessionId() {
9
11
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
@@ -101,25 +103,85 @@ function sanitizeSession(session, fallbackId = '') {
101
103
  return out;
102
104
  }
103
105
 
106
+ function sessionPathById(sessionId, ext = SESSION_JSONL_EXT) {
107
+ return path.join(getSessionsDir(), `${sessionId}${ext}`);
108
+ }
109
+
110
+ function sessionIdFromFileName(fileName) {
111
+ if (fileName.endsWith(SESSION_JSONL_EXT)) return fileName.slice(0, -SESSION_JSONL_EXT.length);
112
+ if (fileName.endsWith(SESSION_LEGACY_EXT)) return fileName.slice(0, -SESSION_LEGACY_EXT.length);
113
+ return '';
114
+ }
115
+
116
+ function summarizeParsedSession(parsed, filePath) {
117
+ const id = parsed.id || sessionIdFromFileName(path.basename(filePath));
118
+ const updatedAt = parsed.updatedAt || parsed.createdAt || '';
119
+ const latestMessage = Array.isArray(parsed.messages) ? parsed.messages.at(-1) : null;
120
+ const preview = latestMessage?.content ? String(latestMessage.content).replace(/\s+/g, ' ').slice(0, 80) : '';
121
+ return {
122
+ id,
123
+ updatedAt,
124
+ messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0,
125
+ preview
126
+ };
127
+ }
128
+
129
+ async function tryReadJson(filePath) {
130
+ const raw = await fs.readFile(filePath, 'utf8');
131
+ return JSON.parse(raw);
132
+ }
133
+
134
+ async function loadLatestJsonlObject(filePath) {
135
+ const raw = await fs.readFile(filePath, 'utf8');
136
+ const lines = String(raw || '')
137
+ .split('\n')
138
+ .map((line) => line.trim())
139
+ .filter(Boolean);
140
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
141
+ try {
142
+ return JSON.parse(lines[i]);
143
+ } catch {
144
+ continue;
145
+ }
146
+ }
147
+ throw new Error(`No valid JSONL record found: ${filePath}`);
148
+ }
149
+
150
+ async function loadSessionPayload(sessionId) {
151
+ const jsonlPath = sessionPathById(sessionId, SESSION_JSONL_EXT);
152
+ let jsonlError = null;
153
+ try {
154
+ return await loadLatestJsonlObject(jsonlPath);
155
+ } catch (error) {
156
+ if (error?.code !== 'ENOENT') jsonlError = error;
157
+ }
158
+ const legacyPath = sessionPathById(sessionId, SESSION_LEGACY_EXT);
159
+ try {
160
+ return await tryReadJson(legacyPath);
161
+ } catch (error) {
162
+ if (jsonlError) throw jsonlError;
163
+ throw error;
164
+ }
165
+ }
166
+
104
167
  export async function createSession() {
105
168
  const sessionId = createSessionId();
106
169
  const dir = getSessionsDir();
107
170
  await fs.mkdir(dir, { recursive: true });
108
- const filePath = path.join(dir, `${sessionId}.json`);
171
+ const filePath = sessionPathById(sessionId, SESSION_JSONL_EXT);
109
172
  const payload = {
110
173
  id: sessionId,
111
174
  createdAt: new Date().toISOString(),
112
175
  updatedAt: new Date().toISOString(),
113
176
  messages: []
114
177
  };
115
- await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
178
+ await fs.writeFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
116
179
  return payload;
117
180
  }
118
181
 
119
182
  export async function loadSession(sessionId) {
120
- const filePath = path.join(getSessionsDir(), `${sessionId}.json`);
121
- const raw = await fs.readFile(filePath, 'utf8');
122
- return sanitizeSession(JSON.parse(raw), sessionId);
183
+ const parsed = await loadSessionPayload(sessionId);
184
+ return sanitizeSession(parsed, sessionId);
123
185
  }
124
186
 
125
187
  export async function saveSession(session) {
@@ -127,8 +189,8 @@ export async function saveSession(session) {
127
189
  await fs.mkdir(dir, { recursive: true });
128
190
  const normalized = sanitizeSession(session);
129
191
  normalized.updatedAt = new Date().toISOString();
130
- const filePath = path.join(dir, `${normalized.id}.json`);
131
- await fs.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
192
+ const filePath = sessionPathById(normalized.id, SESSION_JSONL_EXT);
193
+ await fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8');
132
194
  }
133
195
 
134
196
  export async function resolveSession(sessionId) {
@@ -143,31 +205,25 @@ export async function listSessions(limit = 30) {
143
205
  await fs.mkdir(dir, { recursive: true });
144
206
  const entries = await fs.readdir(dir, { withFileTypes: true });
145
207
  const files = entries
146
- .filter((e) => e.isFile() && e.name.endsWith('.json'))
208
+ .filter((e) => e.isFile() && (e.name.endsWith(SESSION_JSONL_EXT) || e.name.endsWith(SESSION_LEGACY_EXT)))
147
209
  .map((e) => path.join(dir, e.name));
148
210
 
149
- const sessions = [];
211
+ const sessionsById = new Map();
150
212
  for (const file of files) {
151
213
  try {
152
- const raw = await fs.readFile(file, 'utf8');
153
- const parsed = JSON.parse(raw);
154
- const id = parsed.id || path.basename(file, '.json');
155
- const updatedAt = parsed.updatedAt || parsed.createdAt || '';
156
- const latestMessage = Array.isArray(parsed.messages) ? parsed.messages.at(-1) : null;
157
- const preview = latestMessage?.content
158
- ? String(latestMessage.content).replace(/\s+/g, ' ').slice(0, 80)
159
- : '';
160
- sessions.push({
161
- id,
162
- updatedAt,
163
- messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0,
164
- preview
165
- });
214
+ const parsed = file.endsWith(SESSION_JSONL_EXT) ? await loadLatestJsonlObject(file) : await tryReadJson(file);
215
+ const summary = summarizeParsedSession(parsed, file);
216
+ if (!summary.id) continue;
217
+ const existing = sessionsById.get(summary.id);
218
+ if (!existing || String(summary.updatedAt) > String(existing.updatedAt)) {
219
+ sessionsById.set(summary.id, summary);
220
+ }
166
221
  } catch {
167
222
  continue;
168
223
  }
169
224
  }
170
225
 
226
+ const sessions = Array.from(sessionsById.values());
171
227
  sessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
172
228
  return sessions.filter((s) => Number(s.messageCount || 0) > 0).slice(0, limit);
173
229
  }
@@ -195,8 +251,9 @@ export async function pruneSessions(policy = {}) {
195
251
  const entries = await fs.readdir(dir, { withFileTypes: true });
196
252
  let removed = 0;
197
253
  for (const e of entries) {
198
- if (!e.isFile() || !e.name.endsWith('.json')) continue;
199
- const id = path.basename(e.name, '.json');
254
+ if (!e.isFile()) continue;
255
+ const id = sessionIdFromFileName(e.name);
256
+ if (!id) continue;
200
257
  if (keepIds.has(id)) continue;
201
258
  try {
202
259
  await fs.unlink(path.join(dir, e.name));
@@ -61,6 +61,7 @@ const SHELL_PROFILES = {
61
61
  shell: 'bash',
62
62
  label: 'bash',
63
63
  command_allowlist: [
64
+ 'cd',
64
65
  'rg',
65
66
  'find',
66
67
  'grep',
@@ -73,6 +74,22 @@ const SHELL_PROFILES = {
73
74
  'ls',
74
75
  'cat',
75
76
  'sed',
77
+ 'head',
78
+ 'tail',
79
+ 'wc',
80
+ 'test',
81
+ 'sort',
82
+ 'uniq',
83
+ 'cut',
84
+ 'tr',
85
+ 'xargs',
86
+ 'basename',
87
+ 'dirname',
88
+ 'paste',
89
+ 'echo',
90
+ 'sleep',
91
+ 'true',
92
+ 'false',
76
93
  'cp',
77
94
  'mv',
78
95
  'mkdir',
@@ -145,7 +162,6 @@ Use update_todos with these rules:
145
162
  Some tools are loaded on demand through tool_search. Common examples:
146
163
  - glob for pattern-based file lookup
147
164
  - ast_query and read_ast_node for advanced AST-scoped reads and edits
148
- - generate_diff and patch for explicit diff workflows
149
165
  - list_background_tasks, get_background_task, and stop_background_task for managing long-running background commands
150
166
  - remember_user, remember_global, remember_project, list_memory, search_memory, and forget_memory for persistent memory operations
151
167
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 共享字符串/路径工具函数。
3
+ * 统一 trimInline、路径标准化、escapeRegex 等多处重复实现。
4
+ */
5
+
6
+ /**
7
+ * 将字符串截断到指定长度,超出时添加省略号。
8
+ * 会先将空白折叠为单个空格再截断。
9
+ */
10
+ export function trimInline(value, maxLen = 72) {
11
+ const s = String(value || '').replace(/\s+/g, ' ').trim();
12
+ if (!s) return '';
13
+ if (s.length <= maxLen) return s;
14
+ return `${s.slice(0, maxLen - 3)}...`;
15
+ }
16
+
17
+ /**
18
+ * 将路径中的反斜杠替换为正斜杠,并去掉开头的 "./" 前缀。
19
+ * 用于统一 Windows / Unix 路径格式。
20
+ */
21
+ export function normalizePath(value) {
22
+ return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, '');
23
+ }
24
+
25
+ /**
26
+ * 将路径标准化为相对路径格式:反斜杠→正斜杠,去掉 "./" 和开头的 "/"。
27
+ */
28
+ export function normalizeRelativePath(value) {
29
+ return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/^\/+/, '');
30
+ }
31
+
32
+ /**
33
+ * 转义正则表达式中的特殊字符。
34
+ */
35
+ export function escapeRegex(value) {
36
+ return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+ }