@rlabs-inc/memory 0.3.11 → 0.4.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.
@@ -1,20 +1,36 @@
1
1
  // ============================================================================
2
2
  // MIGRATE COMMAND - Upgrade memory files to latest schema version
3
+ // Supports: v1→v3, v2→v3 migrations
3
4
  // ============================================================================
4
5
 
5
6
  import { join } from 'path'
6
7
  import { homedir } from 'os'
7
8
  import { Glob } from 'bun'
8
9
  import { c, symbols, fmt } from '../colors.ts'
9
- import { MEMORY_SCHEMA_VERSION } from '../../types/schema.ts'
10
- import { V2_DEFAULTS } from '../../types/memory.ts'
11
10
  import { EmbeddingGenerator } from '../../core/embeddings.ts'
11
+ import {
12
+ V3_SCHEMA_VERSION,
13
+ CONTEXT_TYPE_MIGRATION_MAP,
14
+ V3_TYPE_DEFAULTS,
15
+ V3_DELETED_FIELDS,
16
+ CANONICAL_CONTEXT_TYPES,
17
+ migrateContextType,
18
+ isKnownContextType,
19
+ migrateTemporalRelevance,
20
+ type CanonicalContextType,
21
+ } from '../../migrations/v3-schema.ts'
22
+
23
+ // Custom mapping loaded from file
24
+ let customMapping: Record<string, string> | null = null
12
25
 
13
26
  interface MigrateOptions {
14
27
  dryRun?: boolean
15
28
  verbose?: boolean
16
29
  path?: string // Custom path to migrate
17
30
  embeddings?: boolean // Regenerate embeddings for all memories
31
+ analyze?: boolean // Analyze fragmentation, show what would change
32
+ generateMapping?: string // Generate a mapping file for customization
33
+ mapping?: string // Path to custom mapping file
18
34
  }
19
35
 
20
36
  // Module-level embedder for reuse across files
@@ -35,7 +51,13 @@ interface MigrationResult {
35
51
  migrated: number
36
52
  skipped: number
37
53
  embeddingsGenerated: number
54
+ contextTypesMigrated: number
55
+ fieldsDeleted: number
38
56
  errors: string[]
57
+ // Tracking what changed
58
+ contextTypeChanges: Map<string, string> // old → new
59
+ unknownTypes: Map<string, number> // types not in our mapping
60
+ nullEmbeddings: number // count of null embeddings found
39
61
  }
40
62
 
41
63
  /**
@@ -116,83 +138,167 @@ function serializeFrontmatter(frontmatter: Record<string, any>, body: string): s
116
138
  }
117
139
 
118
140
  /**
119
- * Apply v2 defaults to frontmatter
141
+ * Get the migrated context type, using custom mapping if available
120
142
  */
121
- function applyMigration(frontmatter: Record<string, any>): Record<string, any> {
122
- const contextType = frontmatter.context_type ?? 'general'
123
- const typeDefaults = V2_DEFAULTS.typeDefaults[contextType] ?? V2_DEFAULTS.typeDefaults.technical
124
-
125
- return {
126
- ...frontmatter,
143
+ function getMigratedContextType(oldType: string): CanonicalContextType {
144
+ // Check custom mapping first
145
+ if (customMapping && oldType in customMapping) {
146
+ const mapped = customMapping[oldType]
147
+ // Validate it's a canonical type
148
+ if (CANONICAL_CONTEXT_TYPES.includes(mapped as CanonicalContextType)) {
149
+ return mapped as CanonicalContextType
150
+ }
151
+ }
127
152
 
128
- // Lifecycle status
129
- status: frontmatter.status ?? 'active',
130
- scope: frontmatter.scope ?? typeDefaults?.scope ?? 'project',
153
+ // Fall back to default migration
154
+ return migrateContextType(oldType) ?? 'technical'
155
+ }
131
156
 
132
- // Temporal class & decay
133
- temporal_class: frontmatter.temporal_class ?? typeDefaults?.temporal_class ?? 'medium_term',
134
- fade_rate: frontmatter.fade_rate ?? typeDefaults?.fade_rate ?? 0.03,
157
+ /**
158
+ * Apply v3 migration to frontmatter
159
+ * Handles both v1→v3 and v2→v3
160
+ */
161
+ function applyV3Migration(frontmatter: Record<string, any>): {
162
+ migrated: Record<string, any>
163
+ contextTypeChanged: boolean
164
+ oldContextType: string
165
+ newContextType: string
166
+ deletedFieldsCount: number
167
+ isUnknownType: boolean
168
+ } {
169
+ const oldContextType = frontmatter.context_type ?? 'general'
170
+ const newContextType = getMigratedContextType(oldContextType)
171
+ const isUnknownType = !isKnownContextType(oldContextType) && !CANONICAL_CONTEXT_TYPES.includes(oldContextType as CanonicalContextType)
172
+ const contextTypeChanged = oldContextType !== newContextType
173
+
174
+ // Get type-specific defaults for the NEW context type
175
+ const typeDefaults = V3_TYPE_DEFAULTS[newContextType]
176
+
177
+ // Start with migrated frontmatter
178
+ const migrated: Record<string, any> = { ...frontmatter }
179
+
180
+ // 1. Migrate context_type to canonical
181
+ migrated.context_type = newContextType
182
+
183
+ // 2. Apply v3 defaults (also handles v1→v3 by filling in missing v2 fields)
184
+ migrated.status = migrated.status ?? 'active'
185
+ migrated.scope = migrated.scope ?? typeDefaults.scope
186
+
187
+ // Migrate temporal_relevance → temporal_class (if temporal_class not already set)
188
+ if (!migrated.temporal_class && migrated.temporal_relevance) {
189
+ const migratedTemporal = migrateTemporalRelevance(migrated.temporal_relevance)
190
+ if (migratedTemporal) {
191
+ migrated.temporal_class = migratedTemporal
192
+ }
193
+ }
194
+ migrated.temporal_class = migrated.temporal_class ?? typeDefaults.temporal_class
195
+ migrated.fade_rate = migrated.fade_rate ?? typeDefaults.fade_rate
196
+ migrated.sessions_since_surfaced = migrated.sessions_since_surfaced ?? 0
197
+ migrated.awaiting_implementation = migrated.awaiting_implementation ?? false
198
+ migrated.awaiting_decision = migrated.awaiting_decision ?? false
199
+ migrated.exclude_from_retrieval = migrated.exclude_from_retrieval ?? false
200
+
201
+ // Initialize arrays if missing
202
+ migrated.related_to = migrated.related_to ?? []
203
+ migrated.resolves = migrated.resolves ?? []
204
+ migrated.blocks = migrated.blocks ?? []
205
+ migrated.related_files = migrated.related_files ?? []
206
+ migrated.anti_triggers = migrated.anti_triggers ?? []
207
+
208
+ // 3. DELETE obsolete fields
209
+ let deletedFieldsCount = 0
210
+ for (const field of V3_DELETED_FIELDS) {
211
+ if (field in migrated) {
212
+ delete migrated[field]
213
+ deletedFieldsCount++
214
+ }
215
+ }
135
216
 
136
- // Temporal tracking (initialize with 0 since we don't know the session numbers)
137
- sessions_since_surfaced: frontmatter.sessions_since_surfaced ?? 0,
217
+ // 4. Mark as v3
218
+ migrated.schema_version = V3_SCHEMA_VERSION
138
219
 
139
- // Lifecycle triggers
140
- awaiting_implementation: frontmatter.awaiting_implementation ?? false,
141
- awaiting_decision: frontmatter.awaiting_decision ?? false,
142
- exclude_from_retrieval: frontmatter.exclude_from_retrieval ?? false,
220
+ return { migrated, contextTypeChanged, oldContextType, newContextType, deletedFieldsCount, isUnknownType }
221
+ }
143
222
 
144
- // Retrieval weight
145
- retrieval_weight: frontmatter.retrieval_weight ?? frontmatter.importance_weight ?? 0.5,
223
+ /**
224
+ * Check if file needs v3 migration
225
+ */
226
+ function needsV3Migration(frontmatter: Record<string, any>): boolean {
227
+ // Needs migration if:
228
+ // 1. Schema version < 3
229
+ // 2. context_type is not canonical
230
+ // 3. Has deleted fields
231
+ if (!frontmatter.schema_version || frontmatter.schema_version < V3_SCHEMA_VERSION) {
232
+ return true
233
+ }
146
234
 
147
- // Initialize empty arrays
148
- related_to: frontmatter.related_to ?? [],
149
- resolves: frontmatter.resolves ?? [],
150
- child_ids: frontmatter.child_ids ?? [],
151
- blocks: frontmatter.blocks ?? [],
152
- related_files: frontmatter.related_files ?? [],
235
+ // Check if context_type needs migration even if already v3
236
+ const contextType = frontmatter.context_type ?? ''
237
+ if (!(contextType in V3_TYPE_DEFAULTS)) {
238
+ return true
239
+ }
153
240
 
154
- // Mark as migrated
155
- schema_version: MEMORY_SCHEMA_VERSION,
241
+ // Check for deleted fields that shouldn't exist
242
+ for (const field of V3_DELETED_FIELDS) {
243
+ if (field in frontmatter) {
244
+ return true
245
+ }
156
246
  }
157
- }
158
247
 
159
- /**
160
- * Check if file needs migration
161
- */
162
- function needsMigration(frontmatter: Record<string, any>): boolean {
163
- return !frontmatter.schema_version || frontmatter.schema_version < MEMORY_SCHEMA_VERSION
248
+ return false
164
249
  }
165
250
 
166
251
  /**
167
252
  * Migrate a single memory file
168
253
  */
169
- async function migrateFile(filePath: string, options: MigrateOptions): Promise<{ migrated: boolean; embeddingGenerated: boolean; error?: string }> {
254
+ async function migrateFile(
255
+ filePath: string,
256
+ options: MigrateOptions
257
+ ): Promise<{
258
+ migrated: boolean
259
+ embeddingGenerated: boolean
260
+ contextTypeChanged: boolean
261
+ oldContextType?: string
262
+ newContextType?: string
263
+ deletedFieldsCount: number
264
+ isUnknownType: boolean
265
+ hasNullEmbedding: boolean
266
+ error?: string
267
+ }> {
170
268
  try {
171
269
  const file = Bun.file(filePath)
172
270
  const content = await file.text()
173
271
  const parsed = parseFrontmatter(content)
174
272
 
175
273
  if (!parsed) {
176
- return { migrated: false, embeddingGenerated: false, error: 'Could not parse frontmatter' }
274
+ return { migrated: false, embeddingGenerated: false, contextTypeChanged: false, deletedFieldsCount: 0, isUnknownType: false, hasNullEmbedding: false, error: 'Could not parse frontmatter' }
177
275
  }
178
276
 
179
- const needsSchema = needsMigration(parsed.frontmatter)
180
- const needsEmbedding = options.embeddings && (
181
- parsed.frontmatter.embedding === null ||
182
- parsed.frontmatter.embedding === undefined ||
183
- (Array.isArray(parsed.frontmatter.embedding) && parsed.frontmatter.embedding.length === 0)
184
- )
277
+ const hasNullEmbedding = !parsed.frontmatter.embedding || parsed.frontmatter.embedding === null
278
+ const needsSchema = needsV3Migration(parsed.frontmatter)
279
+ const needsEmbedding = options.embeddings && hasNullEmbedding
185
280
 
186
281
  // Nothing to do
187
282
  if (!needsSchema && !needsEmbedding) {
188
- return { migrated: false, embeddingGenerated: false }
283
+ return { migrated: false, embeddingGenerated: false, contextTypeChanged: false, deletedFieldsCount: 0, isUnknownType: false, hasNullEmbedding }
189
284
  }
190
285
 
191
286
  let newFrontmatter = parsed.frontmatter
287
+ let contextTypeChanged = false
288
+ let oldContextType: string | undefined
289
+ let newContextType: string | undefined
290
+ let deletedFieldsCount = 0
291
+ let isUnknownType = false
192
292
 
193
293
  // Apply schema migration if needed
194
294
  if (needsSchema) {
195
- newFrontmatter = applyMigration(parsed.frontmatter)
295
+ const result = applyV3Migration(parsed.frontmatter)
296
+ newFrontmatter = result.migrated
297
+ contextTypeChanged = result.contextTypeChanged
298
+ oldContextType = result.oldContextType
299
+ newContextType = result.newContextType
300
+ deletedFieldsCount = result.deletedFieldsCount
301
+ isUnknownType = result.isUnknownType
196
302
  }
197
303
 
198
304
  // Generate embedding if needed
@@ -210,22 +316,28 @@ async function migrateFile(filePath: string, options: MigrateOptions): Promise<{
210
316
  await Bun.write(filePath, newContent)
211
317
  }
212
318
 
213
- return { migrated: needsSchema, embeddingGenerated }
319
+ return {
320
+ migrated: needsSchema,
321
+ embeddingGenerated,
322
+ contextTypeChanged,
323
+ oldContextType,
324
+ newContextType,
325
+ deletedFieldsCount,
326
+ isUnknownType,
327
+ hasNullEmbedding
328
+ }
214
329
  } catch (error: any) {
215
- return { migrated: false, embeddingGenerated: false, error: error.message }
330
+ return { migrated: false, embeddingGenerated: false, contextTypeChanged: false, deletedFieldsCount: 0, isUnknownType: false, hasNullEmbedding: false, error: error.message }
216
331
  }
217
332
  }
218
333
 
219
334
  /**
220
335
  * Find all memory directories to migrate
221
- * Handles both central and local storage modes
222
- * Global memories are ALWAYS in central location, even in local mode
223
336
  */
224
337
  async function findMemoryPaths(customPath?: string): Promise<{ path: string; label: string }[]> {
225
338
  const paths: { path: string; label: string }[] = []
226
339
 
227
340
  if (customPath) {
228
- // Custom path specified - just use it
229
341
  paths.push({ path: customPath, label: customPath })
230
342
  return paths
231
343
  }
@@ -233,7 +345,7 @@ async function findMemoryPaths(customPath?: string): Promise<{ path: string; lab
233
345
  const storageMode = process.env.MEMORY_STORAGE_MODE ?? 'central'
234
346
  const centralPath = process.env.MEMORY_CENTRAL_PATH ?? join(homedir(), '.local', 'share', 'memory')
235
347
 
236
- // ALWAYS check global memories (even in local mode, global is central)
348
+ // ALWAYS check global memories
237
349
  const globalMemoriesPath = join(centralPath, 'global', 'memories')
238
350
  try {
239
351
  const globalGlob = new Glob('*.md')
@@ -250,7 +362,6 @@ async function findMemoryPaths(customPath?: string): Promise<{ path: string; lab
250
362
  }
251
363
 
252
364
  if (storageMode === 'local') {
253
- // Local mode: check current directory for project memories
254
365
  const localFolder = '.memory'
255
366
  const cwd = process.cwd()
256
367
  const localMemoriesPath = join(cwd, localFolder, 'memories')
@@ -269,17 +380,15 @@ async function findMemoryPaths(customPath?: string): Promise<{ path: string; lab
269
380
  // Local directory doesn't exist
270
381
  }
271
382
  } else {
272
- // Central mode: check ~/.local/share/memory/[projects]/
383
+ // Central mode: check all project directories
273
384
  try {
274
385
  const projectGlob = new Glob('*/memories')
275
386
  for await (const match of projectGlob.scan({ cwd: centralPath, onlyFiles: false })) {
276
- // Skip global, we already handled it
277
387
  if (match.startsWith('global/')) continue
278
388
 
279
389
  const fullPath = join(centralPath, match)
280
390
  const projectId = match.split('/')[0]
281
391
 
282
- // Check if directory has any .md files
283
392
  try {
284
393
  const mdGlob = new Glob('*.md')
285
394
  let hasFiles = false
@@ -303,10 +412,21 @@ async function findMemoryPaths(customPath?: string): Promise<{ path: string; lab
303
412
  }
304
413
 
305
414
  /**
306
- * Migrate all memory files in a directory using Bun's Glob
415
+ * Migrate all memory files in a directory
307
416
  */
308
417
  async function migrateDirectory(dir: string, options: MigrateOptions): Promise<MigrationResult> {
309
- const result: MigrationResult = { total: 0, migrated: 0, skipped: 0, embeddingsGenerated: 0, errors: [] }
418
+ const result: MigrationResult = {
419
+ total: 0,
420
+ migrated: 0,
421
+ skipped: 0,
422
+ embeddingsGenerated: 0,
423
+ contextTypesMigrated: 0,
424
+ fieldsDeleted: 0,
425
+ errors: [],
426
+ contextTypeChanges: new Map(),
427
+ unknownTypes: new Map(),
428
+ nullEmbeddings: 0
429
+ }
310
430
 
311
431
  try {
312
432
  const glob = new Glob('*.md')
@@ -315,20 +435,45 @@ async function migrateDirectory(dir: string, options: MigrateOptions): Promise<M
315
435
  result.total++
316
436
  const filePath = join(dir, file)
317
437
 
318
- const { migrated, embeddingGenerated, error } = await migrateFile(filePath, options)
438
+ const migrationResult = await migrateFile(filePath, options)
319
439
 
320
- if (error) {
321
- result.errors.push(`${file}: ${error}`)
440
+ // Track null embeddings
441
+ if (migrationResult.hasNullEmbedding) {
442
+ result.nullEmbeddings++
443
+ }
444
+
445
+ if (migrationResult.error) {
446
+ result.errors.push(`${file}: ${migrationResult.error}`)
322
447
  if (options.verbose) {
323
- console.log(` ${c.error(symbols.cross)} ${file}: ${error}`)
448
+ console.log(` ${c.error(symbols.cross)} ${file}: ${migrationResult.error}`)
324
449
  }
325
- } else if (migrated || embeddingGenerated) {
326
- if (migrated) result.migrated++
327
- if (embeddingGenerated) result.embeddingsGenerated++
450
+ } else if (migrationResult.migrated || migrationResult.embeddingGenerated) {
451
+ if (migrationResult.migrated) result.migrated++
452
+ if (migrationResult.embeddingGenerated) result.embeddingsGenerated++
453
+ if (migrationResult.contextTypeChanged) {
454
+ result.contextTypesMigrated++
455
+ // Track the change
456
+ const key = `${migrationResult.oldContextType} → ${migrationResult.newContextType}`
457
+ result.contextTypeChanges.set(key, (result.contextTypeChanges.get(key) ?? '') + '.')
458
+
459
+ // Track unknown types
460
+ if (migrationResult.isUnknownType && migrationResult.oldContextType) {
461
+ result.unknownTypes.set(
462
+ migrationResult.oldContextType,
463
+ (result.unknownTypes.get(migrationResult.oldContextType) ?? 0) + 1
464
+ )
465
+ }
466
+ }
467
+ result.fieldsDeleted += migrationResult.deletedFieldsCount
468
+
328
469
  if (options.verbose) {
329
470
  const actions = []
330
- if (migrated) actions.push('schema')
331
- if (embeddingGenerated) actions.push('embedding')
471
+ if (migrationResult.migrated) actions.push('v3')
472
+ if (migrationResult.embeddingGenerated) actions.push('embedding')
473
+ if (migrationResult.contextTypeChanged) {
474
+ const prefix = migrationResult.isUnknownType ? '⚠️' : ''
475
+ actions.push(`${prefix}${migrationResult.oldContextType}→${migrationResult.newContextType}`)
476
+ }
332
477
  console.log(` ${c.success(symbols.tick)} ${file} (${actions.join(', ')})`)
333
478
  }
334
479
  } else {
@@ -345,14 +490,268 @@ async function migrateDirectory(dir: string, options: MigrateOptions): Promise<M
345
490
  return result
346
491
  }
347
492
 
493
+ /**
494
+ * Analyze all memories and report on fragmentation
495
+ */
496
+ async function analyzeMemories(memoryPaths: { path: string; label: string }[]): Promise<{
497
+ contextTypes: Map<string, number>
498
+ nullEmbeddings: number
499
+ totalMemories: number
500
+ deletedFieldsFound: Map<string, number>
501
+ }> {
502
+ const contextTypes = new Map<string, number>()
503
+ const deletedFieldsFound = new Map<string, number>()
504
+ let nullEmbeddings = 0
505
+ let totalMemories = 0
506
+
507
+ for (const { path: dir } of memoryPaths) {
508
+ try {
509
+ const glob = new Glob('*.md')
510
+ for await (const file of glob.scan({ cwd: dir })) {
511
+ totalMemories++
512
+ const filePath = join(dir, file)
513
+ const content = await Bun.file(filePath).text()
514
+ const parsed = parseFrontmatter(content)
515
+
516
+ if (!parsed) continue
517
+
518
+ // Count context types
519
+ const ct = parsed.frontmatter.context_type ?? 'unknown'
520
+ contextTypes.set(ct, (contextTypes.get(ct) ?? 0) + 1)
521
+
522
+ // Count null embeddings
523
+ if (!parsed.frontmatter.embedding || parsed.frontmatter.embedding === null) {
524
+ nullEmbeddings++
525
+ }
526
+
527
+ // Count deleted fields that still exist
528
+ for (const field of V3_DELETED_FIELDS) {
529
+ if (field in parsed.frontmatter) {
530
+ deletedFieldsFound.set(field, (deletedFieldsFound.get(field) ?? 0) + 1)
531
+ }
532
+ }
533
+ }
534
+ } catch {
535
+ // Skip directories we can't read
536
+ }
537
+ }
538
+
539
+ return { contextTypes, nullEmbeddings, totalMemories, deletedFieldsFound }
540
+ }
541
+
542
+ /**
543
+ * Show analysis of what migration would do
544
+ */
545
+ async function showAnalysis(options: MigrateOptions) {
546
+ console.log()
547
+ console.log(c.header(`${symbols.search} Memory Migration Analysis`))
548
+ console.log()
549
+
550
+ const memoryPaths = await findMemoryPaths(options.path)
551
+
552
+ if (memoryPaths.length === 0) {
553
+ console.log(c.warn(` ${symbols.warning} No memory directories found`))
554
+ return
555
+ }
556
+
557
+ console.log(c.muted(' Scanning memories...'))
558
+ console.log()
559
+
560
+ const { contextTypes, nullEmbeddings, totalMemories, deletedFieldsFound } = await analyzeMemories(memoryPaths)
561
+
562
+ console.log(fmt.section('Overview'))
563
+ console.log(` ${fmt.kv('Total Memories', totalMemories.toString())}`)
564
+ console.log(` ${fmt.kv('Unique context_types', contextTypes.size.toString())}`)
565
+ console.log(` ${fmt.kv('Null Embeddings', nullEmbeddings > 0 ? c.warn(nullEmbeddings.toString()) : c.success('0'))}`)
566
+ console.log()
567
+
568
+ // Show context_type breakdown
569
+ console.log(fmt.section('Context Type Analysis'))
570
+ console.log()
571
+
572
+ const sorted = [...contextTypes.entries()].sort((a, b) => b[1] - a[1])
573
+
574
+ // Categorize types
575
+ const canonical: [string, number][] = []
576
+ const willMigrate: [string, number, string][] = []
577
+ const unknown: [string, number, string][] = []
578
+
579
+ for (const [type, count] of sorted) {
580
+ if (CANONICAL_CONTEXT_TYPES.includes(type as CanonicalContextType)) {
581
+ canonical.push([type, count])
582
+ } else {
583
+ const migrated = migrateContextType(type)
584
+ if (isKnownContextType(type)) {
585
+ willMigrate.push([type, count, migrated!])
586
+ } else {
587
+ unknown.push([type, count, migrated!])
588
+ }
589
+ }
590
+ }
591
+
592
+ // Show canonical (already good)
593
+ if (canonical.length > 0) {
594
+ console.log(c.success(' Already Canonical (no change needed):'))
595
+ for (const [type, count] of canonical) {
596
+ console.log(c.muted(` ${type}: ${count}`))
597
+ }
598
+ console.log()
599
+ }
600
+
601
+ // Show known migrations
602
+ if (willMigrate.length > 0) {
603
+ console.log(c.warn(' Known Fragmented Types (will be migrated):'))
604
+ for (const [oldType, count, newType] of willMigrate.slice(0, 30)) {
605
+ console.log(` ${c.muted(oldType)} → ${c.success(newType)} (${count})`)
606
+ }
607
+ if (willMigrate.length > 30) {
608
+ console.log(c.muted(` ... and ${willMigrate.length - 30} more`))
609
+ }
610
+ console.log()
611
+ }
612
+
613
+ // Show unknown (will use fuzzy matching or fallback)
614
+ if (unknown.length > 0) {
615
+ console.log(c.error(' Unknown Types (will use fuzzy matching → fallback):'))
616
+ for (const [oldType, count, newType] of unknown.slice(0, 20)) {
617
+ console.log(` ${c.error(oldType)} → ${c.warn(newType)} (${count})`)
618
+ }
619
+ if (unknown.length > 20) {
620
+ console.log(c.muted(` ... and ${unknown.length - 20} more`))
621
+ }
622
+ console.log()
623
+ console.log(c.muted(' To customize unknown type mappings:'))
624
+ console.log(c.muted(' 1. Run: memory migrate --generate-mapping mapping.json'))
625
+ console.log(c.muted(' 2. Edit mapping.json with your preferred mappings'))
626
+ console.log(c.muted(' 3. Run: memory migrate --mapping mapping.json'))
627
+ console.log()
628
+ }
629
+
630
+ // Show deleted fields
631
+ if (deletedFieldsFound.size > 0) {
632
+ console.log(fmt.section('Fields to be Removed'))
633
+ for (const [field, count] of deletedFieldsFound.entries()) {
634
+ console.log(` ${c.muted(field)}: ${count} memories`)
635
+ }
636
+ console.log()
637
+ }
638
+
639
+ // Summary
640
+ console.log(fmt.section('Migration Summary'))
641
+ console.log(` ${fmt.kv('Types already canonical', c.success(canonical.length.toString()))}`)
642
+ console.log(` ${fmt.kv('Types to migrate (known)', c.warn(willMigrate.length.toString()))}`)
643
+ console.log(` ${fmt.kv('Types to migrate (unknown)', unknown.length > 0 ? c.error(unknown.length.toString()) : '0')}`)
644
+ console.log(` ${fmt.kv('Fields to remove', deletedFieldsFound.size.toString())}`)
645
+ console.log()
646
+
647
+ if (willMigrate.length > 0 || unknown.length > 0) {
648
+ console.log(c.muted(' To run migration:'))
649
+ console.log(c.muted(' memory migrate --dry-run # Preview changes'))
650
+ console.log(c.muted(' memory migrate # Apply changes'))
651
+ } else {
652
+ console.log(c.success(' All context_types are already canonical!'))
653
+ }
654
+ console.log()
655
+ }
656
+
657
+ /**
658
+ * Generate a mapping file for customization
659
+ */
660
+ async function generateMappingFile(outputPath: string, options: MigrateOptions) {
661
+ console.log()
662
+ console.log(c.header(`${symbols.gear} Generate Custom Mapping File`))
663
+ console.log()
664
+
665
+ const memoryPaths = await findMemoryPaths(options.path)
666
+
667
+ if (memoryPaths.length === 0) {
668
+ console.log(c.warn(` ${symbols.warning} No memory directories found`))
669
+ return
670
+ }
671
+
672
+ console.log(c.muted(' Scanning memories...'))
673
+
674
+ const { contextTypes } = await analyzeMemories(memoryPaths)
675
+
676
+ // Build mapping with our defaults, user can customize
677
+ const mapping: Record<string, string> = {}
678
+
679
+ for (const [type] of contextTypes.entries()) {
680
+ if (CANONICAL_CONTEXT_TYPES.includes(type as CanonicalContextType)) {
681
+ // Already canonical, map to itself
682
+ mapping[type] = type
683
+ } else {
684
+ // Use our migration logic
685
+ const migrated = migrateContextType(type) ?? 'technical'
686
+ mapping[type] = migrated
687
+ }
688
+ }
689
+
690
+ const output = {
691
+ _comment: 'Edit the values to customize how context_types are migrated',
692
+ _canonical_types: CANONICAL_CONTEXT_TYPES,
693
+ _generated: new Date().toISOString(),
694
+ mapping
695
+ }
696
+
697
+ await Bun.write(outputPath, JSON.stringify(output, null, 2))
698
+
699
+ console.log()
700
+ console.log(c.success(` ${symbols.tick} Generated: ${outputPath}`))
701
+ console.log()
702
+ console.log(c.muted(' Edit the file to customize mappings, then run:'))
703
+ console.log(c.muted(` memory migrate --mapping ${outputPath} --dry-run`))
704
+ console.log(c.muted(` memory migrate --mapping ${outputPath}`))
705
+ console.log()
706
+ }
707
+
708
+ /**
709
+ * Load custom mapping from file
710
+ */
711
+ async function loadCustomMapping(mappingPath: string): Promise<Record<string, string>> {
712
+ try {
713
+ const content = await Bun.file(mappingPath).text()
714
+ const parsed = JSON.parse(content)
715
+ return parsed.mapping ?? parsed
716
+ } catch (error: any) {
717
+ throw new Error(`Could not load mapping file: ${error.message}`)
718
+ }
719
+ }
720
+
348
721
  export async function migrate(options: MigrateOptions) {
722
+ // Handle analyze mode
723
+ if (options.analyze) {
724
+ return showAnalysis(options)
725
+ }
726
+
727
+ // Handle generate-mapping mode
728
+ if (options.generateMapping) {
729
+ return generateMappingFile(options.generateMapping, options)
730
+ }
731
+
732
+ // Load custom mapping if provided
733
+ if (options.mapping) {
734
+ try {
735
+ customMapping = await loadCustomMapping(options.mapping)
736
+ console.log(c.success(` ${symbols.tick} Loaded custom mapping from ${options.mapping}`))
737
+ } catch (error: any) {
738
+ console.log(c.error(` ${symbols.cross} ${error.message}`))
739
+ return
740
+ }
741
+ }
349
742
  console.log()
350
- console.log(c.header(`${symbols.gear} Memory Migration`))
743
+ console.log(c.header(`${symbols.gear} Memory Migration to v${V3_SCHEMA_VERSION}`))
351
744
  console.log()
352
- console.log(` ${fmt.kv('Target Version', `v${MEMORY_SCHEMA_VERSION}`)}`)
745
+ console.log(` ${fmt.kv('Target Version', `v${V3_SCHEMA_VERSION}`)}`)
353
746
  console.log(` ${fmt.kv('Storage Mode', process.env.MEMORY_STORAGE_MODE ?? 'central')}`)
354
- console.log(` ${fmt.kv('Embeddings', options.embeddings ? c.success('Regenerate') : c.muted('Skip'))}`)
355
- console.log(` ${fmt.kv('Mode', options.dryRun ? c.warn('Dry Run') : c.success('Live'))}`)
747
+ console.log(` ${fmt.kv('Embeddings', options.embeddings ? c.success('Regenerate null') : c.muted('Skip'))}`)
748
+ console.log(` ${fmt.kv('Mode', options.dryRun ? c.warn('DRY RUN') : c.success('LIVE'))}`)
749
+ console.log()
750
+
751
+ console.log(c.muted(' v3 Changes:'))
752
+ console.log(c.muted(' • Consolidates 170+ context_types → 11 canonical'))
753
+ console.log(c.muted(' • Removes 10 unused fields'))
754
+ console.log(c.muted(' • Applies type-specific defaults'))
356
755
  console.log()
357
756
 
358
757
  const memoryPaths = await findMemoryPaths(options.path)
@@ -367,7 +766,18 @@ export async function migrate(options: MigrateOptions) {
367
766
  return
368
767
  }
369
768
 
370
- const totals: MigrationResult = { total: 0, migrated: 0, skipped: 0, embeddingsGenerated: 0, errors: [] }
769
+ const totals: MigrationResult = {
770
+ total: 0,
771
+ migrated: 0,
772
+ skipped: 0,
773
+ embeddingsGenerated: 0,
774
+ contextTypesMigrated: 0,
775
+ fieldsDeleted: 0,
776
+ errors: [],
777
+ contextTypeChanges: new Map(),
778
+ unknownTypes: new Map(),
779
+ nullEmbeddings: 0
780
+ }
371
781
 
372
782
  for (const { path, label } of memoryPaths) {
373
783
  console.log(fmt.section(label))
@@ -380,15 +790,37 @@ export async function migrate(options: MigrateOptions) {
380
790
  totals.migrated += result.migrated
381
791
  totals.skipped += result.skipped
382
792
  totals.embeddingsGenerated += result.embeddingsGenerated
793
+ totals.contextTypesMigrated += result.contextTypesMigrated
794
+ totals.fieldsDeleted += result.fieldsDeleted
383
795
  totals.errors.push(...result.errors)
384
796
 
797
+ // Merge context type changes
798
+ for (const [key, value] of result.contextTypeChanges) {
799
+ const existing = totals.contextTypeChanges.get(key) ?? ''
800
+ totals.contextTypeChanges.set(key, existing + value)
801
+ }
802
+
803
+ // Merge unknown types
804
+ for (const [key, value] of result.unknownTypes) {
805
+ totals.unknownTypes.set(key, (totals.unknownTypes.get(key) ?? 0) + value)
806
+ }
807
+
808
+ // Track null embeddings
809
+ totals.nullEmbeddings += result.nullEmbeddings
810
+
385
811
  if (!options.verbose) {
386
812
  console.log(` ${fmt.kv('Files', result.total.toString())}`)
387
- console.log(` ${fmt.kv('Schema Migrated', c.success(result.migrated.toString()))}`)
813
+ console.log(` ${fmt.kv('Migrated to v3', c.success(result.migrated.toString()))}`)
814
+ if (result.contextTypesMigrated > 0) {
815
+ console.log(` ${fmt.kv('Context Types Fixed', c.success(result.contextTypesMigrated.toString()))}`)
816
+ }
817
+ if (result.fieldsDeleted > 0) {
818
+ console.log(` ${fmt.kv('Dead Fields Removed', c.success(result.fieldsDeleted.toString()))}`)
819
+ }
388
820
  if (options.embeddings) {
389
821
  console.log(` ${fmt.kv('Embeddings Generated', c.success(result.embeddingsGenerated.toString()))}`)
390
822
  }
391
- console.log(` ${fmt.kv('Skipped', c.muted(result.skipped.toString()))}`)
823
+ console.log(` ${fmt.kv('Already v3', c.muted(result.skipped.toString()))}`)
392
824
  if (result.errors.length > 0) {
393
825
  console.log(` ${fmt.kv('Errors', c.error(result.errors.length.toString()))}`)
394
826
  }
@@ -400,24 +832,73 @@ export async function migrate(options: MigrateOptions) {
400
832
  console.log(fmt.section('Summary'))
401
833
  console.log()
402
834
  console.log(` ${fmt.kv('Total Files', totals.total.toString())}`)
403
- console.log(` ${fmt.kv('Schema Migrated', c.success(totals.migrated.toString()))}`)
835
+ console.log(` ${fmt.kv('Migrated to v3', c.success(totals.migrated.toString()))}`)
836
+ console.log(` ${fmt.kv('Context Types Fixed', c.success(totals.contextTypesMigrated.toString()))}`)
837
+ console.log(` ${fmt.kv('Dead Fields Removed', c.success(totals.fieldsDeleted.toString()))}`)
404
838
  if (options.embeddings) {
405
839
  console.log(` ${fmt.kv('Embeddings Generated', c.success(totals.embeddingsGenerated.toString()))}`)
406
840
  }
407
- console.log(` ${fmt.kv('Already Current', c.muted(totals.skipped.toString()))}`)
841
+ console.log(` ${fmt.kv('Already v3', c.muted(totals.skipped.toString()))}`)
842
+
843
+ // Show unknown types warning
844
+ if (totals.unknownTypes.size > 0) {
845
+ console.log()
846
+ console.log(c.warn(' Unknown Types (used fuzzy matching):'))
847
+ const sorted = [...totals.unknownTypes.entries()].sort((a, b) => b[1] - a[1])
848
+ for (const [type, count] of sorted.slice(0, 10)) {
849
+ console.log(` ${c.warn(type)}: ${count}`)
850
+ }
851
+ if (sorted.length > 10) {
852
+ console.log(c.muted(` ... and ${sorted.length - 10} more`))
853
+ }
854
+ console.log()
855
+ console.log(c.muted(' To customize these mappings:'))
856
+ console.log(c.muted(' memory migrate --generate-mapping mapping.json'))
857
+ }
858
+
859
+ // Show context type migration breakdown
860
+ if (totals.contextTypeChanges.size > 0) {
861
+ console.log()
862
+ console.log(c.muted(' Context Type Migrations:'))
863
+ const sorted = [...totals.contextTypeChanges.entries()]
864
+ .map(([key, dots]) => ({ key, count: dots.length }))
865
+ .sort((a, b) => b.count - a.count)
866
+ .slice(0, 15)
867
+
868
+ for (const { key, count } of sorted) {
869
+ console.log(c.muted(` ${key}: ${count}`))
870
+ }
871
+ if (totals.contextTypeChanges.size > 15) {
872
+ console.log(c.muted(` ... and ${totals.contextTypeChanges.size - 15} more`))
873
+ }
874
+ }
875
+
876
+ // Show null embeddings warning
877
+ if (totals.nullEmbeddings > 0 && !options.embeddings) {
878
+ console.log()
879
+ console.log(c.warn(` ${symbols.warning} ${totals.nullEmbeddings} memories have null embeddings`))
880
+ console.log(c.muted(' Run with --embeddings to regenerate them'))
881
+ }
408
882
 
409
883
  if (totals.errors.length > 0) {
410
- console.log(` ${fmt.kv('Errors', c.error(totals.errors.length.toString()))}`)
411
884
  console.log()
412
- for (const error of totals.errors) {
885
+ console.log(` ${fmt.kv('Errors', c.error(totals.errors.length.toString()))}`)
886
+ for (const error of totals.errors.slice(0, 10)) {
413
887
  console.log(` ${c.error(symbols.cross)} ${error}`)
414
888
  }
889
+ if (totals.errors.length > 10) {
890
+ console.log(` ... and ${totals.errors.length - 10} more errors`)
891
+ }
415
892
  }
416
893
 
417
894
  console.log()
418
895
 
419
896
  if (options.dryRun && totals.migrated > 0) {
420
- console.log(c.warn(` ${symbols.warning} This was a dry run. Run without --dry-run to apply changes.`))
897
+ console.log(c.warn(` ${symbols.warning} This was a DRY RUN. No files were modified.`))
898
+ console.log(c.muted(` Run without --dry-run to apply changes.`))
899
+ console.log()
900
+ } else if (totals.migrated > 0) {
901
+ console.log(c.success(` ${symbols.tick} Migration complete!`))
421
902
  console.log()
422
903
  }
423
904
  }