@@ -13,10 +13,12 @@ import {
1313 deleteMemoryEntry ,
1414 migrateSqliteToMemoryMd ,
1515 parseMemoryMd ,
16+ readMemoryFileRaw ,
1617 resolveMemoryFilePath ,
1718 searchMemoryEntries ,
1819 serializeMemoryMd ,
1920 updateMemoryEntry ,
21+ writeMemoryFileRaw ,
2022} from './openclawMemoryFile' ;
2123
2224// ---- helpers ----------------------------------------------------------------
@@ -62,11 +64,14 @@ test('parseMemoryMd: fingerprint is case-insensitive and punctuation-agnostic',
6264 expect ( entries . length ) . toBe ( 1 ) ;
6365} ) ;
6466
65- test ( 'parseMemoryMd: ignores non-bullet lines (headings, prose) ' , ( ) => {
66- const md = `# User Memories\n\nSome prose paragraph.\n\n## Section\n\n- only this is a bullet\n` ;
67+ test ( 'parseMemoryMd: headings are not entries; prose paragraphs are ' , ( ) => {
68+ const md = `# User Memories\n\nSome prose paragraph.\n\n## Section\n\n- a bullet entry \n` ;
6769 const entries = parseMemoryMd ( md ) ;
68- expect ( entries . length ) . toBe ( 1 ) ;
69- expect ( entries [ 0 ] . text ) . toBe ( 'only this is a bullet' ) ;
70+ expect ( entries . length ) . toBe ( 2 ) ;
71+ expect ( entries [ 0 ] . text ) . toBe ( 'Some prose paragraph.' ) ;
72+ expect ( entries [ 0 ] . section ) . toBeUndefined ( ) ;
73+ expect ( entries [ 1 ] . text ) . toBe ( 'a bullet entry' ) ;
74+ expect ( entries [ 1 ] . section ) . toBe ( 'Section' ) ;
7075} ) ;
7176
7277test ( 'parseMemoryMd: skips bullets inside fenced code blocks' , ( ) => {
@@ -80,10 +85,18 @@ test('parseMemoryMd: empty string returns empty array', () => {
8085 expect ( parseMemoryMd ( '' ) ) . toEqual ( [ ] ) ;
8186} ) ;
8287
83- test ( 'parseMemoryMd: normalises internal whitespace in entry text ' , ( ) => {
84- const md = `- text with extra spaces\n` ;
88+ test ( 'parseMemoryMd: preserves entry text verbatim; fingerprint ignores extra whitespace ' , ( ) => {
89+ const md = `- text with extra spaces\n- text with extra spaces\n ` ;
8590 const entries = parseMemoryMd ( md ) ;
86- expect ( entries [ 0 ] . text ) . toBe ( 'text with extra spaces' ) ;
91+ expect ( entries . length ) . toBe ( 1 ) ;
92+ expect ( entries [ 0 ] . text ) . toBe ( 'text with extra spaces' ) ;
93+ } ) ;
94+
95+ test ( 'parseMemoryMd: legacy flat files keep the same content-addressed ids' , ( ) => {
96+ // The pre-block parser produced sha1(normalized single-line text).
97+ // sha1('hello world') pins that the id scheme is unchanged for flat files.
98+ const entries = parseMemoryMd ( '- Hello world\n' ) ;
99+ expect ( entries [ 0 ] . id ) . toBe ( '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed' ) ;
87100} ) ;
88101
89102// ==================== serializeMemoryMd ====================
@@ -428,3 +441,249 @@ test('migrateSqliteToMemoryMd: empty source marks done without writing file', ()
428441 cleanupDir ( dir ) ;
429442 }
430443} ) ;
444+
445+ test ( 'migrateSqliteToMemoryMd: skips texts that already exist as lines inside a block' , ( ) => {
446+ const dir = makeTmpDir ( ) ;
447+ try {
448+ const filePath = memFilePath ( dir ) ;
449+ fs . writeFileSync ( filePath , '# User Memories\n\n- parent fact:\n - child detail A\n' , 'utf-8' ) ;
450+
451+ const source = {
452+ isMigrationDone : ( ) => false ,
453+ markMigrationDone : ( ) => { } ,
454+ getActiveMemoryTexts : ( ) => [ 'child detail A' , 'brand new fact' ] ,
455+ } ;
456+
457+ const count = migrateSqliteToMemoryMd ( filePath , source ) ;
458+ expect ( count ) . toBe ( 1 ) ;
459+
460+ const content = fs . readFileSync ( filePath , 'utf-8' ) ;
461+ expect ( content ) . toMatch ( / b r a n d n e w f a c t / ) ;
462+ expect ( content . match ( / c h i l d d e t a i l A / g) ?. length ) . toBe ( 1 ) ;
463+ } finally {
464+ cleanupDir ( dir ) ;
465+ }
466+ } ) ;
467+
468+ // ==================== block-level parsing ====================
469+
470+ const NESTED_MD = [
471+ '# User Memories' ,
472+ '' ,
473+ 'Intro prose kept as an entry.' ,
474+ '' ,
475+ '## Projects' ,
476+ '' ,
477+ '- release flow:' ,
478+ ' - bump the version first' ,
479+ ' - run dist to verify the installer' ,
480+ '' ,
481+ '- commit style uses conventional commits' ,
482+ '' ,
483+ '```' ,
484+ '- not an entry, inside a fence' ,
485+ '```' ,
486+ '' ,
487+ ] . join ( '\n' ) ;
488+
489+ test ( 'parseMemoryMd: nested bullets form a single block entry' , ( ) => {
490+ const entries = parseMemoryMd ( NESTED_MD ) ;
491+ expect ( entries . length ) . toBe ( 3 ) ;
492+ expect ( entries [ 1 ] . text ) . toBe (
493+ 'release flow:\n - bump the version first\n - run dist to verify the installer' ,
494+ ) ;
495+ expect ( entries [ 1 ] . section ) . toBe ( 'Projects' ) ;
496+ expect ( entries [ 2 ] . section ) . toBe ( 'Projects' ) ;
497+ expect ( entries . every ( ( e ) => ! e . text . includes ( 'fence' ) ) ) . toBeTruthy ( ) ;
498+ } ) ;
499+
500+ test ( 'parseMemoryMd: lazy continuation lines stay in the block' , ( ) => {
501+ const md = '- first line\nsecond line without indent\n\n- separate entry\n' ;
502+ const entries = parseMemoryMd ( md ) ;
503+ expect ( entries . length ) . toBe ( 2 ) ;
504+ expect ( entries [ 0 ] . text ) . toBe ( 'first line\nsecond line without indent' ) ;
505+ } ) ;
506+
507+ test ( 'parseMemoryMd: top-level HTML comments are metadata, not entries' , ( ) => {
508+ const md = [
509+ '## Promoted From Short-Term Memory (2026-06-28)' ,
510+ '' ,
511+ '<!-- openclaw-memory-promotion:memory:memory/2026-06-23.md:74:74 -->' ,
512+ '- promoted fact; confidence: 0.79' ,
513+ '' ,
514+ '<!-- multi-line comment' ,
515+ 'still inside the comment -->' ,
516+ '- another fact' ,
517+ '' ,
518+ ] . join ( '\n' ) ;
519+ const entries = parseMemoryMd ( md ) ;
520+ expect ( entries . length ) . toBe ( 2 ) ;
521+ expect ( entries [ 0 ] . text ) . toBe ( 'promoted fact; confidence: 0.79' ) ;
522+ expect ( entries [ 0 ] . section ) . toBe ( 'Promoted From Short-Term Memory (2026-06-28)' ) ;
523+ expect ( entries [ 1 ] . text ) . toBe ( 'another fact' ) ;
524+ } ) ;
525+
526+ test ( 'updateMemoryEntry: HTML comment markers survive edits verbatim' , ( ) => {
527+ const dir = makeTmpDir ( ) ;
528+ try {
529+ const filePath = memFilePath ( dir ) ;
530+ const md = '<!-- marker -->\n- promoted fact\n' ;
531+ fs . writeFileSync ( filePath , md , 'utf-8' ) ;
532+
533+ const entries = parseMemoryMd ( md ) ;
534+ updateMemoryEntry ( filePath , entries [ 0 ] . id , 'edited fact' ) ;
535+
536+ const content = fs . readFileSync ( filePath , 'utf-8' ) ;
537+ expect ( content ) . toBe ( '<!-- marker -->\n- edited fact\n' ) ;
538+ } finally {
539+ cleanupDir ( dir ) ;
540+ }
541+ } ) ;
542+
543+ test ( 'parseMemoryMd: level-1 heading resets the section' , ( ) => {
544+ const md = '- a\n\n## Sec\n\n- b\n\n# Top\n\n- c\n' ;
545+ const entries = parseMemoryMd ( md ) ;
546+ expect ( entries [ 0 ] . section ) . toBeUndefined ( ) ;
547+ expect ( entries [ 1 ] . section ) . toBe ( 'Sec' ) ;
548+ expect ( entries [ 2 ] . section ) . toBeUndefined ( ) ;
549+ } ) ;
550+
551+ // ==================== surgical writes ====================
552+
553+ test ( 'updateMemoryEntry: replaces the block in place and keeps other lines verbatim' , ( ) => {
554+ const dir = makeTmpDir ( ) ;
555+ try {
556+ const filePath = memFilePath ( dir ) ;
557+ fs . writeFileSync ( filePath , NESTED_MD , 'utf-8' ) ;
558+
559+ const entries = parseMemoryMd ( NESTED_MD ) ;
560+ const updated = updateMemoryEntry ( filePath , entries [ 2 ] . id , 'commit style: cc only' ) ;
561+ expect ( updated ?. section ) . toBe ( 'Projects' ) ;
562+
563+ const content = fs . readFileSync ( filePath , 'utf-8' ) ;
564+ const expected = NESTED_MD . replace (
565+ '- commit style uses conventional commits' ,
566+ '- commit style: cc only' ,
567+ ) ;
568+ expect ( content ) . toBe ( expected ) ;
569+ } finally {
570+ cleanupDir ( dir ) ;
571+ }
572+ } ) ;
573+
574+ test ( 'updateMemoryEntry: no-op when text is unchanged (no write, no backup)' , ( ) => {
575+ const dir = makeTmpDir ( ) ;
576+ try {
577+ const filePath = memFilePath ( dir ) ;
578+ const entry = addMemoryEntry ( filePath , 'stable fact' ) ;
579+ const before = fs . readFileSync ( filePath , 'utf-8' ) ;
580+
581+ const result = updateMemoryEntry ( filePath , entry . id , 'stable fact' ) ;
582+ expect ( result ?. id ) . toBe ( entry . id ) ;
583+ expect ( fs . readFileSync ( filePath , 'utf-8' ) ) . toBe ( before ) ;
584+ expect ( fs . existsSync ( `${ filePath } .bak` ) ) . toBe ( false ) ;
585+ } finally {
586+ cleanupDir ( dir ) ;
587+ }
588+ } ) ;
589+
590+ test ( 'deleteMemoryEntry: removes the whole block and collapses the separator' , ( ) => {
591+ const dir = makeTmpDir ( ) ;
592+ try {
593+ const filePath = memFilePath ( dir ) ;
594+ fs . writeFileSync ( filePath , NESTED_MD , 'utf-8' ) ;
595+
596+ const entries = parseMemoryMd ( NESTED_MD ) ;
597+ expect ( deleteMemoryEntry ( filePath , entries [ 1 ] . id ) ) . toBe ( true ) ;
598+
599+ const content = fs . readFileSync ( filePath , 'utf-8' ) ;
600+ expect ( content ) . not . toMatch ( / b u m p t h e v e r s i o n / ) ;
601+ expect ( content ) . not . toMatch ( / \n \n \n / ) ;
602+ expect ( content ) . toMatch ( / - n o t a n e n t r y , i n s i d e a f e n c e / ) ;
603+ expect ( content ) . toMatch ( / I n t r o p r o s e k e p t a s a n e n t r y \. / ) ;
604+ } finally {
605+ cleanupDir ( dir ) ;
606+ }
607+ } ) ;
608+
609+ test ( 'addMemoryEntry: appends after existing content and inherits the trailing section' , ( ) => {
610+ const dir = makeTmpDir ( ) ;
611+ try {
612+ const filePath = memFilePath ( dir ) ;
613+ fs . writeFileSync ( filePath , NESTED_MD , 'utf-8' ) ;
614+
615+ const entry = addMemoryEntry ( filePath , 'a fresh fact' ) ;
616+ expect ( entry . section ) . toBe ( 'Projects' ) ;
617+
618+ const content = fs . readFileSync ( filePath , 'utf-8' ) ;
619+ expect ( content . startsWith ( NESTED_MD . replace ( / \s + $ / , '' ) ) ) . toBe ( true ) ;
620+ expect ( content ) . toMatch ( / \n \n - a f r e s h f a c t \n $ / ) ;
621+ } finally {
622+ cleanupDir ( dir ) ;
623+ }
624+ } ) ;
625+
626+ test ( 'addMemoryEntry: multi-line input becomes one bullet block' , ( ) => {
627+ const dir = makeTmpDir ( ) ;
628+ try {
629+ const filePath = memFilePath ( dir ) ;
630+ const entry = addMemoryEntry ( filePath , 'release flow:\n- bump version\n\n- run dist\n' ) ;
631+ expect ( entry . text ) . toBe ( 'release flow:\n - bump version\n - run dist' ) ;
632+
633+ const reread = parseMemoryMd ( fs . readFileSync ( filePath , 'utf-8' ) ) ;
634+ expect ( reread . length ) . toBe ( 1 ) ;
635+ expect ( reread [ 0 ] . id ) . toBe ( entry . id ) ;
636+ expect ( reread [ 0 ] . text ) . toBe ( entry . text ) ;
637+ } finally {
638+ cleanupDir ( dir ) ;
639+ }
640+ } ) ;
641+
642+ test ( 'mutations create MEMORY.md.bak once and never overwrite it' , ( ) => {
643+ const dir = makeTmpDir ( ) ;
644+ try {
645+ const filePath = memFilePath ( dir ) ;
646+ addMemoryEntry ( filePath , 'entry A' ) ;
647+ expect ( fs . existsSync ( `${ filePath } .bak` ) ) . toBe ( false ) ;
648+
649+ const beforeB = fs . readFileSync ( filePath , 'utf-8' ) ;
650+ addMemoryEntry ( filePath , 'entry B' ) ;
651+ expect ( fs . readFileSync ( `${ filePath } .bak` , 'utf-8' ) ) . toBe ( beforeB ) ;
652+
653+ addMemoryEntry ( filePath , 'entry C' ) ;
654+ expect ( fs . readFileSync ( `${ filePath } .bak` , 'utf-8' ) ) . toBe ( beforeB ) ;
655+ } finally {
656+ cleanupDir ( dir ) ;
657+ }
658+ } ) ;
659+
660+ // ==================== raw file access ====================
661+
662+ test ( 'writeMemoryFileRaw/readMemoryFileRaw: round-trips and stays parseable' , ( ) => {
663+ const dir = makeTmpDir ( ) ;
664+ try {
665+ const filePath = memFilePath ( dir ) ;
666+ writeMemoryFileRaw ( filePath , '# User Memories\n\n- raw entry' ) ;
667+ expect ( readMemoryFileRaw ( filePath ) ) . toBe ( '# User Memories\n\n- raw entry\n' ) ;
668+
669+ const entries = parseMemoryMd ( readMemoryFileRaw ( filePath ) ) ;
670+ expect ( entries . length ) . toBe ( 1 ) ;
671+ expect ( entries [ 0 ] . text ) . toBe ( 'raw entry' ) ;
672+ } finally {
673+ cleanupDir ( dir ) ;
674+ }
675+ } ) ;
676+
677+ test ( 'searchMemoryEntries: matches section names too' , ( ) => {
678+ const dir = makeTmpDir ( ) ;
679+ try {
680+ const filePath = memFilePath ( dir ) ;
681+ writeMemoryFileRaw ( filePath , '## Projects\n\n- ship the release\n\n## Habits\n\n- morning runs\n' ) ;
682+
683+ const results = searchMemoryEntries ( filePath , 'projects' ) ;
684+ expect ( results . length ) . toBe ( 1 ) ;
685+ expect ( results [ 0 ] . text ) . toBe ( 'ship the release' ) ;
686+ } finally {
687+ cleanupDir ( dir ) ;
688+ }
689+ } ) ;
0 commit comments