@miphamai/cli 0.33.2 → 0.34.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.
@@ -0,0 +1,552 @@
1
+ /**
2
+ * Auto-Dream: Background Memory Consolidation Engine
3
+ *
4
+ * Inspired by Claude Code's Auto-Dream mechanism: a periodic background process
5
+ * that reviews accumulated memory, resolves contradictions, merges related
6
+ * knowledge, and solidifies vague expressions into definite facts.
7
+ *
8
+ * Unlike Claude Code's LLM-driven approach, DreamEngine uses pure text analysis
9
+ * for the most common operations (dedup, contradiction detection via keyword
10
+ * overlap, stale entry cleanup). An optional summarizer callback enables
11
+ * LLM-powered deep consolidation when available.
12
+ *
13
+ * Designed to be called:
14
+ * - Periodically (every 24h or every 5 sessions) via daemon ScheduleManager
15
+ * - Manually via `/dream` CLI command
16
+ * - On session end as a lightweight pass
17
+ *
18
+ * Flow:
19
+ * Load all memory entries
20
+ * → Phase 1: Deduplicate near-identical entries
21
+ * → Phase 2: Detect contradictions (opposing claims)
22
+ * → Phase 3: Merge related entries (overlapping topics)
23
+ * → Phase 4: Solidify vague entries (flag "maybe"/"possibly" for review)
24
+ * → Phase 5: Prune stale entries (30+ days, low relevance)
25
+ * → Write consolidated memory + generate DreamReport
26
+ */
27
+
28
+ import {
29
+ readdirSync,
30
+ readFileSync,
31
+ writeFileSync,
32
+ unlinkSync,
33
+ existsSync,
34
+ mkdirSync,
35
+ statSync,
36
+ } from 'node:fs'
37
+ import { join } from 'node:path'
38
+ import { homedir } from 'node:os'
39
+
40
+ // ── Types ──
41
+
42
+ export interface DreamReport {
43
+ /** When the dream cycle ran */
44
+ timestamp: string
45
+ /** Total memories before consolidation */
46
+ beforeCount: number
47
+ /** Total memories after consolidation */
48
+ afterCount: number
49
+ /** Per-phase statistics */
50
+ phases: {
51
+ deduplicated: number
52
+ contradictionsFound: number
53
+ merged: number
54
+ solidified: number
55
+ pruned: number
56
+ }
57
+ /** Specific actions taken */
58
+ actions: DreamAction[]
59
+ /** Summary for display */
60
+ summary: string
61
+ }
62
+
63
+ export interface DreamAction {
64
+ type: 'dedup' | 'contradiction' | 'merge' | 'solidify' | 'prune'
65
+ description: string
66
+ filesAffected: string[]
67
+ autoApplied: boolean
68
+ }
69
+
70
+ interface MemoryFile {
71
+ path: string
72
+ name: string
73
+ frontmatter: Record<string, unknown>
74
+ content: string
75
+ /** Days since last modified */
76
+ age: number
77
+ }
78
+
79
+ // ── Constants ──
80
+
81
+ const DEFAULT_MEMORY_DIR = join(homedir(), '.mipham', 'memory')
82
+ const STALE_DAYS = 30
83
+ const SIMILARITY_THRESHOLD = 0.65
84
+
85
+ /** Vague qualifiers that suggest an entry needs solidification */
86
+ const VAGUE_PATTERNS = [
87
+ /\bmaybe\b/i,
88
+ /\bperhaps\b/i,
89
+ /\bpossibly\b/i,
90
+ /\bmight\b/i,
91
+ /\bcould be\b/i,
92
+ /\bunsure\b/i,
93
+ /\bunclear\b/i,
94
+ /\bseems?\b/i,
95
+ /\bprobably\b/i,
96
+ /\btypically\b/i,
97
+ /\busually\b/i,
98
+ /\boften\b/i,
99
+ ]
100
+
101
+ // ── Engine ──
102
+
103
+ export class DreamEngine {
104
+ private memoryDir: string
105
+ private dreamLog: string
106
+
107
+ constructor(memoryDir: string = DEFAULT_MEMORY_DIR) {
108
+ this.memoryDir = memoryDir
109
+ this.dreamLog = join(memoryDir, 'dream-log.json')
110
+ }
111
+
112
+ /**
113
+ * Run a full dream consolidation cycle.
114
+ *
115
+ * @param options.aggressive — if true, auto-apply more actions (default: safe-only)
116
+ * @returns DreamReport with statistics and actions taken
117
+ */
118
+ dream(options: { aggressive?: boolean } = {}): DreamReport {
119
+ const { aggressive = false } = options
120
+ const actions: DreamAction[] = []
121
+ const timestamp = new Date().toISOString()
122
+
123
+ // Load all memory files
124
+ const memories = this.loadMemories()
125
+ const beforeCount = memories.length
126
+
127
+ if (memories.length === 0) {
128
+ return {
129
+ timestamp,
130
+ beforeCount: 0,
131
+ afterCount: 0,
132
+ phases: { deduplicated: 0, contradictionsFound: 0, merged: 0, solidified: 0, pruned: 0 },
133
+ actions: [],
134
+ summary: 'No memories to consolidate. Dream skipped.',
135
+ }
136
+ }
137
+
138
+ // ── Phase 1: Deduplicate near-identical entries ──
139
+ const dedupResult = this.deduplicate(memories, aggressive)
140
+ actions.push(...dedupResult.actions)
141
+ let working = dedupResult.memories
142
+
143
+ // ── Phase 2: Detect contradictions ──
144
+ const contradictions = this.detectContradictions(working)
145
+ actions.push(...contradictions)
146
+ // Contradictions are flagged, not auto-resolved
147
+
148
+ // ── Phase 3: Merge related entries ──
149
+ const mergeResult = this.mergeRelated(working, aggressive)
150
+ actions.push(...mergeResult.actions)
151
+ working = mergeResult.memories
152
+
153
+ // ── Phase 4: Solidify vague entries ──
154
+ const solidifyActions = this.solidifyVague(working)
155
+ actions.push(...solidifyActions)
156
+ // Solidification just flags — human or LLM should resolve
157
+
158
+ // ── Phase 5: Prune stale entries ──
159
+ const pruneResult = this.pruneStale(working, aggressive)
160
+ actions.push(...pruneResult.actions)
161
+ working = pruneResult.memories
162
+
163
+ // ── Persist dream log ──
164
+ this.saveDreamLog(timestamp, actions)
165
+
166
+ const afterCount = working.length
167
+ const autoApplied = actions.filter((a) => a.autoApplied).length
168
+ const flagged = actions.filter((a) => !a.autoApplied).length
169
+
170
+ return {
171
+ timestamp,
172
+ beforeCount,
173
+ afterCount,
174
+ phases: {
175
+ deduplicated: dedupResult.actions.length,
176
+ contradictionsFound: contradictions.length,
177
+ merged: mergeResult.actions.length,
178
+ solidified: solidifyActions.length,
179
+ pruned: pruneResult.actions.length,
180
+ },
181
+ actions,
182
+ summary: [
183
+ `Dream complete: ${beforeCount} → ${afterCount} memories`,
184
+ `${autoApplied} actions auto-applied, ${flagged} flagged for review`,
185
+ `Phases: ${dedupResult.actions.length} deduped, ${contradictions.length} contradictions, ${mergeResult.actions.length} merged, ${solidifyActions.length} solidified, ${pruneResult.actions.length} pruned`,
186
+ ].join('\n'),
187
+ }
188
+ }
189
+
190
+ // ── Phase 1: Deduplication ──
191
+
192
+ private deduplicate(
193
+ memories: MemoryFile[],
194
+ aggressive: boolean,
195
+ ): { memories: MemoryFile[]; actions: DreamAction[] } {
196
+ const actions: DreamAction[] = []
197
+ const kept: MemoryFile[] = []
198
+ const removed = new Set<string>()
199
+
200
+ for (let i = 0; i < memories.length; i++) {
201
+ if (removed.has(memories[i]!.path)) continue
202
+
203
+ for (let j = i + 1; j < memories.length; j++) {
204
+ if (removed.has(memories[j]!.path)) continue
205
+
206
+ const similarity = this.jaccardSimilarity(memories[i]!.content, memories[j]!.content)
207
+
208
+ if (similarity >= SIMILARITY_THRESHOLD) {
209
+ // Keep the older, more established entry
210
+ const keep = memories[i]!.age >= memories[j]!.age ? memories[i]! : memories[j]!
211
+ const remove = keep === memories[i]! ? memories[j]! : memories[i]!
212
+
213
+ if (aggressive) {
214
+ try {
215
+ unlinkSync(remove.path)
216
+ removed.add(remove.path)
217
+ actions.push({
218
+ type: 'dedup',
219
+ description: `Merged near-duplicate: "${remove.name}" → "${keep.name}" (${Math.round(similarity * 100)}% similar)`,
220
+ filesAffected: [remove.path],
221
+ autoApplied: true,
222
+ })
223
+ } catch {
224
+ // Best-effort
225
+ }
226
+ } else {
227
+ actions.push({
228
+ type: 'dedup',
229
+ description: `Near-duplicate detected: "${remove.name}" ≈ "${keep.name}" (${Math.round(similarity * 100)}% similar). Run /dream --aggressive to auto-merge.`,
230
+ filesAffected: [keep.path, remove.path],
231
+ autoApplied: false,
232
+ })
233
+ }
234
+ }
235
+ }
236
+
237
+ if (!removed.has(memories[i]!.path)) {
238
+ kept.push(memories[i]!)
239
+ }
240
+ }
241
+
242
+ return { memories: kept, actions }
243
+ }
244
+
245
+ // ── Phase 2: Contradiction Detection ──
246
+
247
+ private detectContradictions(memories: MemoryFile[]): DreamAction[] {
248
+ const actions: DreamAction[] = []
249
+
250
+ // Simple heuristic: two entries about the same topic (similar name/description)
251
+ // but with opposing sentiment markers
252
+ const negationWords = /\b(not?|never|no|don't|doesn't|shouldn't|can't|cannot)\b/i
253
+
254
+ for (let i = 0; i < memories.length; i++) {
255
+ for (let j = i + 1; j < memories.length; j++) {
256
+ const a = memories[i]!
257
+ const b = memories[j]!
258
+
259
+ // Must be about similar topics
260
+ const nameSim = this.jaccardSimilarity(a.name, b.name)
261
+ if (nameSim < 0.4) continue
262
+
263
+ // One has negation, the other doesn't → potential contradiction
264
+ const aNegates = negationWords.test(a.content)
265
+ const bNegates = negationWords.test(b.content)
266
+
267
+ if (aNegates !== bNegates) {
268
+ actions.push({
269
+ type: 'contradiction',
270
+ description: `Potential contradiction: "${a.name}" vs "${b.name}" — one asserts, the other negates. Review manually.`,
271
+ filesAffected: [a.path, b.path],
272
+ autoApplied: false,
273
+ })
274
+ }
275
+ }
276
+ }
277
+
278
+ return actions
279
+ }
280
+
281
+ // ── Phase 3: Merge Related ──
282
+
283
+ private mergeRelated(
284
+ memories: MemoryFile[],
285
+ aggressive: boolean,
286
+ ): { memories: MemoryFile[]; actions: DreamAction[] } {
287
+ const actions: DreamAction[] = []
288
+ const merged = new Set<string>()
289
+ const result: MemoryFile[] = []
290
+
291
+ for (let i = 0; i < memories.length; i++) {
292
+ if (merged.has(memories[i]!.path)) continue
293
+
294
+ const related: MemoryFile[] = [memories[i]!]
295
+
296
+ for (let j = i + 1; j < memories.length; j++) {
297
+ if (merged.has(memories[j]!.path)) continue
298
+
299
+ // Related if: same type + high name similarity + moderate content overlap
300
+ const metaI = memories[i]!.frontmatter['metadata'] as Record<string, unknown> | undefined
301
+ const metaJ = memories[j]!.frontmatter['metadata'] as Record<string, unknown> | undefined
302
+ const sameType = metaI?.['type'] === metaJ?.['type']
303
+ const nameSim = this.jaccardSimilarity(memories[i]!.name, memories[j]!.name)
304
+ const contentSim = this.jaccardSimilarity(
305
+ memories[i]!.content.slice(0, 500),
306
+ memories[j]!.content.slice(0, 500),
307
+ )
308
+
309
+ if (sameType && nameSim >= 0.5 && contentSim >= 0.3) {
310
+ related.push(memories[j]!)
311
+ merged.add(memories[j]!.path)
312
+ }
313
+ }
314
+
315
+ if (related.length >= 2 && aggressive) {
316
+ // Merge: keep the most recent, append "See also" links to others
317
+ const primary = related.sort((a, b) => b.age - a.age)[0]!
318
+ const secondary = related.filter((r) => r !== primary)
319
+
320
+ try {
321
+ const links = secondary.map((s) => `- See also: [[${s.name}]]`).join('\n')
322
+ const updated = primary.content + `\n\n## Related\n${links}`
323
+ writeFileSync(primary.path, updated, 'utf-8')
324
+
325
+ for (const s of secondary) {
326
+ try {
327
+ unlinkSync(s.path)
328
+ } catch {
329
+ /* best-effort */
330
+ }
331
+ }
332
+
333
+ actions.push({
334
+ type: 'merge',
335
+ description: `Merged ${secondary.length} related entries into "${primary.name}"`,
336
+ filesAffected: [primary.path, ...secondary.map((s) => s.path)],
337
+ autoApplied: true,
338
+ })
339
+
340
+ result.push(primary)
341
+ } catch {
342
+ result.push(...related)
343
+ }
344
+ } else if (related.length >= 2) {
345
+ actions.push({
346
+ type: 'merge',
347
+ description: `${related.length} related entries found around "${memories[i]!.name}". Run /dream --aggressive to auto-merge.`,
348
+ filesAffected: related.map((r) => r.path),
349
+ autoApplied: false,
350
+ })
351
+ result.push(...related)
352
+ } else {
353
+ result.push(memories[i]!)
354
+ }
355
+ }
356
+
357
+ return { memories: result, actions }
358
+ }
359
+
360
+ // ── Phase 4: Solidify Vague ──
361
+
362
+ private solidifyVague(memories: MemoryFile[]): DreamAction[] {
363
+ const actions: DreamAction[] = []
364
+
365
+ for (const mem of memories) {
366
+ const vagueMatches = VAGUE_PATTERNS.filter((p) => p.test(mem.content))
367
+ if (vagueMatches.length > 0) {
368
+ const matched = vagueMatches
369
+ .map((p) => {
370
+ const m = mem.content.match(p)
371
+ return m ? `"${m[0]}"` : ''
372
+ })
373
+ .filter(Boolean)
374
+ .join(', ')
375
+ actions.push({
376
+ type: 'solidify',
377
+ description: `Vague entry "${mem.name}" uses qualifiers: ${matched}. Consider replacing with definite statements.`,
378
+ filesAffected: [mem.path],
379
+ autoApplied: false,
380
+ })
381
+ }
382
+ }
383
+
384
+ return actions
385
+ }
386
+
387
+ // ── Phase 5: Prune Stale ──
388
+
389
+ private pruneStale(
390
+ memories: MemoryFile[],
391
+ aggressive: boolean,
392
+ ): { memories: MemoryFile[]; actions: DreamAction[] } {
393
+ const actions: DreamAction[] = []
394
+ const kept: MemoryFile[] = []
395
+
396
+ for (const mem of memories) {
397
+ if (mem.age > STALE_DAYS) {
398
+ if (aggressive) {
399
+ try {
400
+ unlinkSync(mem.path)
401
+ actions.push({
402
+ type: 'prune',
403
+ description: `Pruned stale entry: "${mem.name}" (${mem.age}d old)`,
404
+ filesAffected: [mem.path],
405
+ autoApplied: true,
406
+ })
407
+ } catch {
408
+ kept.push(mem)
409
+ }
410
+ } else {
411
+ actions.push({
412
+ type: 'prune',
413
+ description: `Stale entry: "${mem.name}" (${mem.age}d old). Run /dream --aggressive to prune.`,
414
+ filesAffected: [mem.path],
415
+ autoApplied: false,
416
+ })
417
+ kept.push(mem)
418
+ }
419
+ } else {
420
+ kept.push(mem)
421
+ }
422
+ }
423
+
424
+ return { memories: kept, actions }
425
+ }
426
+
427
+ // ── Helpers ──
428
+
429
+ private loadMemories(): MemoryFile[] {
430
+ if (!existsSync(this.memoryDir)) return []
431
+
432
+ try {
433
+ const files = readdirSync(this.memoryDir).filter(
434
+ (f) => f.endsWith('.md') && f !== 'MEMORY.md',
435
+ )
436
+
437
+ return files
438
+ .map((f) => {
439
+ const path = join(this.memoryDir, f)
440
+ try {
441
+ const raw = readFileSync(path, 'utf-8')
442
+ const { frontmatter, content } = this.parseFrontmatter(raw)
443
+ const stat = statSync(path)
444
+ const age = Math.floor((Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24))
445
+ return {
446
+ path,
447
+ name: f.replace('.md', ''),
448
+ frontmatter: frontmatter as Record<string, unknown>,
449
+ content,
450
+ age,
451
+ }
452
+ } catch {
453
+ return null
454
+ }
455
+ })
456
+ .filter((m): m is MemoryFile => m !== null)
457
+ } catch {
458
+ return []
459
+ }
460
+ }
461
+
462
+ private parseFrontmatter(raw: string): {
463
+ frontmatter: Record<string, unknown>
464
+ content: string
465
+ } {
466
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
467
+ if (!match) return { frontmatter: {}, content: raw }
468
+ try {
469
+ const lines = match[1]!.split('\n')
470
+ const fm: Record<string, unknown> = {}
471
+ let currentNested: Record<string, unknown> | null = null
472
+ let nestedKey = ''
473
+ for (const line of lines) {
474
+ // Handle nested YAML (e.g. "metadata:\n type: feedback")
475
+ const nested = line.match(/^ (\w[\w\s]*?):\s*(.+)$/)
476
+ if (nested && currentNested !== null) {
477
+ currentNested[nested[1]!.trim()] = nested[2]!.trim()
478
+ continue
479
+ }
480
+ // Top-level key
481
+ const kv = line.match(/^(\w[\w\s]*?):\s*(.*)$/)
482
+ if (kv) {
483
+ const key = kv[1]!.trim()
484
+ const val = kv[2]!.trim()
485
+ if (val === '') {
486
+ // Empty value → start nested block
487
+ currentNested = {}
488
+ nestedKey = key
489
+ fm[key] = currentNested
490
+ } else {
491
+ currentNested = null
492
+ fm[key] = val
493
+ }
494
+ }
495
+ }
496
+ return { frontmatter: fm, content: match[2] || '' }
497
+ } catch {
498
+ return { frontmatter: {}, content: raw }
499
+ }
500
+ }
501
+
502
+ private jaccardSimilarity(a: string, b: string): number {
503
+ // Strip punctuation before tokenizing for robust text comparison
504
+ const clean = (s: string) => s.toLowerCase().replace(/[.,!?;:'"(){}[\]\\/#*&%$@]+/g, ' ')
505
+ const tokensA = new Set(
506
+ clean(a)
507
+ .split(/\s+/)
508
+ .filter((t) => t.length > 1),
509
+ )
510
+ const tokensB = new Set(
511
+ clean(b)
512
+ .split(/\s+/)
513
+ .filter((t) => t.length > 1),
514
+ )
515
+ if (tokensA.size === 0 || tokensB.size === 0) return 0
516
+
517
+ let intersection = 0
518
+ for (const t of tokensA) {
519
+ if (tokensB.has(t)) intersection++
520
+ }
521
+ const union = tokensA.size + tokensB.size - intersection
522
+ return union > 0 ? intersection / union : 0
523
+ }
524
+
525
+ private saveDreamLog(timestamp: string, actions: DreamAction[]): void {
526
+ try {
527
+ const dir = join(this.memoryDir, '..')
528
+ mkdirSync(dir, { recursive: true })
529
+
530
+ let log: Array<{ timestamp: string; actions: DreamAction[] }> = []
531
+ if (existsSync(this.dreamLog)) {
532
+ log = JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
533
+ }
534
+ log.unshift({ timestamp, actions })
535
+ // Keep last 10 dream cycles
536
+ if (log.length > 10) log = log.slice(0, 10)
537
+ writeFileSync(this.dreamLog, JSON.stringify(log, null, 2), 'utf-8')
538
+ } catch {
539
+ // Best-effort persistence
540
+ }
541
+ }
542
+
543
+ /** Get dream history for display. */
544
+ getDreamHistory(): Array<{ timestamp: string; actions: DreamAction[] }> {
545
+ try {
546
+ if (!existsSync(this.dreamLog)) return []
547
+ return JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
548
+ } catch {
549
+ return []
550
+ }
551
+ }
552
+ }