@miphamai/cli 0.33.1 → 0.34.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.
- package/package.json +1 -1
- package/src/agent/cross-session/file-inbox.ts +30 -12
- package/src/core/compaction-progress.ts +205 -0
- package/src/core/context-compact.ts +70 -6
- package/src/core/context-drain.ts +65 -6
- package/src/core/context.ts +11 -4
- package/src/core/dream-engine.ts +552 -0
- package/src/core/emotion-detector.ts +257 -0
- package/src/core/engine.ts +50 -0
- package/src/core/hooks.ts +114 -9
- package/src/ui/app.tsx +164 -130
- package/src/ui/commands.ts +409 -35
- package/src/ui/error-boundary.tsx +85 -0
|
@@ -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
|
+
}
|