@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.
- package/package.json +1 -1
- package/src/agent/agent-context.ts +20 -57
- package/src/agent/agent-experience.ts +8 -0
- package/src/agent/effectiveness-tracker.ts +149 -0
- package/src/agent/experience-rules.ts +188 -0
- package/src/agent/pattern-analyzer.ts +154 -0
- package/src/agent/sub-agent.ts +67 -0
- package/src/agent/types.ts +2 -0
- package/src/config/defaults.ts +6 -0
- package/src/core/engine.ts +70 -1
- package/src/core/rule-engine.ts +179 -0
- package/src/index.tsx +3 -1
- package/src/shared/types.ts +14 -0
- package/src/tools/agent/agent.ts +7 -1
- package/src/tools/file/edit.ts +78 -15
- package/src/ui/commands.ts +133 -0
package/package.json
CHANGED
|
@@ -5,7 +5,9 @@ import { homedir } from 'node:os'
|
|
|
5
5
|
import { ContextManager } from '../core/context'
|
|
6
6
|
import type { ToolDefinition } from '../shared/index.ts'
|
|
7
7
|
import type { AgentDefinition } from './types'
|
|
8
|
+
import type { CrsiConfig } from '../shared/index.ts'
|
|
8
9
|
import { AgentExperience } from './agent-experience'
|
|
10
|
+
import { ExperienceRuleExtractor } from './experience-rules.js'
|
|
9
11
|
|
|
10
12
|
export interface AgentContextResult {
|
|
11
13
|
context: ContextManager
|
|
@@ -15,8 +17,14 @@ export interface AgentContextResult {
|
|
|
15
17
|
/**
|
|
16
18
|
* Load agent memory files from the appropriate scope directory.
|
|
17
19
|
* Returns combined content for injection into the system prompt.
|
|
20
|
+
*
|
|
21
|
+
* @param crsiRuleInjection — when false, skip experience rule extraction (CRSI feature flag).
|
|
18
22
|
*/
|
|
19
|
-
function loadAgentMemory(
|
|
23
|
+
function loadAgentMemory(
|
|
24
|
+
agentName: string,
|
|
25
|
+
scope: 'user' | 'project' | 'local',
|
|
26
|
+
crsiRuleInjection = true,
|
|
27
|
+
): string {
|
|
20
28
|
let memoryDir: string
|
|
21
29
|
const home = homedir()
|
|
22
30
|
|
|
@@ -56,63 +64,16 @@ function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local')
|
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
// Load auto-accumulated experience (always from user scope)
|
|
60
|
-
|
|
61
|
-
const experienceContent = exp.getExperience()
|
|
62
|
-
|
|
67
|
+
// Load auto-accumulated experience rules (always from user scope)
|
|
68
|
+
// Gated by crsi.ruleInjection feature flag
|
|
63
69
|
let experienceMemory = ''
|
|
64
|
-
if (
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const failureIdx = lines.findIndex((l) => l.startsWith('## Failure Patterns'))
|
|
69
|
-
|
|
70
|
-
// Header: content before the first section
|
|
71
|
-
const firstSection = Math.min(
|
|
72
|
-
successIdx !== -1 ? successIdx : Infinity,
|
|
73
|
-
failureIdx !== -1 ? failureIdx : Infinity,
|
|
74
|
-
statsIdx !== -1 ? statsIdx : Infinity,
|
|
75
|
-
)
|
|
76
|
-
const header = lines.slice(0, firstSection === Infinity ? 3 : firstSection)
|
|
77
|
-
|
|
78
|
-
// Last 5 success entries (summary line only, not detail)
|
|
79
|
-
const successLines: string[] = []
|
|
80
|
-
if (successIdx !== -1) {
|
|
81
|
-
const endIdx = Math.min(
|
|
82
|
-
failureIdx !== -1 && failureIdx > successIdx ? failureIdx : Infinity,
|
|
83
|
-
statsIdx !== -1 && statsIdx > successIdx ? statsIdx : Infinity,
|
|
84
|
-
)
|
|
85
|
-
successLines.push(
|
|
86
|
-
...lines
|
|
87
|
-
.slice(successIdx + 1, endIdx === Infinity ? undefined : endIdx)
|
|
88
|
-
.filter((l) => l.startsWith('- ['))
|
|
89
|
-
.slice(-5),
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Last 3 failure entries (summary line only, not detail)
|
|
94
|
-
const failureLines: string[] = []
|
|
95
|
-
if (failureIdx !== -1) {
|
|
96
|
-
const endIdx = statsIdx !== -1 && statsIdx > failureIdx ? statsIdx : Infinity
|
|
97
|
-
failureLines.push(
|
|
98
|
-
...lines
|
|
99
|
-
.slice(failureIdx + 1, endIdx === Infinity ? undefined : endIdx)
|
|
100
|
-
.filter((l) => l.startsWith('- ['))
|
|
101
|
-
.slice(-3),
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Stats section (header + stat line)
|
|
106
|
-
const stats = statsIdx !== -1 ? lines.slice(statsIdx, statsIdx + 3) : []
|
|
70
|
+
if (crsiRuleInjection) {
|
|
71
|
+
const exp = new AgentExperience(agentName)
|
|
72
|
+
const extractor = new ExperienceRuleExtractor()
|
|
73
|
+
const rules = extractor.prioritize(exp.getRules())
|
|
107
74
|
|
|
108
|
-
if (
|
|
109
|
-
experienceMemory =
|
|
110
|
-
'## Agent Experience',
|
|
111
|
-
...header.filter((l) => l.trim()),
|
|
112
|
-
...successLines,
|
|
113
|
-
...failureLines,
|
|
114
|
-
...stats,
|
|
115
|
-
].join('\n')
|
|
75
|
+
if (rules.length > 0) {
|
|
76
|
+
experienceMemory = extractor.formatForInjection(rules)
|
|
116
77
|
}
|
|
117
78
|
}
|
|
118
79
|
|
|
@@ -139,6 +100,7 @@ export function createAgentContext(
|
|
|
139
100
|
agentDef: AgentDefinition,
|
|
140
101
|
toolRegistry: Map<string, ToolDefinition>,
|
|
141
102
|
contextWindow?: number,
|
|
103
|
+
crsiConfig?: Partial<CrsiConfig>,
|
|
142
104
|
): AgentContextResult {
|
|
143
105
|
// Create isolated context
|
|
144
106
|
const context = new ContextManager({
|
|
@@ -149,7 +111,8 @@ export function createAgentContext(
|
|
|
149
111
|
// Build system prompt with optional agent memory
|
|
150
112
|
let systemPrompt = agentDef.systemPrompt
|
|
151
113
|
if (agentDef.memory) {
|
|
152
|
-
const
|
|
114
|
+
const ruleInjection = crsiConfig?.ruleInjection !== false
|
|
115
|
+
const memory = loadAgentMemory(agentDef.name, agentDef.memory, ruleInjection)
|
|
153
116
|
if (memory) {
|
|
154
117
|
systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
|
|
155
118
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { ExperienceRuleExtractor, type ExperienceRule } from './experience-rules.js'
|
|
3
4
|
|
|
4
5
|
const MAX_EXPERIENCES = 20
|
|
5
6
|
|
|
@@ -46,6 +47,13 @@ export class AgentExperience {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
getRules(): ExperienceRule[] {
|
|
51
|
+
const content = this.getExperience()
|
|
52
|
+
if (!content) return []
|
|
53
|
+
const extractor = new ExperienceRuleExtractor()
|
|
54
|
+
return extractor.extract(content, this.agentName)
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
private appendToSection(section: string, entry: string, type: 'success' | 'failure'): void {
|
|
50
58
|
mkdirSync(this.expDir, { recursive: true })
|
|
51
59
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface RuleEffectiveness {
|
|
5
|
+
ruleId: string
|
|
6
|
+
appliedCount: number
|
|
7
|
+
successAfterCount: number
|
|
8
|
+
preRuleFailureRate: number
|
|
9
|
+
postRuleFailureRate: number
|
|
10
|
+
status: 'active' | 'degrading' | 'disabled'
|
|
11
|
+
createdAt: string
|
|
12
|
+
lastEvaluatedAt: string
|
|
13
|
+
evaluationHistory: Array<{
|
|
14
|
+
date: string
|
|
15
|
+
appliedCount: number
|
|
16
|
+
failureRate: number
|
|
17
|
+
}>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EVAL_THRESHOLD = 10 // evaluate after 10 applications
|
|
21
|
+
const STORE_FILE = 'effectiveness.json'
|
|
22
|
+
|
|
23
|
+
export class EffectivenessTracker {
|
|
24
|
+
private data: Map<string, RuleEffectiveness>
|
|
25
|
+
private storePath: string
|
|
26
|
+
|
|
27
|
+
constructor(storeDir: string = join(process.env.HOME || '~', '.mipham', 'rule-engine')) {
|
|
28
|
+
this.storePath = join(storeDir, STORE_FILE)
|
|
29
|
+
this.data = new Map()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
recordApplication(ruleId: string, success: boolean): void {
|
|
33
|
+
let eff = this.data.get(ruleId)
|
|
34
|
+
if (!eff) {
|
|
35
|
+
eff = {
|
|
36
|
+
ruleId,
|
|
37
|
+
appliedCount: 0,
|
|
38
|
+
successAfterCount: 0,
|
|
39
|
+
preRuleFailureRate: 1.0,
|
|
40
|
+
postRuleFailureRate: 0,
|
|
41
|
+
status: 'active',
|
|
42
|
+
createdAt: new Date().toISOString().slice(0, 10),
|
|
43
|
+
lastEvaluatedAt: '',
|
|
44
|
+
evaluationHistory: [],
|
|
45
|
+
}
|
|
46
|
+
this.data.set(ruleId, eff)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
eff.appliedCount++
|
|
50
|
+
if (success) eff.successAfterCount++
|
|
51
|
+
|
|
52
|
+
// Calculate current failure rate
|
|
53
|
+
if (eff.appliedCount >= EVAL_THRESHOLD) {
|
|
54
|
+
eff.postRuleFailureRate = 1 - eff.successAfterCount / eff.appliedCount
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Evaluate rule effectiveness and return auto-management actions.
|
|
60
|
+
*
|
|
61
|
+
* @param autoRuleManagement — when false, skip auto-degrade/disable/enable logic
|
|
62
|
+
* but still record evaluation history for manual review. Default true.
|
|
63
|
+
*/
|
|
64
|
+
evaluate(autoRuleManagement = true): {
|
|
65
|
+
upgrades: string[]
|
|
66
|
+
degradations: string[]
|
|
67
|
+
disables: string[]
|
|
68
|
+
} {
|
|
69
|
+
const result = {
|
|
70
|
+
upgrades: [] as string[],
|
|
71
|
+
degradations: [] as string[],
|
|
72
|
+
disables: [] as string[],
|
|
73
|
+
}
|
|
74
|
+
const now = new Date().toISOString().slice(0, 10)
|
|
75
|
+
|
|
76
|
+
for (const [ruleId, eff] of this.data) {
|
|
77
|
+
if (eff.appliedCount < EVAL_THRESHOLD) continue
|
|
78
|
+
|
|
79
|
+
eff.lastEvaluatedAt = now
|
|
80
|
+
eff.evaluationHistory.push({
|
|
81
|
+
date: now,
|
|
82
|
+
appliedCount: eff.appliedCount,
|
|
83
|
+
failureRate: eff.postRuleFailureRate,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Keep max 10 history entries
|
|
87
|
+
if (eff.evaluationHistory.length > 10) {
|
|
88
|
+
eff.evaluationHistory = eff.evaluationHistory.slice(-10)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Auto-management is gated by crsi.autoRuleManagement feature flag
|
|
92
|
+
if (!autoRuleManagement) continue
|
|
93
|
+
|
|
94
|
+
// Degrade: active rule with high failure rate
|
|
95
|
+
if (eff.status === 'active' && eff.postRuleFailureRate > 0.6) {
|
|
96
|
+
eff.status = 'degrading'
|
|
97
|
+
result.degradations.push(ruleId)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Disable: degrading rule with no improvement across evaluations
|
|
101
|
+
if (eff.status === 'degrading' && eff.evaluationHistory.length >= 2) {
|
|
102
|
+
const last = eff.evaluationHistory[eff.evaluationHistory.length - 1]
|
|
103
|
+
const prev = eff.evaluationHistory[eff.evaluationHistory.length - 2]
|
|
104
|
+
if (last!.failureRate >= prev!.failureRate) {
|
|
105
|
+
eff.status = 'disabled'
|
|
106
|
+
result.disables.push(ruleId)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Upgrade: degrading rule that has improved significantly
|
|
111
|
+
if (eff.status === 'degrading' && eff.postRuleFailureRate < 0.4) {
|
|
112
|
+
eff.status = 'active'
|
|
113
|
+
result.upgrades.push(ruleId)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getEffectiveness(ruleId: string): RuleEffectiveness | null {
|
|
121
|
+
return this.data.get(ruleId) || null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
persist(): void {
|
|
125
|
+
const dir = dirname(this.storePath)
|
|
126
|
+
mkdirSync(dir, { recursive: true })
|
|
127
|
+
|
|
128
|
+
const obj: Record<string, RuleEffectiveness> = {}
|
|
129
|
+
for (const [k, v] of this.data) {
|
|
130
|
+
obj[k] = v
|
|
131
|
+
}
|
|
132
|
+
writeFileSync(this.storePath, JSON.stringify(obj, null, 2), 'utf-8')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
load(): void {
|
|
136
|
+
if (!existsSync(this.storePath)) return
|
|
137
|
+
try {
|
|
138
|
+
const raw = JSON.parse(readFileSync(this.storePath, 'utf-8'))
|
|
139
|
+
this.data = new Map(Object.entries(raw))
|
|
140
|
+
} catch {
|
|
141
|
+
// Corrupt file — start fresh
|
|
142
|
+
this.data = new Map()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get allRules(): RuleEffectiveness[] {
|
|
147
|
+
return Array.from(this.data.values())
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export interface ExperienceRule {
|
|
2
|
+
id: string // rule-<category>-<shortHash>
|
|
3
|
+
type: 'mandatory' | 'warning'
|
|
4
|
+
condition: string
|
|
5
|
+
action: string
|
|
6
|
+
evidence: {
|
|
7
|
+
failureCount: number
|
|
8
|
+
lastFailure: string // ISO date
|
|
9
|
+
examples: string[]
|
|
10
|
+
}
|
|
11
|
+
category: 'timeout' | 'import' | 'search' | 'tool-params' | 'semantic'
|
|
12
|
+
source: 'agent-experience' | 'manual' | 'pattern-analyzer'
|
|
13
|
+
agentName: string
|
|
14
|
+
createdAt: string // ISO date
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FailureEntry {
|
|
18
|
+
date: string
|
|
19
|
+
description: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseFailureEntries(content: string): FailureEntry[] {
|
|
23
|
+
const entries: FailureEntry[] = []
|
|
24
|
+
const failureIdx = content.indexOf('## Failure Patterns')
|
|
25
|
+
if (failureIdx === -1) return entries
|
|
26
|
+
|
|
27
|
+
const afterFailure = content.slice(failureIdx)
|
|
28
|
+
const nextSection = afterFailure.indexOf('\n## ', '## Failure Patterns'.length)
|
|
29
|
+
const section = nextSection !== -1 ? afterFailure.slice(0, nextSection) : afterFailure
|
|
30
|
+
|
|
31
|
+
const lines = section.split('\n')
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const match = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]\s+(.+)/)
|
|
34
|
+
if (match && match[1] && match[2]) {
|
|
35
|
+
entries.push({ date: match[1], description: match[2].trim() })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return entries
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function categorize(description: string): ExperienceRule['category'] {
|
|
42
|
+
const lower = description.toLowerCase()
|
|
43
|
+
if (lower.includes('timeout')) return 'timeout'
|
|
44
|
+
if (/module_not_found|import|\.js/.test(lower)) return 'import'
|
|
45
|
+
if (/grep|token.*overflow|search.*scope/.test(lower)) return 'search'
|
|
46
|
+
if (/bash|command.*fail|docker|npm|pnpm|cargo|brew/.test(lower)) return 'tool-params'
|
|
47
|
+
return 'semantic'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createRuleId(category: string, description: string): string {
|
|
51
|
+
// Hash the first 30 chars of description for uniqueness
|
|
52
|
+
const hash = description
|
|
53
|
+
.slice(0, 30)
|
|
54
|
+
.split('')
|
|
55
|
+
.reduce((acc, c) => {
|
|
56
|
+
return ((acc << 5) - acc + c.charCodeAt(0)) | 0
|
|
57
|
+
}, 0)
|
|
58
|
+
.toString(36)
|
|
59
|
+
.slice(-6)
|
|
60
|
+
return `rule-${category}-${hash}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function conditionForCategory(category: string, entries: FailureEntry[]): string {
|
|
64
|
+
const combined = entries.map((e) => e.description).join('; ')
|
|
65
|
+
switch (category) {
|
|
66
|
+
case 'timeout':
|
|
67
|
+
return 'heavy CLI commands (npm/docker/pnpm)'
|
|
68
|
+
case 'import':
|
|
69
|
+
return 'ESM module imports missing .js extension'
|
|
70
|
+
case 'search':
|
|
71
|
+
return 'full-repository Grep without directory scoping'
|
|
72
|
+
case 'tool-params':
|
|
73
|
+
return combined.slice(0, 100)
|
|
74
|
+
default:
|
|
75
|
+
return combined.slice(0, 100)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function actionForCategory(category: string): string {
|
|
80
|
+
switch (category) {
|
|
81
|
+
case 'timeout':
|
|
82
|
+
return 'set Bash timeout ≥ 300000ms for heavy commands'
|
|
83
|
+
case 'import':
|
|
84
|
+
return 'always append .js extension to ESM relative imports'
|
|
85
|
+
case 'search':
|
|
86
|
+
return 'use Glob to narrow directory before Grep'
|
|
87
|
+
case 'tool-params':
|
|
88
|
+
return 'review and adjust tool parameters before execution'
|
|
89
|
+
default:
|
|
90
|
+
return 'verify tool parameters match known good patterns'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ExperienceRuleExtractor {
|
|
95
|
+
extract(content: string, agentName: string): ExperienceRule[] {
|
|
96
|
+
if (!content || !content.includes('## Failure Patterns')) return []
|
|
97
|
+
|
|
98
|
+
const entries = parseFailureEntries(content)
|
|
99
|
+
if (entries.length < 2) return []
|
|
100
|
+
|
|
101
|
+
// Group by category
|
|
102
|
+
const byCategory = new Map<string, FailureEntry[]>()
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const cat = categorize(entry.description)
|
|
105
|
+
const existing = byCategory.get(cat) || []
|
|
106
|
+
existing.push(entry)
|
|
107
|
+
byCategory.set(cat, existing)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const rules: ExperienceRule[] = []
|
|
111
|
+
for (const [category, catEntries] of byCategory) {
|
|
112
|
+
if (catEntries.length < 2) continue
|
|
113
|
+
|
|
114
|
+
const type: 'mandatory' | 'warning' = catEntries.length >= 3 ? 'mandatory' : 'warning'
|
|
115
|
+
const firstEntry = catEntries[0]
|
|
116
|
+
const lastEntry = catEntries[catEntries.length - 1]
|
|
117
|
+
if (!firstEntry || !lastEntry) continue
|
|
118
|
+
|
|
119
|
+
rules.push({
|
|
120
|
+
id: createRuleId(category, firstEntry.description),
|
|
121
|
+
type,
|
|
122
|
+
condition: conditionForCategory(category, catEntries),
|
|
123
|
+
action: actionForCategory(category),
|
|
124
|
+
evidence: {
|
|
125
|
+
failureCount: catEntries.length,
|
|
126
|
+
lastFailure: lastEntry.date,
|
|
127
|
+
examples: catEntries.slice(0, 3).map((e) => e.description),
|
|
128
|
+
},
|
|
129
|
+
category: category as ExperienceRule['category'],
|
|
130
|
+
source: 'agent-experience',
|
|
131
|
+
agentName,
|
|
132
|
+
createdAt: new Date().toISOString().slice(0, 10),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return rules
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
prioritize(rules: ExperienceRule[]): ExperienceRule[] {
|
|
140
|
+
const order: Record<string, number> = {
|
|
141
|
+
mandatory: 0,
|
|
142
|
+
warning: 1,
|
|
143
|
+
}
|
|
144
|
+
return [...rules].sort((a, b) => {
|
|
145
|
+
const aOrder = order[a.type] ?? 0
|
|
146
|
+
const bOrder = order[b.type] ?? 0
|
|
147
|
+
const typeDiff = aOrder - bOrder
|
|
148
|
+
if (typeDiff !== 0) return typeDiff
|
|
149
|
+
// Within same type, more failures first
|
|
150
|
+
return b.evidence.failureCount - a.evidence.failureCount
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
formatForInjection(rules: ExperienceRule[]): string {
|
|
155
|
+
if (rules.length === 0) return ''
|
|
156
|
+
|
|
157
|
+
const mandatory = rules.filter((r) => r.type === 'mandatory')
|
|
158
|
+
const warnings = rules.filter((r) => r.type === 'warning')
|
|
159
|
+
|
|
160
|
+
let output = ''
|
|
161
|
+
|
|
162
|
+
if (mandatory.length > 0) {
|
|
163
|
+
output += '## ⚠️ Active Mandatory Rules (learned from past failures)\n\n'
|
|
164
|
+
for (const r of mandatory) {
|
|
165
|
+
const idx = mandatory.indexOf(r) + 1
|
|
166
|
+
output += `${idx}. [${r.category}] ${r.condition} → ${r.action}\n`
|
|
167
|
+
output += ` Evidence: ${r.evidence.failureCount} failures`
|
|
168
|
+
if (r.evidence.lastFailure) output += `, last: ${r.evidence.lastFailure}`
|
|
169
|
+
output += '\n'
|
|
170
|
+
if (r.evidence.examples.length > 0) {
|
|
171
|
+
output += ` Example: ${r.evidence.examples[0]}\n`
|
|
172
|
+
}
|
|
173
|
+
output += '\n'
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (warnings.length > 0) {
|
|
178
|
+
output += '## ⚡ Observed Patterns (warning level)\n\n'
|
|
179
|
+
for (const r of warnings) {
|
|
180
|
+
const idx = warnings.indexOf(r) + 1
|
|
181
|
+
output += `${idx}. [${r.category}] ${r.condition} → ${r.action}\n`
|
|
182
|
+
output += ` Evidence: ${r.evidence.failureCount} occurrences\n\n`
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return output.trim()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { AgentExperience } from './agent-experience.js'
|
|
5
|
+
import { parseFailureEntries, categorize, type ExperienceRule } from './experience-rules.js'
|
|
6
|
+
import type { ToolRule } from '../core/rule-engine.js'
|
|
7
|
+
|
|
8
|
+
export interface Pattern {
|
|
9
|
+
id: string
|
|
10
|
+
category: 'timeout' | 'import' | 'search' | 'tool-params' | 'semantic'
|
|
11
|
+
agentName: string
|
|
12
|
+
frequency: number
|
|
13
|
+
confidence: 'high' | 'medium' | 'low'
|
|
14
|
+
examples: string[]
|
|
15
|
+
firstSeen: string
|
|
16
|
+
lastSeen: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class PatternAnalyzer {
|
|
20
|
+
analyzeAgent(agentName: string, baseDir?: string): Pattern[] {
|
|
21
|
+
const dir = baseDir || join(homedir(), '.mipham', 'agent-memory')
|
|
22
|
+
const exp = new AgentExperience(agentName, dir)
|
|
23
|
+
const content = exp.getExperience()
|
|
24
|
+
if (!content) return []
|
|
25
|
+
|
|
26
|
+
const allEntries = parseFailureEntries(content)
|
|
27
|
+
|
|
28
|
+
// Group by category
|
|
29
|
+
const byCategory = new Map<string, { descriptions: string[]; dates: string[] }>()
|
|
30
|
+
for (const entry of allEntries) {
|
|
31
|
+
const cat = categorize(entry.description)
|
|
32
|
+
const existing = byCategory.get(cat) || { descriptions: [], dates: [] }
|
|
33
|
+
existing.descriptions.push(entry.description)
|
|
34
|
+
existing.dates.push(entry.date)
|
|
35
|
+
byCategory.set(cat, existing)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const patterns: Pattern[] = []
|
|
39
|
+
for (const [category, data] of byCategory) {
|
|
40
|
+
if (data.descriptions.length < 3) continue
|
|
41
|
+
|
|
42
|
+
patterns.push({
|
|
43
|
+
id: `pattern-${category}-${agentName}`,
|
|
44
|
+
category: category as Pattern['category'],
|
|
45
|
+
agentName,
|
|
46
|
+
frequency: data.descriptions.length,
|
|
47
|
+
confidence: data.descriptions.length >= 5 ? 'high' : 'medium',
|
|
48
|
+
examples: data.descriptions.slice(0, 5),
|
|
49
|
+
firstSeen: data.dates[0] ?? '',
|
|
50
|
+
lastSeen: data.dates[data.dates.length - 1] ?? '',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return patterns
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
analyzeAllAgents(baseDir?: string): Pattern[] {
|
|
58
|
+
const dir = baseDir || join(homedir(), '.mipham', 'agent-memory')
|
|
59
|
+
if (!existsSync(dir)) return []
|
|
60
|
+
|
|
61
|
+
let agents: string[]
|
|
62
|
+
try {
|
|
63
|
+
agents = readdirSync(dir, { withFileTypes: true })
|
|
64
|
+
.filter((d) => d.isDirectory())
|
|
65
|
+
.map((d) => d.name)
|
|
66
|
+
} catch {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const allPatterns: Pattern[] = []
|
|
71
|
+
for (const agent of agents) {
|
|
72
|
+
allPatterns.push(...this.analyzeAgent(agent, baseDir))
|
|
73
|
+
}
|
|
74
|
+
return allPatterns
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
toRule(pattern: Pattern): ExperienceRule {
|
|
78
|
+
return {
|
|
79
|
+
id: `${pattern.id}-${Date.now().toString(36)}`,
|
|
80
|
+
type: pattern.frequency >= 3 ? 'mandatory' : 'warning',
|
|
81
|
+
condition: this._conditionForCategory(pattern),
|
|
82
|
+
action: this._actionForCategory(pattern.category),
|
|
83
|
+
evidence: {
|
|
84
|
+
failureCount: pattern.frequency,
|
|
85
|
+
lastFailure: pattern.lastSeen,
|
|
86
|
+
examples: pattern.examples.slice(0, 3),
|
|
87
|
+
},
|
|
88
|
+
category: pattern.category,
|
|
89
|
+
source: 'pattern-analyzer',
|
|
90
|
+
agentName: pattern.agentName,
|
|
91
|
+
createdAt: new Date().toISOString().slice(0, 10),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
toToolRule(pattern: Pattern): ToolRule {
|
|
96
|
+
const toolNameMap: Record<string, string> = {
|
|
97
|
+
timeout: 'Bash',
|
|
98
|
+
'tool-params': 'Bash',
|
|
99
|
+
import: 'Write',
|
|
100
|
+
search: 'Grep',
|
|
101
|
+
semantic: 'Bash',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id: pattern.id,
|
|
106
|
+
toolName: toolNameMap[pattern.category] || 'Bash',
|
|
107
|
+
category: pattern.category,
|
|
108
|
+
match: (p: Record<string, unknown>): boolean => {
|
|
109
|
+
if (pattern.category === 'timeout') {
|
|
110
|
+
const cmd = String(p.command ?? '')
|
|
111
|
+
const heavy = /npm|docker|pnpm|cargo|brew|install|build/.test(cmd)
|
|
112
|
+
if (!heavy) return false
|
|
113
|
+
const timeout = (p as Record<string, unknown>).timeout as number | undefined
|
|
114
|
+
return !timeout || timeout < 300_000
|
|
115
|
+
}
|
|
116
|
+
return true
|
|
117
|
+
},
|
|
118
|
+
fix: (p: Record<string, unknown>) => ({
|
|
119
|
+
modified: pattern.category === 'timeout' ? { ...p, timeout: 300_000 } : p,
|
|
120
|
+
warning: `🤖 [auto-rule] ${pattern.category}: ${pattern.frequency} 次同类失败 — ${this._actionForCategory(pattern.category)}`,
|
|
121
|
+
}),
|
|
122
|
+
source: 'pattern-analyzer',
|
|
123
|
+
enabled: true,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Private helpers ──
|
|
128
|
+
|
|
129
|
+
private _conditionForCategory(pattern: Pattern): string {
|
|
130
|
+
switch (pattern.category) {
|
|
131
|
+
case 'timeout':
|
|
132
|
+
return 'heavy CLI commands (npm/docker/pnpm/cargo)'
|
|
133
|
+
case 'import':
|
|
134
|
+
return 'ESM module imports missing .js extension'
|
|
135
|
+
case 'search':
|
|
136
|
+
return 'full-repository search without directory scoping'
|
|
137
|
+
default:
|
|
138
|
+
return pattern.examples[0]?.slice(0, 100) || 'unknown condition'
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private _actionForCategory(category: string): string {
|
|
143
|
+
switch (category) {
|
|
144
|
+
case 'timeout':
|
|
145
|
+
return 'set Bash timeout ≥ 300000ms for heavy commands'
|
|
146
|
+
case 'import':
|
|
147
|
+
return 'append .js extension to ESM relative imports'
|
|
148
|
+
case 'search':
|
|
149
|
+
return 'use Glob to narrow directory before Grep'
|
|
150
|
+
default:
|
|
151
|
+
return 'review and adjust tool parameters before execution'
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|