Files
my-pi/pi-hermes-memory/tests/handlers/sync-markdown-memories.test.ts
云服务部-叶林立 312bb2ba5a feat(pi-hermes-memory): 导入上游 v0.9.10 源码快照
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10
(df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。
本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略
均未改动。

- 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、
  AGENTS 与 LICENSE;该快照共 156 个上游文件。
- 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、
  覆盖率与构建产物。
- 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项:
  在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts
  对齐、strip-ansi 解析确认。
- 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至
  24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为
  对应一个已发布、可复现的版本。

尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载
Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。

验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份;
已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
2026-10-13 23:19:35 -07:00

673 lines
28 KiB
TypeScript

import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { DatabaseManager } from '../../src/store/db.js';
import { registerMemoryTool } from '../../src/tools/memory-tool.js';
import {
migrateThenSyncMarkdownMemories,
registerSyncMarkdownMemoriesCommand,
syncMarkdownMemoriesToSqlite,
} from '../../src/handlers/sync-markdown-memories.js';
import { ENTRY_DELIMITER, MDSYNC_METADATA_KEY_PREFIX } from '../../src/constants.js';
import { addMemory, getMemories, reconcileMarkdownMemoryScope, searchMemories } from '../../src/store/sqlite-memory-store.js';
import { AtomicLockCoordinator } from '../../src/store/atomic-lock-coordinator.js';
import { MemoryStore } from '../../src/store/memory-store.js';
async function withBlockedMemoryWrites<T>(dbManager: DatabaseManager, fn: () => T | Promise<T>): Promise<T> {
const db = dbManager.getDb() as { prepare: (sql: string) => unknown };
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql: string) => {
const normalized = sql.replace(/\s+/g, ' ').trim();
if (/^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized) || /^UPDATE memories\b/i.test(normalized)) {
throw new Error(`unexpected memories write: ${normalized}`);
}
return originalPrepare(sql);
};
try {
return await fn();
} finally {
db.prepare = originalPrepare;
}
}
describe('memory sqlite sync + markdown backfill', () => {
let tmpDir: string;
let agentRoot: string;
let globalDir: string;
let dbManager: DatabaseManager;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-sync-command-test-'));
agentRoot = path.join(tmpDir, 'agent');
globalDir = path.join(agentRoot, 'memory');
fs.mkdirSync(globalDir, { recursive: true });
dbManager = new DatabaseManager(globalDir);
});
afterEach(() => {
dbManager.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('memory tool writes are immediately searchable in SQLite', async () => {
let capturedTool: any;
const mockPi = {
registerTool: (def: any) => {
if (!capturedTool || def.name === "memory_add") capturedTool = def;
},
} as unknown as ExtensionAPI;
const mockStore = {
add: async () => ({
success: true,
target: 'memory',
entries: ['sync token 2026-05-09'],
usage: '1% — 20/5000 chars',
entry_count: 1,
message: 'Entry added.',
}),
} as any;
registerMemoryTool(mockPi, mockStore, null, dbManager);
await capturedTool.execute(
'tc-1',
{ action: 'add', target: 'memory', content: 'sync token 2026-05-09' },
undefined,
undefined,
undefined,
);
const results = searchMemories(dbManager, 'sync token 2026-05-09', { target: 'memory' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'sync token 2026-05-09');
});
it('backfill command is idempotent across repeated runs', async () => {
const memoryEntries = [
'global memory one <!-- created=2026-05-08, last=2026-05-08 -->',
'global memory two <!-- created=2026-05-08, last=2026-05-09 -->',
];
const userEntries = [
'name: Chandra <!-- created=2026-05-08, last=2026-05-08 -->',
];
const failureEntries = [
'[tool-quirk] npm cache stale — Failed: clear .cache/tsx <!-- created=2026-05-08, last=2026-05-09 -->',
];
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), memoryEntries.join(ENTRY_DELIMITER), 'utf-8');
fs.writeFileSync(path.join(globalDir, 'USER.md'), userEntries.join(ENTRY_DELIMITER), 'utf-8');
fs.writeFileSync(path.join(globalDir, 'failures.md'), failureEntries.join(ENTRY_DELIMITER), 'utf-8');
const projectDir = path.join(agentRoot, 'projects-memory', 'project-a');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, 'MEMORY.md'),
'project memory entry <!-- created=2026-05-08, last=2026-05-09 -->',
'utf-8',
);
let handler: any;
const mockPi = {
registerCommand: (_name: string, opts: any) => {
handler = opts.handler;
},
} as unknown as ExtensionAPI;
const notifications: Array<{ message: string; severity: string }> = [];
const ctx = {
ui: {
notify: (message: string, severity: string) => {
notifications.push({ message, severity });
},
},
} as any;
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
await handler({}, ctx);
const afterFirst = getMemories(dbManager);
await handler({}, ctx);
const afterSecond = getMemories(dbManager);
assert.strictEqual(afterFirst.length, 5, 'first run should import all unique entries');
assert.strictEqual(afterSecond.length, 5, 'second run should not create duplicates');
const projectRows = getMemories(dbManager, { project: 'project-a', target: 'memory' });
assert.strictEqual(projectRows.length, 1);
const failureRows = getMemories(dbManager, { target: 'failure', category: 'tool-quirk' });
assert.strictEqual(failureRows.length, 1);
assert.ok(
notifications.some((n) => n.message.includes('SQLite sync complete')),
'command should report completion',
);
});
it('backfills legacy project memory directories from the old ~/.pi/agent/<project> layout', async () => {
const legacyProjectDir = path.join(agentRoot, 'legacy-project');
fs.mkdirSync(legacyProjectDir, { recursive: true });
fs.writeFileSync(
path.join(legacyProjectDir, 'MEMORY.md'),
'legacy project entry <!-- created=2026-05-08, last=2026-05-09 -->',
'utf-8',
);
let handler: any;
const mockPi = {
registerCommand: (_name: string, opts: any) => {
handler = opts.handler;
},
} as unknown as ExtensionAPI;
const ctx = {
ui: {
notify: () => {},
},
} as any;
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
await handler({}, ctx);
const projectRows = getMemories(dbManager, { project: 'legacy-project', target: 'memory' });
assert.strictEqual(projectRows.length, 1);
assert.strictEqual(projectRows[0].content, 'legacy project entry');
});
it('makes new-layout project markdown searchable when startup sync runs', async () => {
const projectDir = path.join(agentRoot, 'projects-memory', 'latest-project');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, 'MEMORY.md'),
'latest path searchable entry <!-- created=2026-05-11, last=2026-05-11 -->',
'utf-8',
);
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.projectCount, 1);
assert.strictEqual(counters.imported, 1);
const results = searchMemories(dbManager, 'latest path searchable entry', {
project: 'latest-project',
target: 'memory',
});
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'latest path searchable entry');
});
it('prunes Markdown orphans while preserving other targets and projects', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'kept global memory', 'utf-8');
fs.writeFileSync(path.join(globalDir, 'USER.md'), 'kept global user', 'utf-8');
const projectDir = path.join(agentRoot, 'projects-memory', 'project-a');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, 'MEMORY.md'), 'kept project memory', 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
dbManager.getDb().prepare(`
INSERT INTO memories (project, target, category, content, created, last_referenced)
VALUES (NULL, 'memory', NULL, 'orphaned global memory', '2026-07-01', '2026-07-01')
`).run();
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(
getMemories(dbManager).map((entry) => `${entry.project ?? 'global'}:${entry.target}:${entry.content}`).sort(),
[
'global:memory:kept global memory',
'global:user:kept global user',
'project-a:memory:kept project memory',
].sort(),
);
});
it('waits for the canonical Markdown mutation before reading and reconciling', async () => {
const memoryFile = path.join(globalDir, 'MEMORY.md');
fs.writeFileSync(memoryFile, 'stale memory', 'utf-8');
addMemory(dbManager, 'stale memory');
const identity = fs.realpathSync(memoryFile);
const coordinator = new AtomicLockCoordinator(path.join(path.dirname(path.dirname(identity)), '.pi-hermes-locks.sqlite'));
const lease = coordinator.tryAcquire(`mutation:${identity}`, { staleMs: 300_000 });
assert.ok(lease);
let settled = false;
const syncing = Promise.resolve()
.then(() => syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot))
.then((result) => {
settled = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
const waitedForMutation = !settled;
fs.writeFileSync(memoryFile, ['stale memory', 'newer writer memory'].join(ENTRY_DELIMITER), 'utf-8');
addMemory(dbManager, 'newer writer memory');
lease.release();
await syncing;
assert.ok(waitedForMutation, 'reconciliation must wait for the active Markdown mutation');
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content).sort(),
['newer writer memory', 'stale memory'],
);
});
it('prunes a deleted project Markdown scope without touching unrelated rows', async () => {
const deletedProjectDir = path.join(agentRoot, 'projects-memory', 'deleted-project');
const keptProjectDir = path.join(agentRoot, 'projects-memory', 'kept-project');
fs.mkdirSync(deletedProjectDir, { recursive: true });
fs.mkdirSync(keptProjectDir, { recursive: true });
fs.writeFileSync(path.join(deletedProjectDir, 'MEMORY.md'), 'deleted project memory', 'utf-8');
fs.writeFileSync(path.join(keptProjectDir, 'MEMORY.md'), 'kept project memory', 'utf-8');
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'kept global memory', 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
dbManager.getDb().prepare(`
INSERT INTO memories (project, target, category, content, created, last_referenced)
VALUES ('deleted-project', 'user', NULL, 'unrelated project user', '2026-07-01', '2026-07-01')
`).run();
fs.rmSync(deletedProjectDir, { recursive: true });
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.ok(!fs.existsSync(deletedProjectDir));
assert.deepStrictEqual(
getMemories(dbManager).map((entry) => `${entry.project ?? 'global'}:${entry.target}:${entry.content}`).sort(),
[
'deleted-project:user:unrelated project user',
'global:memory:kept global memory',
'kept-project:memory:kept project memory',
],
);
});
it('treats a missing canonical project file as empty despite a retained legacy backup', async () => {
const projectName = 'canonical-deleted-project';
const canonicalProjectDir = path.join(agentRoot, 'projects-memory', projectName);
const legacyProjectDir = path.join(agentRoot, projectName);
fs.mkdirSync(canonicalProjectDir, { recursive: true });
fs.mkdirSync(legacyProjectDir, { recursive: true });
fs.writeFileSync(path.join(legacyProjectDir, 'MEMORY.md'), 'retained legacy backup', 'utf-8');
addMemory(dbManager, 'stale canonical row', 'memory', projectName);
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: projectName, target: 'memory' }), []);
assert.strictEqual(
fs.readFileSync(path.join(legacyProjectDir, 'MEMORY.md'), 'utf-8'),
'retained legacy backup',
);
});
it('reconciles unsafe SQLite project scopes empty without reading outside projects-memory', async () => {
const outsideDir = path.join(agentRoot, 'outside');
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(path.join(outsideDir, 'MEMORY.md'), 'outside traversal bait', 'utf-8');
addMemory(dbManager, 'outside traversal bait', 'memory', '../outside');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: '../outside', target: 'memory' }), []);
assert.strictEqual(fs.readFileSync(path.join(outsideDir, 'MEMORY.md'), 'utf-8'), 'outside traversal bait');
});
it('reconciles a symlinked project directory empty without reading its target', async (t) => {
const outsideDir = path.join(tmpDir, 'outside-project');
const projectLink = path.join(agentRoot, 'projects-memory', 'linked-project');
fs.mkdirSync(outsideDir, { recursive: true });
fs.mkdirSync(path.dirname(projectLink), { recursive: true });
fs.writeFileSync(path.join(outsideDir, 'MEMORY.md'), 'outside directory bait', 'utf-8');
try {
fs.symlinkSync(outsideDir, projectLink, 'dir');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') return t.skip('directory symlinks unavailable');
throw error;
}
addMemory(dbManager, 'stale linked project row', 'memory', 'linked-project');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'linked-project', target: 'memory' }), []);
});
it('reconciles a symlinked project memory file empty without reading its target', async (t) => {
const outsideFile = path.join(tmpDir, 'outside-memory.md');
const projectDir = path.join(agentRoot, 'projects-memory', 'linked-file-project');
const memoryLink = path.join(projectDir, 'MEMORY.md');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(outsideFile, 'outside file bait', 'utf-8');
try {
fs.symlinkSync(outsideFile, memoryLink, 'file');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') return t.skip('file symlinks unavailable');
throw error;
}
addMemory(dbManager, 'stale linked file row', 'memory', 'linked-file-project');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'linked-file-project', target: 'memory' }), []);
});
it('still scans project markdown under ~/.pi/agent when memoryDir is customized elsewhere', async () => {
const customGlobalDir = path.join(tmpDir, 'external-memory-root');
fs.mkdirSync(customGlobalDir, { recursive: true });
const customDbManager = new DatabaseManager(customGlobalDir);
try {
const projectDir = path.join(agentRoot, 'projects-memory', 'custom-root-project');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, 'MEMORY.md'),
'custom root project entry <!-- created=2026-05-11, last=2026-05-11 -->',
'utf-8',
);
const counters = await syncMarkdownMemoriesToSqlite(customDbManager, customGlobalDir, undefined, agentRoot);
assert.strictEqual(counters.projectCount, 1);
const results = searchMemories(customDbManager, 'custom root project entry', {
project: 'custom-root-project',
target: 'memory',
});
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'custom root project entry');
} finally {
customDbManager.close();
}
});
it('migrates a populated legacy database before startup reconciliation', async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
fs.mkdirSync(legacyDir, { recursive: true });
const legacyManager = new DatabaseManager(legacyDir);
legacyManager.getDb().prepare(`
INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
VALUES ('legacy-session', 'legacy-project', '/legacy/project', '2026-07-01', NULL, 1)
`).run();
legacyManager.close();
const targetManager = new DatabaseManager(targetDir);
try {
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
const sessions = targetManager.getDb().prepare('SELECT id FROM sessions').all() as Array<{ id: string }>;
assert.deepStrictEqual(sessions.map((session) => session.id), ['legacy-session']);
} finally {
targetManager.close();
}
});
it('does not create a destination database after critical migration failure', async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'sessions.db'), 'populated legacy database', 'utf-8');
const targetManager = new DatabaseManager(targetDir);
let migrationSucceeded = false;
try {
await assert.rejects(
migrateThenSyncMarkdownMemories(
targetManager,
legacyDir,
targetDir,
undefined,
agentRoot,
{
moveFile: async () => {
throw new Error('injected sessions.db move failure');
},
onMigrationSucceeded: () => {
migrationSucceeded = true;
},
},
),
/sessions\.db migration failed/,
);
assert.equal(fs.existsSync(path.join(targetDir, 'sessions.db')), false);
assert.equal(fs.existsSync(path.join(legacyDir, 'sessions.db')), true);
assert.equal(migrationSucceeded, false);
} finally {
targetManager.close();
}
});
it('hands a corrupt legacy database to bounded destination recovery', async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'sessions.db'), 'not a sqlite database', 'utf-8');
const targetManager = new DatabaseManager(targetDir);
try {
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
assert.equal(fs.existsSync(path.join(legacyDir, 'sessions.db')), false);
assert.deepStrictEqual(targetManager.getDb().pragma?.('quick_check'), [{ quick_check: 'ok' }]);
assert.equal(targetManager.getLastRecovery()?.strategy, 'recreated-empty');
assert.ok(
fs.readdirSync(targetDir).some((name) => name.startsWith('sessions.db.corrupt-')),
'the corrupt generation should be quarantined by DatabaseManager',
);
} finally {
targetManager.close();
}
});
it('resolves a migrated file-symlink database at first I/O', { skip: process.platform === 'win32' }, async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
const realDir = path.join(tmpDir, 'real-database');
const realDbPath = path.join(realDir, 'sessions.db');
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(realDir, { recursive: true });
fs.writeFileSync(realDbPath, 'not a sqlite database');
fs.symlinkSync(path.relative(legacyDir, realDbPath), path.join(legacyDir, 'sessions.db'), 'file');
const targetManager = new DatabaseManager(targetDir);
try {
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
targetManager.getDb().prepare(
"INSERT INTO extension_metadata (key, value) VALUES ('migrated-alias', 'kept')",
).run();
targetManager.close();
assert.equal(fs.lstatSync(path.join(targetDir, 'sessions.db')).isSymbolicLink(), true);
const directManager = new DatabaseManager(realDir);
try {
assert.deepEqual(
directManager.getDb().prepare(
"SELECT value FROM extension_metadata WHERE key = 'migrated-alias'",
).get(),
{ value: 'kept' },
);
} finally {
directManager.close();
}
} finally {
targetManager.close();
}
});
it('skips unchanged markdown on a second startup sync without inserting', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'startup skip entry', 'utf-8');
const first = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(first.imported, 1);
const second = await withBlockedMemoryWrites(dbManager, () =>
syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot),
);
assert.strictEqual(second.imported, 0);
assert.strictEqual(second.warnings.length, 0, 'a working skip never touches memories, so no blocked-write errors');
assert.strictEqual(second.skipped, 1);
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 1);
});
it('repairs COUNT-preserving sqlite-only drift through the command, not startup', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'canonical markdown content', 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
dbManager.getDb().prepare(`
UPDATE memories SET content = 'drifted sqlite content' WHERE target = 'memory' AND project IS NULL
`).run();
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(
getMemories(dbManager, { target: 'memory', project: null })[0].content,
'drifted sqlite content',
);
let handler: any;
const mockPi = {
registerCommand: (_name: string, opts: any) => {
handler = opts.handler;
},
} as unknown as ExtensionAPI;
const notifications: Array<{ message: string; severity: string }> = [];
const ctx = {
ui: {
notify: (message: string, severity: string) => {
notifications.push({ message, severity });
},
},
} as any;
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
await handler({}, ctx);
assert.strictEqual(
getMemories(dbManager, { target: 'memory', project: null })[0].content,
'canonical markdown content',
);
assert.ok(notifications.some((n) => n.message.includes('SQLite sync complete')));
});
it('still reconciles a same-size rewrite that preserves mtime', async () => {
const memoryFile = path.join(globalDir, 'MEMORY.md');
const first = 'same-len-entry-aaa';
const second = 'same-len-entry-bbb';
assert.strictEqual(first.length, second.length);
fs.writeFileSync(memoryFile, first, 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
[first],
);
const st = fs.statSync(memoryFile);
fs.writeFileSync(memoryFile, second, 'utf-8');
fs.utimesSync(memoryFile, st.atime, st.mtime);
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
[second],
);
});
it('skips startup sync after a live mutation observer already reconciled', async () => {
const store = new MemoryStore({
memoryDir: globalDir,
memoryCharLimit: 5000,
userCharLimit: 5000,
failureCharLimit: 5000,
} as any);
await store.loadFromDisk();
store.setMutationObserver((target, entries) => {
reconcileMarkdownMemoryScope(dbManager, entries, target, null);
return null;
});
const added = await store.add('memory', 'observer composed entry');
assert.ok(added.success);
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 1);
const counters = await withBlockedMemoryWrites(dbManager, () =>
syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot),
);
assert.strictEqual(counters.imported, 0);
assert.strictEqual(counters.warnings.length, 0, 'the observer-written fingerprint must let startup skip without touching memories');
assert.strictEqual(counters.skipped, 1);
});
it('scoped startup sync reconciles only globals and the current project', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'global memory', 'utf-8');
fs.writeFileSync(path.join(globalDir, 'USER.md'), 'global user', 'utf-8');
const total = 30;
for (let i = 0; i < total; i++) {
const dir = path.join(agentRoot, 'projects-memory', `proj-${i}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'MEMORY.md'), `memory for project ${i}`, 'utf-8');
}
// A stale mirror row outside the startup scope must survive: scoped runs
// never prune scopes they did not reconcile (the full command does that).
addMemory(dbManager, 'stale out-of-scope row', 'memory', 'proj-29');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
onlyProjects: ['proj-0'],
});
assert.strictEqual(counters.projectCount, 1);
assert.strictEqual(counters.filesScanned, 3, 'global MEMORY.md + USER.md + proj-0 only');
const current = searchMemories(dbManager, 'memory for project 0', { project: 'proj-0', target: 'memory' });
assert.strictEqual(current.length, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-1', target: 'memory' }), []);
assert.strictEqual(
getMemories(dbManager, { project: 'proj-29', target: 'memory' }).length,
1,
'out-of-scope mirror rows are left alone',
);
const fingerprints = dbManager.getDb().prepare(
`SELECT key FROM extension_metadata WHERE key LIKE '${MDSYNC_METADATA_KEY_PREFIX}%'`,
).all() as Array<{ key: string }>;
assert.ok(
fingerprints.every((row) => row.key.includes('proj-0') || row.key.includes('null')),
`no fingerprint may be recorded for unscanned projects: ${fingerprints.map((row) => row.key)}`,
);
});
it('scoped startup sync with no project reconciles globals only', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'global memory', 'utf-8');
const dir = path.join(agentRoot, 'projects-memory', 'proj-0');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'MEMORY.md'), 'memory for project 0', 'utf-8');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
onlyProjects: [],
});
assert.strictEqual(counters.projectCount, 0);
assert.strictEqual(counters.filesScanned, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-0', target: 'memory' }), []);
});
it('scoped startup sync still prunes the current project when its file is gone', async () => {
addMemory(dbManager, 'stale current-project row', 'memory', 'proj-gone');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
onlyProjects: ['proj-gone'],
});
assert.strictEqual(counters.projectCount, 1);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-gone', target: 'memory' }), []);
});
});