@miphamai/cli 0.23.0 → 0.24.1

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.
@@ -7,6 +7,16 @@ import { getMessageBus } from './message-bus'
7
7
  import type { HookEngine } from '../core/hooks'
8
8
  import type { PermissionSystem } from '../core/permission'
9
9
  import { AgentExperience } from './agent-experience'
10
+ import { PatternAnalyzer } from './pattern-analyzer.js'
11
+ import type { ExperienceRuleEngine } from '../core/rule-engine.js'
12
+
13
+ // Singleton instances (created lazily)
14
+ let _patternAnalyzer: PatternAnalyzer | undefined
15
+
16
+ function getPatternAnalyzer(): PatternAnalyzer {
17
+ if (!_patternAnalyzer) _patternAnalyzer = new PatternAnalyzer()
18
+ return _patternAnalyzer
19
+ }
10
20
 
11
21
  const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
12
22
  general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
@@ -32,6 +42,7 @@ export class SubAgent {
32
42
  private toolRegistry: Map<string, ToolDefinition>,
33
43
  private permission?: PermissionSystem,
34
44
  private hookEngine?: HookEngine,
45
+ private ruleEngine?: ExperienceRuleEngine,
35
46
  ) {}
36
47
 
37
48
  /**
@@ -85,6 +96,24 @@ export class SubAgent {
85
96
  options.agentDef,
86
97
  )
87
98
  }
99
+
100
+ // CRSI: trigger pattern analysis after each agent execution
101
+ // Gated by crsi.autoPatternAnalysis feature flag
102
+ if (options.autoPatternAnalysis !== false) {
103
+ try {
104
+ const analyzer = getPatternAnalyzer()
105
+ const agentName = options.agentDef?.name || agentType
106
+ const patterns = analyzer.analyzeAgent(agentName)
107
+ if (patterns.length > 0 && this.ruleEngine) {
108
+ for (const pattern of patterns) {
109
+ const toolRule = analyzer.toToolRule(pattern)
110
+ this.ruleEngine.register(toolRule)
111
+ }
112
+ }
113
+ } catch {
114
+ // Pattern analysis failure never blocks agent execution
115
+ }
116
+ }
88
117
  })
89
118
 
90
119
  return `[background-task:${taskId}]`
@@ -97,6 +126,25 @@ export class SubAgent {
97
126
  await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
98
127
  }
99
128
  this.logSuccessExperience(agentType, description, result, options.agentDef)
129
+
130
+ // CRSI: trigger pattern analysis after successful sync execution
131
+ // Gated by crsi.autoPatternAnalysis feature flag
132
+ if (options.autoPatternAnalysis !== false) {
133
+ try {
134
+ const analyzer = getPatternAnalyzer()
135
+ const agentName = options.agentDef?.name || agentType
136
+ const patterns = analyzer.analyzeAgent(agentName)
137
+ if (patterns.length > 0 && this.ruleEngine) {
138
+ for (const pattern of patterns) {
139
+ const toolRule = analyzer.toToolRule(pattern)
140
+ this.ruleEngine.register(toolRule)
141
+ }
142
+ }
143
+ } catch {
144
+ // Pattern analysis failure never blocks agent execution
145
+ }
146
+ }
147
+
100
148
  return result
101
149
  } catch (err) {
102
150
  if (this.hookEngine) {
@@ -109,6 +157,25 @@ export class SubAgent {
109
157
  )
110
158
  }
111
159
  this.logFailureExperience(agentType, description, String(err), options.agentDef)
160
+
161
+ // CRSI: trigger pattern analysis after failed sync execution (failures produce patterns too)
162
+ // Gated by crsi.autoPatternAnalysis feature flag
163
+ if (options.autoPatternAnalysis !== false) {
164
+ try {
165
+ const analyzer = getPatternAnalyzer()
166
+ const agentName = options.agentDef?.name || agentType
167
+ const patterns = analyzer.analyzeAgent(agentName)
168
+ if (patterns.length > 0 && this.ruleEngine) {
169
+ for (const pattern of patterns) {
170
+ const toolRule = analyzer.toToolRule(pattern)
171
+ this.ruleEngine.register(toolRule)
172
+ }
173
+ }
174
+ } catch {
175
+ // Pattern analysis failure never blocks agent execution
176
+ }
177
+ }
178
+
112
179
  throw err
113
180
  }
114
181
  }
@@ -46,4 +46,6 @@ export interface SubAgentOptions {
46
46
  onProgress?: (chunk: string) => void
47
47
  /** When set, tool executions use this path as cwd (git worktree isolation). */
48
48
  worktreePath?: string
49
+ /** CRSI: when false, skip pattern analysis after agent execution. Default true. */
50
+ autoPatternAnalysis?: boolean
49
51
  }
@@ -22,6 +22,12 @@ export const DEFAULT_CONFIG: MiphamConfig = {
22
22
  mcp: { oauthEnabled: true },
23
23
  context: { useRealTokenizer: true, adaptiveThresholds: true },
24
24
  },
25
+ crsi: {
26
+ ruleInjection: true,
27
+ preToolHook: true,
28
+ autoPatternAnalysis: true,
29
+ autoRuleManagement: true,
30
+ },
25
31
  }
26
32
 
27
33
  export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
@@ -3,6 +3,7 @@ import type {
3
3
  ToolDefinition,
4
4
  ToolResult,
5
5
  InferenceHookConfig,
6
+ CrsiConfig,
6
7
  } from '../shared/index.ts'
7
8
  import { ProviderRegistry } from '../providers/registry'
8
9
  import { ContextManager } from './context'
@@ -16,6 +17,9 @@ import type { AgentViewManager } from '../agent-view/agent-view-manager'
16
17
  import type { SkillsLoader } from '../skills/loader'
17
18
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
18
19
  import { RulesLoader } from './rules-loader'
20
+ import { ExperienceRuleEngine } from './rule-engine.js'
21
+ import { PatternAnalyzer } from '../agent/pattern-analyzer.js'
22
+ import { EffectivenessTracker } from '../agent/effectiveness-tracker.js'
19
23
  import { UsageTracker } from './usage-tracker'
20
24
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
21
25
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
@@ -37,6 +41,9 @@ export class QueryEngine {
37
41
  private agentRegistry?: AgentRegistry
38
42
  private agentViewManager?: AgentViewManager
39
43
  private skillsLoader?: SkillsLoader
44
+ private ruleEngine?: ExperienceRuleEngine
45
+ private _patternAnalyzer?: PatternAnalyzer
46
+ private _effectivenessTracker?: EffectivenessTracker
40
47
  private goal?: string
41
48
  private maxGoalLoops = 20
42
49
  private lastAssistantContent?: string
@@ -54,7 +61,10 @@ export class QueryEngine {
54
61
  private context: ContextManager,
55
62
  private tools: Map<string, ToolDefinition>,
56
63
  private permission: PermissionSystem = new PermissionSystem('default'),
57
- ) {}
64
+ ruleEngine?: ExperienceRuleEngine,
65
+ ) {
66
+ this.ruleEngine = ruleEngine
67
+ }
58
68
 
59
69
  /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
60
70
  private sessionId: string = 'session-1'
@@ -128,6 +138,8 @@ export class QueryEngine {
128
138
  private usageTracker = new UsageTracker()
129
139
  /** Inference hook (DLP) configuration. */
130
140
  private inferenceHookConfig?: InferenceHookConfig
141
+ /** CRSI feature flags. */
142
+ private crsiConfig?: Partial<CrsiConfig>
131
143
 
132
144
  /** Register the rules loader. */
133
145
  setRulesLoader(loader: RulesLoader): void {
@@ -140,6 +152,11 @@ export class QueryEngine {
140
152
  this.inferenceHookConfig = config
141
153
  }
142
154
 
155
+ /** Register CRSI feature flags. */
156
+ setCrsiConfig(config: Partial<CrsiConfig> | undefined): void {
157
+ this.crsiConfig = config
158
+ }
159
+
143
160
  /** Pending task notifications from background agents (cleared after draining). */
144
161
  private pendingTaskNotifications: Array<StreamChunk> = []
145
162
 
@@ -796,6 +813,7 @@ export class QueryEngine {
796
813
 
797
814
  // Run PreToolUse hooks
798
815
  let effectiveParams = params
816
+ let hookWarnings: string[] = []
799
817
  if (this.hookEngine) {
800
818
  const preResult = await this.hookEngine.executePreToolUse(name, params, this.sessionId)
801
819
  if (!preResult.allowed) {
@@ -810,6 +828,17 @@ export class QueryEngine {
810
828
  }
811
829
  }
812
830
 
831
+ // CRSI RuleEngine intercept — gated by crsi.preToolHook feature flag
832
+ if (this.crsiConfig?.preToolHook !== false && this.ruleEngine) {
833
+ const ruleResult = this.ruleEngine.intercept(name, effectiveParams)
834
+ if (Object.keys(ruleResult.modified).length > 0) {
835
+ effectiveParams = ruleResult.modified
836
+ }
837
+ if (ruleResult.warnings.length > 0) {
838
+ hookWarnings = ruleResult.warnings
839
+ }
840
+ }
841
+
813
842
  try {
814
843
  const result = await tool.execute(effectiveParams, {
815
844
  cwd: process.cwd(),
@@ -823,16 +852,35 @@ export class QueryEngine {
823
852
  agentRegistry: this.agentRegistry,
824
853
  backgroundAgentRegistry: getBackgroundAgentRegistry(),
825
854
  permissionSystem: this.permission,
855
+ ruleEngine: this.ruleEngine,
826
856
  })
827
857
 
828
858
  // Track touched files for rules matching
829
859
  this.trackTouchedFile(name, effectiveParams)
830
860
 
861
+ // Prepend CRSI warnings to result content
862
+ if (hookWarnings.length > 0 && result.success) {
863
+ result.content = hookWarnings.join('\n') + '\n' + (result.content || '')
864
+ }
865
+
831
866
  // Run PostToolUse hooks
832
867
  if (this.hookEngine) {
833
868
  await this.hookEngine.executePostToolUse(name, effectiveParams, result, this.sessionId)
834
869
  }
835
870
 
871
+ // CRSI: Track rule effectiveness after tool execution
872
+ if (hookWarnings.length > 0) {
873
+ const tracker = this.getEffectivenessTracker()
874
+ for (const warning of hookWarnings) {
875
+ const match = warning.match(/^\[rule:([^\]]+)\]/)
876
+ const ruleId = match?.[1]
877
+ if (ruleId) {
878
+ tracker.recordApplication(ruleId, result.success)
879
+ }
880
+ }
881
+ tracker.persist()
882
+ }
883
+
836
884
  return result
837
885
  } catch (err) {
838
886
  // Sanitize error: strip stack traces and internal paths to prevent
@@ -864,6 +912,27 @@ export class QueryEngine {
864
912
  return this.tools
865
913
  }
866
914
 
915
+ getRuleEngine(): ExperienceRuleEngine | undefined {
916
+ return this.ruleEngine
917
+ }
918
+
919
+ /** Lazily-initialized PatternAnalyzer singleton. */
920
+ getPatternAnalyzer(): PatternAnalyzer {
921
+ if (!this._patternAnalyzer) {
922
+ this._patternAnalyzer = new PatternAnalyzer()
923
+ }
924
+ return this._patternAnalyzer
925
+ }
926
+
927
+ /** Lazily-initialized EffectivenessTracker singleton. */
928
+ getEffectivenessTracker(): EffectivenessTracker {
929
+ if (!this._effectivenessTracker) {
930
+ this._effectivenessTracker = new EffectivenessTracker()
931
+ this._effectivenessTracker.load()
932
+ }
933
+ return this._effectivenessTracker
934
+ }
935
+
867
936
  /** Register a tool dynamically (used by MCP auto-registration). */
868
937
  registerTool(tool: ToolDefinition): void {
869
938
  if (this.tools.has(tool.name)) {
@@ -0,0 +1,179 @@
1
+ import type { ExperienceRule } from '../agent/experience-rules.js'
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
3
+ import { join, dirname } from 'node:path'
4
+
5
+ export interface ToolRule {
6
+ id: string
7
+ toolName: string
8
+ category: string
9
+ match: (params: Record<string, unknown>) => boolean
10
+ fix: (params: Record<string, unknown>) => {
11
+ modified: Record<string, unknown>
12
+ warning: string
13
+ }
14
+ source: 'builtin' | 'pattern-analyzer' | 'manual'
15
+ enabled: boolean
16
+ }
17
+
18
+ const BUILTIN_RULES: ToolRule[] = [
19
+ {
20
+ id: 'rule-timeout-bash-heavy',
21
+ toolName: 'Bash',
22
+ category: 'timeout',
23
+ match: (p: Record<string, unknown>) => {
24
+ const cmd = String(p.command ?? '')
25
+ const heavy = /npm (install|ci|test)|docker build|pnpm install|cargo build|brew install/.test(
26
+ cmd,
27
+ )
28
+ if (!heavy) return false
29
+ const timeout = (p as Record<string, unknown>).timeout as number | undefined
30
+ return !timeout || timeout < 300_000
31
+ },
32
+ fix: (p: Record<string, unknown>) => {
33
+ const prevTimeout = (p as Record<string, unknown>).timeout as number | undefined
34
+ return {
35
+ modified: { ...p, timeout: 300_000 },
36
+ warning: `⏱️ timeout 已从 ${prevTimeout || 'default'}ms 自动提升至 300000ms(该命令类型历史超时率 > 50%)`,
37
+ }
38
+ },
39
+ source: 'builtin',
40
+ enabled: true,
41
+ },
42
+ {
43
+ id: 'rule-git-force-protection',
44
+ toolName: 'Bash',
45
+ category: 'tool-params',
46
+ match: (p: Record<string, unknown>) => {
47
+ const cmd = String(p.command ?? '')
48
+ return /git (push|reset) .*--force/.test(cmd) && !p.dangerouslyDisableSandbox
49
+ },
50
+ fix: (p: Record<string, unknown>) => ({
51
+ modified: p,
52
+ warning: '⚠️ 检测到 git --force 操作。如需执行请设置 dangerouslyDisableSandbox: true',
53
+ }),
54
+ source: 'builtin',
55
+ enabled: true,
56
+ },
57
+ ]
58
+
59
+ export class ExperienceRuleEngine {
60
+ private rules: ToolRule[]
61
+ private storePath: string
62
+
63
+ constructor(storeDir: string = join(process.env.HOME || '~', '.mipham', 'rule-engine')) {
64
+ this.rules = [...BUILTIN_RULES.map((r) => ({ ...r }))]
65
+ this.storePath = join(storeDir, 'rules.json')
66
+ this.load()
67
+ }
68
+
69
+ register(rule: ToolRule): void {
70
+ // Replace if same ID exists, otherwise append
71
+ const idx = this.rules.findIndex((r) => r.id === rule.id)
72
+ if (idx !== -1) {
73
+ this.rules[idx] = rule
74
+ } else {
75
+ this.rules.push(rule)
76
+ }
77
+ this.persist()
78
+ }
79
+
80
+ intercept(
81
+ toolName: string,
82
+ params: Record<string, unknown>,
83
+ ): { modified: Record<string, unknown>; warnings: string[] } {
84
+ let modified = params
85
+ const warnings: string[] = []
86
+
87
+ for (const rule of this.rules) {
88
+ if (!rule.enabled) continue
89
+ if (rule.toolName !== toolName) continue
90
+
91
+ try {
92
+ if (rule.match(modified)) {
93
+ const result = rule.fix(modified)
94
+ modified = result.modified
95
+ if (result.warning) {
96
+ warnings.push(`[rule:${rule.id}] ${result.warning}`)
97
+ }
98
+ }
99
+ } catch {
100
+ // Rule match/fix failures never block execution
101
+ }
102
+ }
103
+
104
+ return { modified, warnings }
105
+ }
106
+
107
+ convertFromExperienceRules(experienceRules: ExperienceRule[]): ToolRule[] {
108
+ return experienceRules.map((er): ToolRule => {
109
+ // Map experience rule category to tool name
110
+ const toolNameMap: Record<string, string> = {
111
+ timeout: 'Bash',
112
+ 'tool-params': 'Bash',
113
+ import: 'Write',
114
+ search: 'Grep',
115
+ semantic: 'Bash',
116
+ }
117
+ const toolName = toolNameMap[er.category] || 'Bash'
118
+
119
+ return {
120
+ id: er.id,
121
+ toolName,
122
+ category: er.category,
123
+ match: (p: Record<string, unknown>): boolean => {
124
+ if (er.category === 'timeout') {
125
+ const cmd = String(p.command ?? '')
126
+ const heavy = /npm|docker|pnpm|cargo|brew|install|build/.test(cmd)
127
+ if (!heavy) return false
128
+ const timeout = (p as Record<string, unknown>).timeout as number | undefined
129
+ return !timeout || timeout < 300_000
130
+ }
131
+ // Default: always match for the given tool
132
+ return true
133
+ },
134
+ fix: (p: Record<string, unknown>) => ({
135
+ modified: { ...p, timeout: 300_000 },
136
+ warning: `⏱️ [auto-rule] ${er.action} (${er.evidence.failureCount} 次历史失败)`,
137
+ }),
138
+ source: 'pattern-analyzer',
139
+ enabled: true,
140
+ }
141
+ })
142
+ }
143
+
144
+ getActiveRules(): ToolRule[] {
145
+ return this.rules.filter((r) => r.enabled)
146
+ }
147
+
148
+ setRuleEnabled(id: string, enabled: boolean): void {
149
+ const rule = this.rules.find((r) => r.id === id)
150
+ if (rule) {
151
+ rule.enabled = enabled
152
+ this.persist()
153
+ }
154
+ }
155
+
156
+ /** Persist non-builtin rules to disk. */
157
+ persist(): void {
158
+ const nonBuiltin = this.rules.filter((r) => r.source !== 'builtin')
159
+ const dir = dirname(this.storePath)
160
+ mkdirSync(dir, { recursive: true })
161
+ writeFileSync(this.storePath, JSON.stringify(nonBuiltin, null, 2), 'utf-8')
162
+ }
163
+
164
+ /** Load persisted non-builtin rules from disk. Rejects rules whose IDs conflict with builtins. */
165
+ load(): void {
166
+ if (!existsSync(this.storePath)) return
167
+ try {
168
+ const raw = JSON.parse(readFileSync(this.storePath, 'utf-8')) as ToolRule[]
169
+ const builtinIds = new Set(BUILTIN_RULES.map((r) => r.id))
170
+ for (const rule of raw) {
171
+ // Reject if a builtin with the same ID exists (builtins always win)
172
+ if (builtinIds.has(rule.id)) continue
173
+ this.rules.push(rule)
174
+ }
175
+ } catch {
176
+ // Corrupt file — start fresh with only builtins
177
+ }
178
+ }
179
+ }
package/src/index.tsx CHANGED
@@ -20,6 +20,7 @@ import { InstructionsLoader } from './core/instructions'
20
20
  import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
21
21
  import { ContextManager } from './core/context'
22
22
  import { QueryEngine } from './core/engine'
23
+ import { ExperienceRuleEngine } from './core/rule-engine.js'
23
24
  import { SessionStore } from './core/session-store'
24
25
  import type { PermissionLevel } from './shared/types'
25
26
  import { SkillsLoader } from './skills/loader'
@@ -247,7 +248,8 @@ export async function runApp(options: RunOptions): Promise<void> {
247
248
  const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
248
249
 
249
250
  // Create query engine
250
- const engine = new QueryEngine(registry, context, tools)
251
+ const ruleEngine = new ExperienceRuleEngine()
252
+ const engine = new QueryEngine(registry, context, tools, undefined, ruleEngine)
251
253
  engine.setHookEngine(hookEngine)
252
254
  engine.setArtifactServer(artifactServer)
253
255
  engine.setAgentViewManager(agentViewManager)
@@ -72,6 +72,7 @@ export interface ToolContext {
72
72
  agentRegistry?: import('../agent/agent-registry').AgentRegistry
73
73
  backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
74
74
  permissionSystem?: import('../core/permission').PermissionSystem
75
+ ruleEngine?: import('../core/rule-engine').ExperienceRuleEngine
75
76
  }
76
77
 
77
78
  // ── Artifact Types ──
@@ -162,6 +163,8 @@ export interface MiphamConfig {
162
163
  }
163
164
  /** Phase 9 feature flags. All default to true. */
164
165
  features?: Partial<FeatureFlags>
166
+ /** Phase 10 CRSI feature flags. All default to true. */
167
+ crsi?: Partial<CrsiConfig>
165
168
  }
166
169
 
167
170
  export interface FeatureFlags {
@@ -169,6 +172,17 @@ export interface FeatureFlags {
169
172
  context: { useRealTokenizer: boolean; adaptiveThresholds: boolean }
170
173
  }
171
174
 
175
+ export interface CrsiConfig {
176
+ /** Extract and inject experience rules into agent system prompts. */
177
+ ruleInjection: boolean
178
+ /** Intercept tool calls and auto-fix known failure patterns before execution. */
179
+ preToolHook: boolean
180
+ /** Analyze agent outcomes for recurring failure patterns. */
181
+ autoPatternAnalysis: boolean
182
+ /** Auto-degrade/disable low-effectiveness rules based on success-rate tracking. */
183
+ autoRuleManagement: boolean
184
+ }
185
+
172
186
  export interface McpServerConfig {
173
187
  name: string
174
188
  command: string
@@ -60,7 +60,13 @@ export const agentTool: ToolDefinition = {
60
60
  const agentDef = ctx.agentRegistry?.resolve(agentType)
61
61
 
62
62
  try {
63
- const sub = new SubAgent(registry, toolRegistry, ctx.permissionSystem)
63
+ const sub = new SubAgent(
64
+ registry,
65
+ toolRegistry,
66
+ ctx.permissionSystem,
67
+ undefined,
68
+ ctx.ruleEngine,
69
+ )
64
70
  const result = await sub.execute(prompt, description, {
65
71
  type: agentType,
66
72
  agentDef,
@@ -2,10 +2,65 @@ import { readFileSync, writeFileSync } from 'node:fs'
2
2
  import type { ToolDefinition } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
4
 
5
+ /**
6
+ * Characters considered part of an identifier in source code.
7
+ * A match is only valid if it sits at identifier boundaries —
8
+ * i.e. not embedded inside a larger identifier word.
9
+ */
10
+ function isIdentifierChar(c: string): boolean {
11
+ return /[a-zA-Z0-9_$]/.test(c)
12
+ }
13
+
14
+ /**
15
+ * Find all valid occurrences of `oldStr` in `content`.
16
+ * A match is valid only when it does NOT sit inside a larger identifier:
17
+ * - If oldStr starts with an identifier char, the char before the match
18
+ * must NOT be an identifier char (or the match must be at position 0).
19
+ * - If oldStr ends with an identifier char, the char after the match
20
+ * must NOT be an identifier char (or the match must be at end of file).
21
+ */
22
+ function findValidMatches(content: string, oldStr: string): number[] {
23
+ const indices: number[] = []
24
+ // oldStr is guaranteed non-empty by the required parameter validation
25
+ const oldFirst = oldStr[0]!
26
+ const oldLast = oldStr[oldStr.length - 1]!
27
+ const checkLeft = isIdentifierChar(oldFirst)
28
+ const checkRight = isIdentifierChar(oldLast)
29
+
30
+ let pos = 0
31
+ while (pos < content.length) {
32
+ const idx = content.indexOf(oldStr, pos)
33
+ if (idx === -1) break
34
+
35
+ // Left boundary check: if oldStr starts with identifier char,
36
+ // the character before the match must not be an identifier char.
37
+ if (checkLeft && idx > 0 && isIdentifierChar(content[idx - 1]!)) {
38
+ pos = idx + 1
39
+ continue
40
+ }
41
+
42
+ // Right boundary check: if oldStr ends with identifier char,
43
+ // the character after the match must not be an identifier char.
44
+ const afterIdx = idx + oldStr.length
45
+ if (checkRight && afterIdx < content.length && isIdentifierChar(content[afterIdx]!)) {
46
+ pos = idx + 1
47
+ continue
48
+ }
49
+
50
+ indices.push(idx)
51
+ pos = idx + 1
52
+ }
53
+
54
+ return indices
55
+ }
56
+
5
57
  export const editTool: ToolDefinition = {
6
58
  name: 'Edit',
7
59
  description:
8
- 'Perform exact string replacement in a file. old_string must match exactly and be unique.',
60
+ 'Perform exact string replacement in a file. ' +
61
+ 'old_string must match exactly as a standalone occurrence — it will NOT match inside larger ' +
62
+ 'identifiers or words (e.g. "user" will not match inside "username"). ' +
63
+ 'Must be unique in the file (unless replace_all is used).',
9
64
  category: 'file',
10
65
  permission: 'ask',
11
66
  parameters: {
@@ -43,22 +98,30 @@ export const editTool: ToolDefinition = {
43
98
  }
44
99
  }
45
100
 
46
- if (replaceAll) {
47
- if (!content.includes(oldStr)) {
48
- return { success: false, content: '', error: 'old_string not found in file' }
101
+ const matches = findValidMatches(content, oldStr)
102
+
103
+ if (matches.length === 0) {
104
+ return {
105
+ success: false,
106
+ content: '',
107
+ error:
108
+ 'old_string not found in file. ' +
109
+ 'It may appear only as part of a larger word/identifier — provide more context.',
49
110
  }
50
- const updated = content.replaceAll(oldStr, newStr)
51
- writeFileSync(filePath, updated, 'utf-8')
52
- const count = content.split(oldStr).length - 1
53
- return { success: true, content: `Replaced ${count} occurrences in ${filePath}` }
54
111
  }
55
112
 
56
- const firstIndex = content.indexOf(oldStr)
57
- if (firstIndex === -1) {
58
- return { success: false, content: '', error: 'old_string not found in file' }
113
+ if (replaceAll) {
114
+ // Build result by splicing at each match (in reverse so indices stay valid)
115
+ let result = content
116
+ for (let i = matches.length - 1; i >= 0; i--) {
117
+ const idx = matches[i]!
118
+ result = result.slice(0, idx) + newStr + result.slice(idx + oldStr.length)
119
+ }
120
+ writeFileSync(filePath, result, 'utf-8')
121
+ return { success: true, content: `Replaced ${matches.length} occurrences in ${filePath}` }
59
122
  }
60
- const secondIndex = content.indexOf(oldStr, firstIndex + 1)
61
- if (secondIndex !== -1) {
123
+
124
+ if (matches.length > 1) {
62
125
  return {
63
126
  success: false,
64
127
  content: '',
@@ -66,8 +129,8 @@ export const editTool: ToolDefinition = {
66
129
  }
67
130
  }
68
131
 
69
- const updated =
70
- content.slice(0, firstIndex) + newStr + content.slice(firstIndex + oldStr.length)
132
+ const idx = matches[0]!
133
+ const updated = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length)
71
134
  writeFileSync(filePath, updated, 'utf-8')
72
135
  return { success: true, content: `Replaced 1 occurrence in ${filePath}` }
73
136
  },