codemini-cli 0.3.4 → 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.
@@ -2,15 +2,22 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs/promises';
4
4
  import { BoundedCache } from './bounded-cache.js';
5
+ import { trimInline as _trimInline, normalizePath } from './string-utils.js';
5
6
 
7
+ /**
8
+ * 安全解析 JSON 字符串。
9
+ * 解析失败时返回带 _raw 和 _invalid_json 标记的对象,
10
+ * 调用方可据此决定是回退到原始文本还是报告错误。
11
+ */
6
12
  function safeJsonParse(raw) {
7
13
  if (!raw || typeof raw !== 'string') return {};
8
14
  try {
9
15
  return JSON.parse(raw);
10
- } catch {
16
+ } catch (parseError) {
11
17
  return {
12
18
  _raw: String(raw),
13
- _invalid_json: true
19
+ _invalid_json: true,
20
+ _parseError: parseError.message
14
21
  };
15
22
  }
16
23
  }
@@ -29,6 +36,41 @@ function parseInlineRangePath(value) {
29
36
  return { path: maybePath, start_line: start, end_line: end };
30
37
  }
31
38
 
39
+ function buildDeleteApprovalDetails(source, rawPath) {
40
+ const existing =
41
+ source?.approval && typeof source.approval === 'object' && !Array.isArray(source.approval)
42
+ ? source.approval
43
+ : {};
44
+ const approvalPath = String(existing.path || rawPath || '').trim();
45
+ const approvalName = String(existing.name || (approvalPath ? path.basename(approvalPath) : '') || '').trim();
46
+ const approvalType = String(existing.type || '').trim();
47
+
48
+ const approval = {};
49
+ if (approvalPath) approval.path = approvalPath;
50
+ if (approvalName) approval.name = approvalName;
51
+ if (approvalType) approval.type = approvalType;
52
+ return Object.keys(approval).length > 0 ? approval : undefined;
53
+ }
54
+
55
+ function buildDeleteCancellationResult(args) {
56
+ const approval =
57
+ args?.approval && typeof args.approval === 'object' && !Array.isArray(args.approval)
58
+ ? args.approval
59
+ : undefined;
60
+ const pathValue = String(approval?.path || args?.path || '').trim();
61
+ const nameValue = String(approval?.name || (pathValue ? path.basename(pathValue) : '') || '').trim();
62
+ const typeValue = String(approval?.type || '').trim();
63
+ return {
64
+ ok: false,
65
+ ...(pathValue ? { path: pathValue } : {}),
66
+ ...(nameValue ? { name: nameValue } : {}),
67
+ ...(typeValue ? { type: typeValue } : {}),
68
+ deleted: false,
69
+ cancelled: true,
70
+ reason: 'User denied deletion approval'
71
+ };
72
+ }
73
+
32
74
  function normalizeToolArguments(toolName, args, rawArguments) {
33
75
  const rawText = typeof rawArguments === 'string' ? rawArguments.trim() : '';
34
76
  const primitive =
@@ -102,6 +144,14 @@ function normalizeToolArguments(toolName, args, rawArguments) {
102
144
  return source;
103
145
  }
104
146
 
147
+ if (toolName === 'delete') {
148
+ const value = String(source.path || source.file_path || source.file || source.target || source.directory || source.dir || stringValue || '').trim();
149
+ if (value) source.path = value;
150
+ const approval = buildDeleteApprovalDetails(source, source.path);
151
+ if (approval) source.approval = approval;
152
+ return source;
153
+ }
154
+
105
155
  return source;
106
156
  }
107
157
 
@@ -307,7 +357,7 @@ export function checkReadDedup(filePath, startLine, endLine, mtimeMs) {
307
357
 
308
358
  const READ_ONLY_TOOLS = new Set([
309
359
  'read', 'grep', 'glob', 'list',
310
- 'ast_query', 'read_ast_node', 'generate_diff',
360
+ 'ast_query', 'read_ast_node',
311
361
  'list_background_tasks', 'get_background_task'
312
362
  ]);
313
363
 
@@ -322,6 +372,12 @@ export function summarizeToolResult(result) {
322
372
  if (typeof result === 'object') {
323
373
  const obj = result;
324
374
  if (Array.isArray(obj)) return `array(${obj.length})`;
375
+ if ('deleted' in obj && 'path' in obj) {
376
+ const kind = trimInline(obj.type || 'item', 16);
377
+ const target = trimInline(obj.path || '', 96);
378
+ if (obj.deleted) return target ? `deleted ${kind} ${target}` : `deleted ${kind}`;
379
+ if (obj.cancelled) return target ? `cancelled delete ${target}` : 'cancelled delete';
380
+ }
325
381
  if ('path' in obj && 'action' in obj) {
326
382
  const p = String(obj.path || '');
327
383
  const action = String(obj.action || 'write');
@@ -354,11 +410,12 @@ export function summarizeToolResult(result) {
354
410
  : start;
355
411
  const rangeText = start > 0 && end >= start ? ` lines ${start}-${end}` : '';
356
412
  const totalText = total > 0 ? ` of ${total}` : '';
413
+ const enclosingText = obj.enclosing_symbol ? ` in ${obj.enclosing_symbol}` : '';
357
414
  const errorText = obj.error ? ` (${trimInline(obj.error, 64)})` : '';
358
415
  const truncatedText = obj.truncated ? ' [truncated]' : '';
359
416
  return phase === 'metadata'
360
417
  ? `metadata for ${p}${rangeText}${totalText}${errorText}`
361
- : `content from ${p}${rangeText}${totalText}${truncatedText}`;
418
+ : `content from ${p}${rangeText}${totalText}${enclosingText}${truncatedText}`;
362
419
  }
363
420
  if ('stdout' in obj || 'stderr' in obj || 'code' in obj) {
364
421
  const stdout = trimInline(obj.stdout || '', 96);
@@ -405,12 +462,7 @@ export function summarizeToolResult(result) {
405
462
  return String(result);
406
463
  }
407
464
 
408
- export function trimInline(value, maxLen = 72) {
409
- const s = String(value || '').replace(/\s+/g, ' ').trim();
410
- if (!s) return '';
411
- if (s.length <= maxLen) return s;
412
- return `${s.slice(0, maxLen - 3)}...`;
413
- }
465
+ export const trimInline = _trimInline;
414
466
 
415
467
  function normalizeAssistantText(value) {
416
468
  return String(value || '').trim();
@@ -503,12 +555,12 @@ function createAnalysisGuardState(userPrompt) {
503
555
  }
504
556
 
505
557
  function topLevelPath(value) {
506
- const normalized = String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, '').trim();
558
+ const normalized = normalizePath(value).trim();
507
559
  return normalized.split('/')[0] || '';
508
560
  }
509
561
 
510
562
  function isRelevantSourcePath(filePath, state) {
511
- const normalized = String(filePath || '').replace(/\\/g, '/').trim();
563
+ const normalized = normalizePath(filePath).trim();
512
564
  if (!normalized) return false;
513
565
  if (state.candidateFiles.has(normalized) || state.entryCandidates.has(normalized)) return true;
514
566
  for (const root of state.sourceRoots) {
@@ -519,20 +571,17 @@ function isRelevantSourcePath(filePath, state) {
519
571
 
520
572
  function blockedExplorationReason(toolName, args, state) {
521
573
  if (!state.active) return '';
522
- if (!state.indexQueried && toolName !== 'query_project_index') {
523
- return 'Use query_project_index before broad repository exploration so the next reads stay focused on relevant source files.';
524
- }
525
574
 
526
- const target = String(args?.path || args?.pattern || args?.query || '').replace(/\\/g, '/').trim();
575
+ // Always note when query_project_index is used, but never force it
576
+ if (toolName === 'query_project_index') return '';
577
+
578
+ const target = normalizePath(String(args?.path || args?.pattern || args?.query || '')).trim();
527
579
  const top = topLevelPath(target);
528
580
  if (!top) return '';
529
581
 
530
582
  if (['skills', 'souls', 'templates', '.codemini', '.codemini-project'].includes(top)) {
531
583
  return `Skip ${top}/ for broad repository analysis unless the user explicitly asks for it. Inspect relevant source files first.`;
532
584
  }
533
- if (top === 'tests' && state.relevantSourceReads.size < 2) {
534
- return 'Inspect the next relevant source files before reading tests. Broad analysis should be grounded in production code first.';
535
- }
536
585
  return '';
537
586
  }
538
587
 
@@ -603,13 +652,13 @@ function formatToolDisplayName(name, args) {
603
652
  const target = trimInline(args?.path || args?.file || '.', 96) || '.';
604
653
  return `edit(${target})`;
605
654
  }
655
+ if (name === 'delete') {
656
+ const target = trimInline(args?.path || args?.target || '.', 96) || '.';
657
+ return `delete(${target})`;
658
+ }
606
659
  if (name === 'update_todos') {
607
660
  return 'update_todos';
608
661
  }
609
- if (name === 'patch') {
610
- const target = trimInline(args?.path || args?.file || args?.patch || '', 96) || '.';
611
- return `patch(${target})`;
612
- }
613
662
  if (name === 'list_background_tasks') {
614
663
  return name;
615
664
  }
@@ -617,10 +666,6 @@ function formatToolDisplayName(name, args) {
617
666
  const taskId = trimInline(args?.task_id || args?.taskId || '', 96);
618
667
  return taskId ? `${name}(${taskId})` : name;
619
668
  }
620
- if (name === 'read' || name === 'write' || name === 'run' || name === 'grep' || name === 'glob' || name === 'list' || name === 'edit' || name === 'patch' || name === 'generate_diff') {
621
- const target = trimInline(args?.path || args?.query || args?.symbol || '', 96);
622
- return target ? `${name}(${target})` : name;
623
- }
624
669
  return name;
625
670
  }
626
671
 
@@ -654,7 +699,9 @@ export async function runAgentLoop({
654
699
  requestToolApproval,
655
700
  toolResultMaxChars = 12000,
656
701
  toolFormatters = {},
657
- deferredDefinitions = {}
702
+ deferredDefinitions = {},
703
+ signal,
704
+ skipAnalysisNudge = false
658
705
  }) {
659
706
  const messages = [];
660
707
  if (systemPrompt) {
@@ -677,13 +724,25 @@ export async function runAgentLoop({
677
724
  const activeTools = [...toolDefinitions];
678
725
 
679
726
  for (let step = 0; step < maxSteps; step += 1) {
727
+ // 检查是否已被用户中止
728
+ if (signal?.aborted) {
729
+ if (onEvent) onEvent({ type: 'aborted', step: step + 1 });
730
+ break;
731
+ }
680
732
  if (onEvent) onEvent({ type: 'step:start', step: step + 1 });
681
733
  const completion = await requestCompletion({
682
734
  model,
683
735
  messages,
684
- tools: activeTools
736
+ tools: activeTools,
737
+ signal
685
738
  });
686
739
 
740
+ // 流式请求完成后再次检查中止状态
741
+ if (signal?.aborted) {
742
+ if (onEvent) onEvent({ type: 'aborted', step: step + 1 });
743
+ break;
744
+ }
745
+
687
746
  if (completion?.incomplete) {
688
747
  continue;
689
748
  }
@@ -692,8 +751,14 @@ export async function runAgentLoop({
692
751
  const assistantText = completion.text || '';
693
752
  lastAssistantText = assistantText || lastAssistantText;
694
753
 
695
- const assistantMessage = { role: 'assistant', content: completion?.content ?? assistantText };
696
- if (toolCalls.length > 0) {
754
+ const assistantMessage = completion?.assistantMessage
755
+ ? {
756
+ ...completion.assistantMessage,
757
+ role: 'assistant',
758
+ content: completion.assistantMessage.content ?? completion?.content ?? assistantText
759
+ }
760
+ : { role: 'assistant', content: completion?.content ?? assistantText };
761
+ if (!Array.isArray(assistantMessage.tool_calls) && toolCalls.length > 0) {
697
762
  assistantMessage.tool_calls = toolCalls.map((tc) => ({
698
763
  id: tc.id,
699
764
  type: 'function',
@@ -712,7 +777,7 @@ export async function runAgentLoop({
712
777
  }
713
778
 
714
779
  if (toolCalls.length === 0) {
715
- if (needsMoreAnalysisEvidence(analysisGuard) && pendingSummaryNudges < 2) {
780
+ if (!skipAnalysisNudge && needsMoreAnalysisEvidence(analysisGuard) && pendingSummaryNudges < 2) {
716
781
  pendingSummaryNudges += 1;
717
782
  messages.push({
718
783
  role: 'user',
@@ -721,7 +786,7 @@ export async function runAgentLoop({
721
786
  });
722
787
  continue;
723
788
  }
724
- if (shouldAskForConcreteFinalAnswer(assistantText, messages.slice(0, -1)) && pendingSummaryNudges < 2) {
789
+ if (!skipAnalysisNudge && shouldAskForConcreteFinalAnswer(assistantText, messages.slice(0, -1)) && pendingSummaryNudges < 2) {
725
790
  pendingSummaryNudges += 1;
726
791
  messages.push({
727
792
  role: 'user',
@@ -764,19 +829,44 @@ export async function runAgentLoop({
764
829
  const approvalResults = new Map();
765
830
  for (const { call, toolName, displayName, args } of callsWithMeta) {
766
831
  let approved = true;
767
- if (executionMode === 'normal' && !alwaysAllowSet.has(toolName)) {
832
+ let approvalArgs = args;
833
+ let preflightErrorContent = '';
834
+ const needsApproval = toolName === 'delete' || (executionMode === 'normal' && !alwaysAllowSet.has(toolName));
835
+ if (needsApproval) {
768
836
  approved = false;
837
+ const handler = toolHandlers[toolName];
838
+ if (toolName === 'delete' && typeof handler?.prepareApproval === 'function') {
839
+ try {
840
+ const approval = await handler.prepareApproval(args);
841
+ const normalizedApproval = buildDeleteApprovalDetails({ approval }, args?.path);
842
+ if (normalizedApproval) {
843
+ approvalArgs = { ...args, approval: normalizedApproval };
844
+ }
845
+ } catch (error) {
846
+ const message = error instanceof Error ? error.message : String(error);
847
+ preflightErrorContent = clipToolResult({ error: message }, toolResultMaxChars);
848
+ }
849
+ }
850
+ if (preflightErrorContent) {
851
+ approvalResults.set(call.id, {
852
+ approved: false,
853
+ args: approvalArgs,
854
+ errorContent: preflightErrorContent
855
+ });
856
+ continue;
857
+ }
769
858
  if (typeof requestToolApproval === 'function') {
770
859
  const decision = await requestToolApproval({
771
860
  id: call.id,
772
861
  name: toolName,
773
862
  displayName,
774
- arguments: args
863
+ arguments: approvalArgs,
864
+ approvalDetails: toolName === 'delete' ? approvalArgs.approval : undefined
775
865
  });
776
866
  approved = Boolean(decision?.approved);
777
867
  }
778
868
  }
779
- approvalResults.set(call.id, approved);
869
+ approvalResults.set(call.id, { approved, args: approvalArgs });
780
870
  }
781
871
 
782
872
  // Collect results keyed by call.id, then write to messages in original order
@@ -785,28 +875,45 @@ export async function runAgentLoop({
785
875
  // Helper to execute a single tool call
786
876
  async function executeOne({ call, args, toolName, displayName, isReadOnly }) {
787
877
  const startedAt = Date.now();
878
+ const approvalState = approvalResults.get(call.id) || { approved: true, args };
879
+ const effectiveArgs = approvalState.args || args;
880
+
881
+ if (approvalState.errorContent) {
882
+ if (onEvent) {
883
+ onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary: trimInline(approvalState.errorContent, 120) });
884
+ }
885
+ return {
886
+ callId: call.id,
887
+ content: approvalState.errorContent,
888
+ error: true
889
+ };
890
+ }
788
891
 
789
- if (!approvalResults.get(call.id)) {
790
- if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id, arguments: args });
892
+ if (!approvalState.approved) {
893
+ if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id, arguments: effectiveArgs });
894
+ const blockedPayload =
895
+ toolName === 'delete'
896
+ ? buildDeleteCancellationResult(effectiveArgs)
897
+ : { blocked: true, reason: 'Tool call requires approval in normal mode' };
791
898
  return {
792
899
  callId: call.id,
793
- content: JSON.stringify({ blocked: true, reason: 'Tool call requires approval in normal mode' }),
900
+ content: JSON.stringify(blockedPayload),
794
901
  blocked: true
795
902
  };
796
903
  }
797
904
 
798
- if (onEvent) onEvent({ type: 'tool:start', name: displayName, id: call.id, arguments: args });
905
+ if (onEvent) onEvent({ type: 'tool:start', name: displayName, id: call.id, arguments: effectiveArgs });
799
906
  const handler = toolHandlers[toolName];
800
907
  if (!handler) {
801
908
  throw new Error(`Unknown tool: ${call.name}`);
802
909
  }
803
910
 
804
- const blockedReason = blockedExplorationReason(toolName, args, analysisGuard);
911
+ const blockedReason = blockedExplorationReason(toolName, effectiveArgs, analysisGuard);
805
912
  if (blockedReason) {
806
913
  analysisGuard.blockedExplorations += 1;
807
914
  const content = clipToolResult({ error: blockedReason }, toolResultMaxChars);
808
915
  if (onEvent) {
809
- onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: args, durationMs: 0, summary: trimInline(blockedReason, 120) });
916
+ onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary: trimInline(blockedReason, 120) });
810
917
  }
811
918
  return {
812
919
  callId: call.id,
@@ -817,12 +924,12 @@ export async function runAgentLoop({
817
924
 
818
925
  let toolResult;
819
926
  try {
820
- toolResult = await handler(args);
927
+ toolResult = await handler(effectiveArgs);
821
928
  } catch (error) {
822
929
  const durationMs = Date.now() - startedAt;
823
930
  const message = error instanceof Error ? error.message : String(error);
824
931
  if (onEvent) {
825
- onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: args, durationMs, summary: trimInline(message, 120) });
932
+ onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary: trimInline(message, 120) });
826
933
  }
827
934
  return {
828
935
  callId: call.id,
@@ -833,12 +940,12 @@ export async function runAgentLoop({
833
940
 
834
941
  const durationMs = Date.now() - startedAt;
835
942
  if (onEvent) {
836
- onEvent({ type: 'tool:end', name: displayName, id: call.id, arguments: args, durationMs, summary: summarizeToolResult(toolResult) });
943
+ onEvent({ type: 'tool:end', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary: summarizeToolResult(toolResult) });
837
944
  }
838
945
 
839
946
  // P1b: Use per-tool formatter if available, else fallback
840
- let formatted = formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars);
841
- noteAnalysisEvidence(analysisGuard, toolName, args, toolResult);
947
+ let formatted = formatToolResult(toolResult, toolName, effectiveArgs, toolFormatters, toolResultMaxChars);
948
+ noteAnalysisEvidence(analysisGuard, toolName, effectiveArgs, toolResult);
842
949
 
843
950
  // P2: If tool_search loaded deferred tools, inject their schemas into activeTools
844
951
  if (toolName === 'tool_search' && toolResult && Array.isArray(toolResult.schemas)) {
@@ -857,8 +964,8 @@ export async function runAgentLoop({
857
964
  }
858
965
 
859
966
  // Separate read-only and write calls, preserving order
860
- const readOnlyCalls = callsWithMeta.filter((c) => c.isReadOnly && approvalResults.get(c.call.id));
861
- const writeCalls = callsWithMeta.filter((c) => !c.isReadOnly || !approvalResults.get(c.call.id));
967
+ const readOnlyCalls = callsWithMeta.filter((c) => c.isReadOnly && approvalResults.get(c.call.id)?.approved);
968
+ const writeCalls = callsWithMeta.filter((c) => !c.isReadOnly || !approvalResults.get(c.call.id)?.approved);
862
969
 
863
970
  // Execute read-only calls in parallel
864
971
  if (readOnlyCalls.length > 0) {
@@ -902,6 +1009,17 @@ export async function runAgentLoop({
902
1009
  }
903
1010
  }
904
1011
 
1012
+ // 如果被用户中止,返回已有内容并标记
1013
+ if (signal?.aborted) {
1014
+ const fallback = lastAssistantText || '';
1015
+ return {
1016
+ text: fallback,
1017
+ messages,
1018
+ steps: maxSteps,
1019
+ aborted: true
1020
+ };
1021
+ }
1022
+
905
1023
  const fallback = lastAssistantText || 'Stopped before final response.';
906
1024
  return {
907
1025
  text: `${fallback}\n\n[stopped] Reached max tool steps (${maxSteps}). Try a narrower prompt or increase execution.max_steps.`,
package/src/core/ast.js CHANGED
@@ -153,6 +153,46 @@ function exactNodeForTarget(rootNode, target) {
153
153
  return null;
154
154
  }
155
155
 
156
+ /**
157
+ * Find the enclosing named structural symbol (function, class, method, etc.)
158
+ * for a given line range in already-parsed content. Returns null if not found
159
+ * or if the language is unsupported.
160
+ */
161
+ export async function findEnclosingSymbol(content, filePath, line) {
162
+ const ext = path.extname(String(filePath || '')).toLowerCase();
163
+ const language = EXTENSION_LANGUAGE_MAP[ext];
164
+ if (!language) return null;
165
+ let parser = null;
166
+ let tree = null;
167
+ try {
168
+ const parsed = await parseContent(content, language);
169
+ parser = parsed.parser;
170
+ tree = parsed.tree;
171
+ const row = Math.max(0, Number(line || 1) - 1);
172
+ const node = tree.rootNode.descendantForPosition({ row, column: 0 });
173
+ let current = node;
174
+ while (current) {
175
+ if (current.type === 'program' || !current.parent) break;
176
+ const nameChild = current.childForFieldName('name');
177
+ if (nameChild) {
178
+ return {
179
+ name: nameChild.text,
180
+ kind: current.type,
181
+ start_line: current.startPosition.row + 1,
182
+ end_line: current.endPosition.row + 1
183
+ };
184
+ }
185
+ current = current.parent;
186
+ }
187
+ return null;
188
+ } catch {
189
+ return null;
190
+ } finally {
191
+ if (tree) tree.delete();
192
+ if (parser) parser.delete();
193
+ }
194
+ }
195
+
156
196
  export async function queryAst(root, args) {
157
197
  const relativePath = String(args?.path || '').trim();
158
198
  const querySource = String(args?.query || '').trim();