Skip to content

Commit 5e4cfa3

Browse files
authored
Merge pull request #2283 from netease-youdao/fisherdaddy/optimize-skill-mail-ui
chore: optimize skill, mcp, memrory and mail UI
2 parents ce71427 + e99a838 commit 5e4cfa3

26 files changed

Lines changed: 2526 additions & 958 deletions

src/main/libs/openclawConfigSync.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,13 @@ const MANAGED_MEMORY_POLICY_PROMPT = [
370370
'- Only say "记住了" / "I\'ll remember that" AFTER the write tool call succeeds.',
371371
'- Never give a verbal acknowledgment of remembering without a corresponding file write.',
372372
'- "Mental notes" do not survive session restarts. Files do.',
373+
'',
374+
'**MEMORY.md format.** Keep each memory readable as one self-contained block:',
375+
'',
376+
'- One memory = one top-level bullet. Put related details on indented child',
377+
' bullets inside the same block, never as separate top-level bullets.',
378+
'- Group related memories under `## <topic>` headings.',
379+
'- Do not split a single fact across multiple top-level bullets.',
373380
].join('\n');
374381

375382
const MANAGED_HEARTBEAT_POLICY_PROMPT = [

src/main/libs/openclawMemoryFile.test.ts

Lines changed: 266 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

7277
test('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(/brand new fact/);
462+
expect(content.match(/child detail 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(/bump the version/);
601+
expect(content).not.toMatch(/\n\n\n/);
602+
expect(content).toMatch(/- not an entry, inside a fence/);
603+
expect(content).toMatch(/Intro prose kept as an entry\./);
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 fresh fact\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

Comments
 (0)