@miphamai/cli 0.32.7 → 0.33.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,324 @@
1
+ /**
2
+ * SIS (Self-Immune System) Phase 0: Error Signature Database
3
+ *
4
+ * Persistent storage for known error patterns. Each signature captures:
5
+ * - What error occurred (pattern matching against command/error text)
6
+ * - How to fix it (strategy + action)
7
+ * - How reliable the fix is (success rate tracking)
8
+ *
9
+ * Storage: JSON file at ~/.mipham/sis/error-signatures.json
10
+ * Pattern: follows EffectivenessTracker's file-based persistence model
11
+ *
12
+ * Integration:
13
+ * AutoMemoryEngine.feedCrsiPipeline() → ErrorSignatureDB.insert()
14
+ * PreFlightChecker.check() → ErrorSignatureDB.match()
15
+ */
16
+
17
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { homedir } from 'node:os'
20
+ import { randomUUID } from 'node:crypto'
21
+
22
+ // ── Types ──
23
+
24
+ export interface ErrorSignature {
25
+ /** Unique identifier */
26
+ id: string
27
+ /** Substring or regex pattern to match against command/error text */
28
+ pattern: string
29
+ /** Failure category: timeout | tool-params | import | search | semantic */
30
+ category: string
31
+ /** Associated tool name (e.g. 'Bash', 'Write') */
32
+ toolName: string
33
+ /** How to fix: replace params, prepend to command, append, warn only, or block entirely */
34
+ fixStrategy: 'replace' | 'prepend' | 'append' | 'warn' | 'block'
35
+ /** The fix action (e.g. 'pnpm install' replaces 'npm install') */
36
+ fixAction: string
37
+ /** Human-readable explanation of the fix */
38
+ explanation: string
39
+ /** Number of times this error has been encountered */
40
+ occurrences: number
41
+ /** Number of times the fix was applied successfully */
42
+ successCount: number
43
+ /** ISO timestamp of first encounter */
44
+ firstSeen: string
45
+ /** ISO timestamp of most recent encounter */
46
+ lastSeen: string
47
+ /** Fix success rate (0.0-1.0) */
48
+ successRate: number
49
+ /** Current status */
50
+ status: 'active' | 'degraded' | 'retired'
51
+ }
52
+
53
+ export interface ErrorSignatureStats {
54
+ total: number
55
+ active: number
56
+ degraded: number
57
+ retired: number
58
+ avgSuccessRate: number
59
+ totalInterceptions: number
60
+ }
61
+
62
+ // ── Constants ──
63
+
64
+ const DEFAULT_STORE_DIR = join(homedir(), '.mipham', 'sis')
65
+ const STORE_FILE = 'error-signatures.json'
66
+ const MIN_SUCCESS_RATE = 0.5 // below this → degraded
67
+ const RETIREMENT_RATE = 0.2 // below this and > 90 days old → retired
68
+ const RETENTION_DAYS = 90 // auto-clean retired signatures older than this
69
+
70
+ // ── Database ──
71
+
72
+ export class ErrorSignatureDB {
73
+ private signatures: Map<string, ErrorSignature> = new Map()
74
+ private storePath: string
75
+ private dirty = false
76
+
77
+ constructor(storeDir: string = DEFAULT_STORE_DIR) {
78
+ this.storePath = join(storeDir, STORE_FILE)
79
+ this.load()
80
+ }
81
+
82
+ // ── Persistence ──
83
+
84
+ /** Load signatures from disk. Non-existent file = clean slate. */
85
+ private load(): void {
86
+ try {
87
+ if (!existsSync(this.storePath)) return
88
+ const raw = readFileSync(this.storePath, 'utf-8')
89
+ const arr: ErrorSignature[] = JSON.parse(raw)
90
+ for (const sig of arr) {
91
+ this.signatures.set(sig.id, sig)
92
+ }
93
+ } catch {
94
+ // Corrupted or missing file — start fresh
95
+ }
96
+ }
97
+
98
+ /** Persist signatures to disk. Debounced: only writes if dirty. */
99
+ private save(): void {
100
+ if (!this.dirty) return
101
+ try {
102
+ mkdirSync(join(this.storePath, '..'), { recursive: true })
103
+ const arr = Array.from(this.signatures.values())
104
+ writeFileSync(this.storePath, JSON.stringify(arr, null, 2), 'utf-8')
105
+ this.dirty = false
106
+ } catch {
107
+ // Best-effort persistence — never crash on write failure
108
+ }
109
+ }
110
+
111
+ // ── CRUD ──
112
+
113
+ /**
114
+ * Insert a new error signature. If a signature with the same pattern +
115
+ * toolName + category already exists, increment its occurrence count instead.
116
+ */
117
+ insert(
118
+ sig: Omit<
119
+ ErrorSignature,
120
+ 'id' | 'occurrences' | 'successCount' | 'successRate' | 'firstSeen' | 'lastSeen' | 'status'
121
+ >,
122
+ ): ErrorSignature {
123
+ // Dedup: same pattern + toolName + category → update existing
124
+ const existing = this.findByPattern(sig.pattern, sig.toolName, sig.category)
125
+ if (existing) {
126
+ existing.occurrences++
127
+ existing.lastSeen = new Date().toISOString()
128
+ existing.successRate =
129
+ existing.occurrences > 0 ? existing.successCount / existing.occurrences : 1.0
130
+ this.dirty = true
131
+ this.save()
132
+ return existing
133
+ }
134
+
135
+ const now = new Date().toISOString()
136
+ const id = `sis-${randomUUID().slice(0, 8)}`
137
+ const full: ErrorSignature = {
138
+ ...sig,
139
+ id,
140
+ occurrences: 1,
141
+ successCount: 0,
142
+ successRate: 0, // starts at 0 — must prove itself before auto-apply
143
+ firstSeen: now,
144
+ lastSeen: now,
145
+ status: 'active',
146
+ }
147
+
148
+ this.signatures.set(id, full)
149
+ this.dirty = true
150
+ this.save()
151
+ return full
152
+ }
153
+
154
+ /**
155
+ * Match a tool call against known error signatures.
156
+ * Returns the best-matching active signature, or null if no match.
157
+ *
158
+ * Matching strategy:
159
+ * 1. Exact toolName match
160
+ * 2. Pattern substring match in command/error text
161
+ * 3. Category match as tiebreaker
162
+ */
163
+ match(toolName: string, params: Record<string, unknown>): ErrorSignature | null {
164
+ const text = this.paramsToText(toolName, params)
165
+ let best: ErrorSignature | null = null
166
+ let bestScore = 0
167
+
168
+ for (const sig of this.signatures.values()) {
169
+ if (sig.status === 'retired') continue
170
+ if (sig.toolName !== toolName) continue
171
+
172
+ // Pattern must match — this is the primary filter
173
+ if (!text.toLowerCase().includes(sig.pattern.toLowerCase())) continue
174
+
175
+ // Score tiebreakers when multiple signatures match
176
+ let score = 10 // base score for pattern match
177
+ score += sig.successRate * 5
178
+ score += Math.min(sig.occurrences / 10, 1) * 2
179
+ if (sig.status === 'active') score += 1
180
+
181
+ if (score > bestScore) {
182
+ bestScore = score
183
+ best = sig
184
+ }
185
+ }
186
+
187
+ return best
188
+ }
189
+
190
+ /**
191
+ * Record the result of applying a fix for a given signature.
192
+ * Updates success rate and may auto-degrade/retire low-performing signatures.
193
+ */
194
+ recordResult(id: string, success: boolean): void {
195
+ const sig = this.signatures.get(id)
196
+ if (!sig) return
197
+
198
+ if (success) {
199
+ sig.successCount++
200
+ }
201
+ sig.successRate = sig.occurrences > 0 ? sig.successCount / sig.occurrences : 1.0
202
+
203
+ // Auto-degrade
204
+ if (sig.successRate < MIN_SUCCESS_RATE && sig.status === 'active') {
205
+ sig.status = 'degraded'
206
+ }
207
+ // Auto-retire
208
+ const ageDays = (Date.now() - new Date(sig.firstSeen).getTime()) / (1000 * 60 * 60 * 24)
209
+ if (sig.successRate < RETIREMENT_RATE && ageDays > 90 && sig.status === 'degraded') {
210
+ sig.status = 'retired'
211
+ }
212
+
213
+ this.dirty = true
214
+ this.save()
215
+ }
216
+
217
+ // ── Query ──
218
+
219
+ /** Get all active and degraded signatures (excludes retired). */
220
+ getActive(): ErrorSignature[] {
221
+ return Array.from(this.signatures.values())
222
+ .filter((s) => s.status !== 'retired')
223
+ .sort((a, b) => b.occurrences - a.occurrences)
224
+ }
225
+
226
+ /** Get a specific signature by ID. */
227
+ get(id: string): ErrorSignature | undefined {
228
+ return this.signatures.get(id)
229
+ }
230
+
231
+ /** Manually retire a signature. */
232
+ retire(id: string): boolean {
233
+ const sig = this.signatures.get(id)
234
+ if (!sig) return false
235
+ sig.status = 'retired'
236
+ this.dirty = true
237
+ this.save()
238
+ return true
239
+ }
240
+
241
+ /** Get aggregate statistics. */
242
+ getStats(): ErrorSignatureStats {
243
+ const all = Array.from(this.signatures.values())
244
+ const active = all.filter((s) => s.status === 'active')
245
+ const degraded = all.filter((s) => s.status === 'degraded')
246
+ const retired = all.filter((s) => s.status === 'retired')
247
+
248
+ const avgSuccessRate =
249
+ active.length > 0 ? active.reduce((sum, s) => sum + s.successRate, 0) / active.length : 0
250
+
251
+ return {
252
+ total: all.length,
253
+ active: active.length,
254
+ degraded: degraded.length,
255
+ retired: retired.length,
256
+ avgSuccessRate: Math.round(avgSuccessRate * 100) / 100,
257
+ totalInterceptions: all.reduce((sum, s) => sum + s.occurrences, 0),
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Clean up old retired signatures and low-performing degraded ones.
263
+ * Called periodically (e.g. on daemon startup or via /sis cleanup).
264
+ */
265
+ cleanup(retentionDays: number = RETENTION_DAYS): number {
266
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
267
+ let removed = 0
268
+
269
+ for (const [id, sig] of this.signatures) {
270
+ if (sig.status === 'retired' && new Date(sig.lastSeen).getTime() < cutoff) {
271
+ this.signatures.delete(id)
272
+ removed++
273
+ }
274
+ }
275
+
276
+ if (removed > 0) {
277
+ this.dirty = true
278
+ this.save()
279
+ }
280
+
281
+ return removed
282
+ }
283
+
284
+ /** Clear all signatures. Used for testing and manual reset. */
285
+ clear(): void {
286
+ this.signatures.clear()
287
+ this.dirty = true
288
+ this.save()
289
+ }
290
+
291
+ // ── Private Helpers ──
292
+
293
+ /** Find an existing signature by pattern + toolName + category. */
294
+ private findByPattern(
295
+ pattern: string,
296
+ toolName: string,
297
+ category: string,
298
+ ): ErrorSignature | undefined {
299
+ for (const sig of this.signatures.values()) {
300
+ if (sig.pattern === pattern && sig.toolName === toolName && sig.category === category) {
301
+ return sig
302
+ }
303
+ }
304
+ return undefined
305
+ }
306
+
307
+ /** Convert tool params to a searchable text blob for pattern matching. */
308
+ private paramsToText(toolName: string, params: Record<string, unknown>): string {
309
+ const parts: string[] = [toolName]
310
+ if (params.command && typeof params.command === 'string') {
311
+ parts.push(params.command)
312
+ }
313
+ if (params.file_path && typeof params.file_path === 'string') {
314
+ parts.push(params.file_path)
315
+ }
316
+ if (params.description && typeof params.description === 'string') {
317
+ parts.push(params.description)
318
+ }
319
+ if (params.error && typeof params.error === 'string') {
320
+ parts.push(params.error)
321
+ }
322
+ return parts.join(' ')
323
+ }
324
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * SIS Phase 2: Immune Memory Garbage Collection
3
+ *
4
+ * Periodic cleanup of the error signature database:
5
+ * - Removes retired signatures older than retention period
6
+ * - Merges near-duplicate signatures (same tool + category, similar pattern)
7
+ * - Auto-retires signatures with consistently zero success rate
8
+ *
9
+ * Designed to be called:
10
+ * - On daemon startup
11
+ * - Periodically (e.g. every 24h via ScheduleManager)
12
+ * - Manually via `/sis cleanup`
13
+ */
14
+
15
+ import type { ErrorSignatureDB, ErrorSignature } from './error-signature-db.js'
16
+
17
+ // ── Constants ──
18
+
19
+ /** Default retention period for retired signatures (days) */
20
+ const DEFAULT_RETENTION_DAYS = 90
21
+
22
+ /** Signatures with this many occurrences and 0 successes → auto-retire */
23
+ const ZERO_SUCCESS_THRESHOLD = 10
24
+
25
+ /** Levenshtein distance threshold for near-duplicate detection */
26
+ const SIMILARITY_THRESHOLD = 0.8
27
+
28
+ // ── Types ──
29
+
30
+ export interface GCReport {
31
+ /** Number of retired signatures removed */
32
+ retiredRemoved: number
33
+ /** Number of zero-success signatures auto-retired */
34
+ zeroSuccessRetired: number
35
+ /** Number of near-duplicate signatures merged */
36
+ duplicatesMerged: number
37
+ /** Total signatures before cleanup */
38
+ before: number
39
+ /** Total signatures after cleanup */
40
+ after: number
41
+ }
42
+
43
+ // ── GC Engine ──
44
+
45
+ export class ImmuneMemoryGC {
46
+ private errorDB: ErrorSignatureDB
47
+
48
+ constructor(errorDB: ErrorSignatureDB) {
49
+ this.errorDB = errorDB
50
+ }
51
+
52
+ /**
53
+ * Run a full garbage collection cycle.
54
+ *
55
+ * @param retentionDays — days to retain retired signatures (default 90)
56
+ * @returns GCReport with cleanup statistics
57
+ */
58
+ collect(retentionDays: number = DEFAULT_RETENTION_DAYS): GCReport {
59
+ const before = this.errorDB.getStats().total
60
+
61
+ // Phase 1: Remove old retired signatures
62
+ const retiredRemoved = this.errorDB.cleanup(retentionDays)
63
+
64
+ // Phase 2: Auto-retire zero-success signatures
65
+ const zeroSuccessRetired = this.retireZeroSuccess()
66
+
67
+ // Phase 3: Merge near-duplicates
68
+ const duplicatesMerged = this.mergeDuplicates()
69
+
70
+ const after = this.errorDB.getStats().total
71
+
72
+ return {
73
+ retiredRemoved,
74
+ zeroSuccessRetired,
75
+ duplicatesMerged,
76
+ before,
77
+ after,
78
+ }
79
+ }
80
+
81
+ // ── Private ──
82
+
83
+ /**
84
+ * Auto-retire signatures that have been tried many times
85
+ * but never succeeded. These are likely bad or outdated fixes.
86
+ */
87
+ private retireZeroSuccess(): number {
88
+ let count = 0
89
+ const active = this.errorDB.getActive()
90
+
91
+ for (const sig of active) {
92
+ if (
93
+ sig.occurrences >= ZERO_SUCCESS_THRESHOLD &&
94
+ sig.successCount === 0 &&
95
+ sig.status === 'active'
96
+ ) {
97
+ this.errorDB.retire(sig.id)
98
+ count++
99
+ }
100
+ }
101
+
102
+ return count
103
+ }
104
+
105
+ /**
106
+ * Merge near-duplicate signatures.
107
+ * Two signatures are duplicates if they share the same toolName + category
108
+ * and their patterns are textually similar (high substring overlap).
109
+ *
110
+ * The older signature absorbs the newer one's occurrence count.
111
+ */
112
+ private mergeDuplicates(): number {
113
+ let count = 0
114
+ const active = this.errorDB.getActive()
115
+ const merged = new Set<string>()
116
+
117
+ for (let i = 0; i < active.length; i++) {
118
+ const sigI = active[i]!
119
+ if (merged.has(sigI.id)) continue
120
+
121
+ for (let j = i + 1; j < active.length; j++) {
122
+ const sigJ = active[j]!
123
+ if (merged.has(sigJ.id)) continue
124
+
125
+ if (this.areSimilar(sigI, sigJ)) {
126
+ // Merge j into i (keep the older, more established one)
127
+ this.merge(sigI, sigJ)
128
+ merged.add(sigJ.id)
129
+ count++
130
+ }
131
+ }
132
+ }
133
+
134
+ return count
135
+ }
136
+
137
+ /** Check if two signatures are similar enough to be merged. */
138
+ private areSimilar(a: ErrorSignature, b: ErrorSignature): boolean {
139
+ // Must share toolName and category
140
+ if (a.toolName !== b.toolName) return false
141
+ if (a.category !== b.category) return false
142
+
143
+ // Check pattern similarity via substring containment
144
+ const shorter = a.pattern.length < b.pattern.length ? a.pattern : b.pattern
145
+ const longer = a.pattern.length < b.pattern.length ? b.pattern : a.pattern
146
+
147
+ if (longer.includes(shorter)) return true
148
+
149
+ // Check Levenshtein-like similarity
150
+ const similarity = this.jaccardSimilarity(shorter, longer)
151
+ return similarity >= SIMILARITY_THRESHOLD
152
+ }
153
+
154
+ /** Jaccard similarity on word tokens (simple and fast). */
155
+ private jaccardSimilarity(a: string, b: string): number {
156
+ const tokensA = new Set(a.toLowerCase().split(/\s+/))
157
+ const tokensB = new Set(b.toLowerCase().split(/\s+/))
158
+
159
+ let intersection = 0
160
+ for (const t of tokensA) {
161
+ if (tokensB.has(t)) intersection++
162
+ }
163
+
164
+ const union = tokensA.size + tokensB.size - intersection
165
+ return union > 0 ? intersection / union : 0
166
+ }
167
+
168
+ /** Merge signature `from` into `to`. Retire `from`. */
169
+ private merge(to: ErrorSignature, from: ErrorSignature): void {
170
+ // Transfer occurrences
171
+ to.occurrences += from.occurrences
172
+ to.successCount += from.successCount
173
+ to.successRate = to.occurrences > 0 ? to.successCount / to.occurrences : 1.0
174
+
175
+ // Keep the earlier firstSeen
176
+ if (from.firstSeen < to.firstSeen) {
177
+ to.firstSeen = from.firstSeen
178
+ }
179
+ // Keep the later lastSeen
180
+ if (from.lastSeen > to.lastSeen) {
181
+ to.lastSeen = from.lastSeen
182
+ }
183
+
184
+ // Retire the absorbed signature
185
+ this.errorDB.retire(from.id)
186
+ }
187
+ }