mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
从官方 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。
673 lines
28 KiB
TypeScript
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' }), []);
|
|
});
|
|
});
|