Files
my-pi/pi-hermes-memory/tests/store/sqlite-memory-store.test.ts
T
云服务部-叶林立 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

987 lines
39 KiB
TypeScript

import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { DatabaseManager } from '../../src/store/db.js';
import {
addMemory,
searchMemories,
getMemories,
removeMemory,
touchMemory,
getMemoryStats,
syncMemoryEntry,
replaceSyncedMemories,
removeSyncedMemories,
parseMarkdownMemoryEntry,
formatFailureMemoryContent,
reconcileMarkdownMemoryScope,
reconcileMarkdownFailureScopes,
} from '../../src/store/sqlite-memory-store.js';
import { MemoryStore } from '../../src/store/memory-store.js';
import { MDSYNC_METADATA_KEY_PREFIX } from '../../src/constants.js';
function mdsyncKey(target: string, project: string | null): string {
return MDSYNC_METADATA_KEY_PREFIX + JSON.stringify([target, project]);
}
function readMdsyncValue(dbManager: DatabaseManager, target: string, project: string | null): string | undefined {
const row = dbManager.getDb().prepare(
'SELECT value FROM extension_metadata WHERE key = ?',
).get(mdsyncKey(target, project)) as { value: string } | undefined;
return row?.value;
}
function withBlockedMemoryWrites<T>(
dbManager: DatabaseManager,
fn: () => T,
mode: 'insert' | 'insert-or-update' = 'insert-or-update',
): 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();
const isInsert = /^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized);
const isUpdate = /^UPDATE memories\b/i.test(normalized);
if (isInsert || (mode === 'insert-or-update' && isUpdate)) {
throw new Error(`unexpected memories write: ${normalized}`);
}
return originalPrepare(sql);
};
try {
return fn();
} finally {
db.prepare = originalPrepare;
}
}
describe('sqlite-memory-store', () => {
let tmpDir: string;
let dbManager: DatabaseManager;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-store-test-'));
dbManager = new DatabaseManager(tmpDir);
});
afterEach(() => {
dbManager.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('addMemory', () => {
it('should add a memory entry', () => {
const entry = addMemory(dbManager, 'prefers pnpm over npm');
assert.ok(entry.id > 0);
assert.strictEqual(entry.target, 'memory');
assert.strictEqual(entry.content, 'prefers pnpm over npm');
assert.ok(entry.created.length > 0);
assert.ok(entry.lastReferenced.length > 0);
});
it('should add a user entry', () => {
const entry = addMemory(dbManager, 'name: Chandrateja', 'user');
assert.strictEqual(entry.target, 'user');
});
it('should add a project-specific entry', () => {
const entry = addMemory(dbManager, 'uses Prisma', 'memory', 'my-project');
assert.strictEqual(entry.project, 'my-project');
});
it('should add a global entry (null project)', () => {
const entry = addMemory(dbManager, 'timezone: AEST');
assert.strictEqual(entry.project, null);
});
});
describe('syncMemoryEntry', () => {
it('deduplicates exact logical entries', () => {
const first = syncMemoryEntry(dbManager, {
content: 'prefers pnpm over npm',
target: 'memory',
});
const second = syncMemoryEntry(dbManager, {
content: 'prefers pnpm over npm',
target: 'memory',
});
assert.strictEqual(first.action, 'inserted');
assert.strictEqual(second.action, 'existing');
assert.strictEqual(getMemories(dbManager).length, 1);
});
it('stores project-scoped memory with project name', () => {
syncMemoryEntry(dbManager, {
content: 'uses Prisma',
target: 'memory',
project: 'project-a',
});
const results = getMemories(dbManager, { project: 'project-a' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].project, 'project-a');
assert.strictEqual(results[0].target, 'memory');
});
it('preserves failure category metadata', () => {
syncMemoryEntry(dbManager, {
content: formatFailureMemoryContent('pnpm lockfile mismatch', {
category: 'tool-quirk',
failureReason: 'npm install rewrote lockfile',
}),
target: 'failure',
category: 'tool-quirk',
failureReason: 'npm install rewrote lockfile',
});
const results = getMemories(dbManager, { target: 'failure', category: 'tool-quirk' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].category, 'tool-quirk');
assert.strictEqual(results[0].failureReason, 'npm install rewrote lockfile');
});
it('parses Markdown failure entries for backfill', () => {
const parsed = parseMarkdownMemoryEntry(
'[correction] use pnpm — Failed: npm install rewrote lockfile <!-- created=2026-05-08, last=2026-05-09 -->',
'failure',
);
assert.strictEqual(parsed.category, 'correction');
assert.strictEqual(parsed.failureReason, 'npm install rewrote lockfile');
assert.strictEqual(parsed.created, '2026-05-08');
assert.strictEqual(parsed.lastReferenced, '2026-05-09');
});
it('does not infer project scope from spoofable failure content', () => {
const raw = '[correction] literal user text — Project: other-project <!-- created=2026-05-08, last=2026-05-09 -->';
reconcileMarkdownFailureScopes(dbManager, [raw]);
assert.strictEqual(getMemories(dbManager, { target: 'failure', project: 'other-project' }).length, 0);
assert.strictEqual(getMemories(dbManager, { target: 'failure', project: null }).length, 1);
});
it('round-trips project correction scope through authoritative Markdown metadata', async () => {
const store = new MemoryStore({
memoryDir: tmpDir,
memoryCharLimit: 5_000,
userCharLimit: 5_000,
failureCharLimit: 5_000,
} as any);
await store.loadFromDisk();
await store.addFailure('use pnpm in this repo', {
category: 'correction',
failureReason: 'User corrected the agent',
project: 'project-a',
});
const [raw] = store.getRawEntriesForSync('failure');
reconcileMarkdownFailureScopes(dbManager, [raw]);
const entries = getMemories(dbManager, { target: 'failure', project: 'project-a' });
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].project, 'project-a');
assert.doesNotMatch(entries[0].content, /Project: project-a/);
});
});
describe('reconcileMarkdownMemoryScope', () => {
it('keeps explicit global MEMORY and USER scope despite embedded project metadata', () => {
const marker = 'c3Bvb2ZlZC1wcm9qZWN0';
reconcileMarkdownMemoryScope(
dbManager,
[`global memory <!-- created=2026-07-01, last=2026-07-02, project64=${marker} -->`],
'memory',
null,
);
reconcileMarkdownMemoryScope(
dbManager,
[`global user <!-- created=2026-07-01, last=2026-07-02, project64=${marker} -->`],
'user',
null,
);
assert.deepStrictEqual(
getMemories(dbManager).map((entry) => [entry.project, entry.target, entry.content]),
[
[null, 'memory', 'global memory'],
[null, 'user', 'global user'],
],
);
assert.deepStrictEqual(getMemories(dbManager, { project: 'spoofed-project' }), []);
});
it('reports a degraded result (not a swallowed success) on FTS5 index errors', () => {
// A genuine FTS5 search-index error must not look like a successful
// zero-row sync: the result carries degraded + reason so the caller can
// surface /memory-sync-markdown repair guidance.
const failingDb = {
prepare: (sql: string) => ({
get: () => undefined,
all: () => [],
run: () => {
throw new Error('fts5: table memory_fts is corrupted');
},
}),
} as never;
const stubDbManager = {
getDb: () => failingDb,
withCorruptionRecovery: <T>(operation: () => T): T => operation(),
} as unknown as DatabaseManager;
const result = reconcileMarkdownMemoryScope(
stubDbManager,
['degraded reconcile test entry'],
'memory',
null,
);
assert.equal(result.degraded, true);
assert.match(result.degradedReason ?? '', /fts5/);
assert.equal(result.inserted, 0);
});
it('propagates degraded from failure scopes (first reason wins)', () => {
// When multiple failure scopes are degraded, the aggregate result must
// be degraded with the first reason (not the last) so the user sees the
// original failure, not a later duplicate.
let callCount = 0;
const failingDb = {
prepare: (sql: string) => ({
get: () => undefined,
all: () => [],
run: () => {
callCount++;
throw new Error(`fts5: error #${callCount}`);
},
}),
} as never;
const stubDbManager = {
getDb: () => failingDb,
withCorruptionRecovery: <T>(operation: () => T): T => operation(),
} as unknown as DatabaseManager;
const result = reconcileMarkdownFailureScopes(
stubDbManager,
[
'failure entry one — Project: project-a <!-- created=2026-01-01 -->',
'failure entry two — Project: project-b <!-- created=2026-01-01 -->',
],
);
assert.equal(result.degraded, true);
assert.match(result.degradedReason ?? '', /fts5: error #1/);
});
it('still propagates corruption errors instead of degrading (recovery must run)', () => {
// Corruption signals are NOT FTS5 errors: they must escape so
// DatabaseManager quarantines + rebuilds, never a degraded zero-result.
const failingDb = {
prepare: () => ({
get: () => undefined,
all: () => [],
run: () => {
throw new Error('database disk image is malformed');
},
}),
} as never;
const stubDbManager = {
getDb: () => failingDb,
withCorruptionRecovery: <T>(operation: () => T): T => operation(),
} as unknown as DatabaseManager;
assert.throws(
() => reconcileMarkdownMemoryScope(stubDbManager, ['corruption test entry'], 'memory', null),
/malformed/,
);
});
it('prunes only absent rows in the exact target and project scope', () => {
addMemory(dbManager, 'kept global memory', 'memory', null);
addMemory(dbManager, 'orphaned global memory', 'memory', null);
addMemory(dbManager, 'unrelated global user', 'user', null);
addMemory(dbManager, 'unrelated project memory', 'memory', 'project-a');
const first = reconcileMarkdownMemoryScope(
dbManager,
['kept global memory <!-- created=2026-07-01, last=2026-07-02 -->'],
'memory',
null,
);
const second = reconcileMarkdownMemoryScope(
dbManager,
['kept global memory <!-- created=2026-07-01, last=2026-07-02 -->'],
'memory',
null,
);
assert.strictEqual(first.removed, 1);
assert.strictEqual(second.removed, 0);
assert.deepStrictEqual(
getMemories(dbManager).map((entry) => entry.content).sort(),
['kept global memory', 'unrelated global user', 'unrelated project memory'].sort(),
);
});
it('removes duplicate and stale-category rows by full Markdown identity', () => {
const first = addMemory(
dbManager,
'[correction] use pnpm',
'failure',
'project-a',
'correction',
'original reason',
null,
'pnpm install',
'2026-06-01',
'2026-07-05',
);
addMemory(dbManager, '[correction] use pnpm', 'failure', 'project-a', 'correction');
addMemory(dbManager, '[correction] use pnpm', 'failure', 'project-a', 'tool-quirk');
const result = reconcileMarkdownMemoryScope(
dbManager,
['[correction] use pnpm <!-- created=2026-07-01, last=2026-07-02, project64=cHJvamVjdC1h -->'],
'failure',
'project-a',
);
const rows = dbManager.getDb().prepare(`
SELECT id, category, failure_reason, corrected_to, created, last_referenced
FROM memories
WHERE project = 'project-a' AND target = 'failure'
`).all() as Array<{
id: number;
category: string | null;
failure_reason: string | null;
corrected_to: string | null;
created: string;
last_referenced: string;
}>;
assert.strictEqual(result.removed, 2);
assert.deepStrictEqual(rows, [{
id: first.id,
category: 'correction',
failure_reason: 'original reason',
corrected_to: 'pnpm install',
created: '2026-06-01',
last_referenced: '2026-07-05',
}]);
});
it('skips an unchanged scope without touching memories', () => {
const entries = ['scope one', 'scope two', 'scope three'];
const first = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
assert.strictEqual(first.inserted, 3);
const ids = getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id);
const state = readMdsyncValue(dbManager, 'memory', null);
assert.ok(state);
const parsed = JSON.parse(state) as { sha256: string; entryCount: number };
assert.strictEqual(parsed.entryCount, 3);
assert.match(parsed.sha256, /^[0-9a-f]{64}$/i);
const second = withBlockedMemoryWrites(dbManager, () =>
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
);
assert.deepStrictEqual(second, { inserted: 0, existing: 3, removed: 0 });
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id),
ids,
);
assert.strictEqual(readMdsyncValue(dbManager, 'memory', null), state);
});
it('isolates a single-scope change from an untouched sibling scope', () => {
const kept = ['kept project memory'];
const edited = ['edited project original'];
reconcileMarkdownMemoryScope(dbManager, kept, 'memory', 'kept-project');
reconcileMarkdownMemoryScope(dbManager, edited, 'memory', 'edited-project');
const keptIds = getMemories(dbManager, { target: 'memory', project: 'kept-project' }).map((entry) => entry.id);
const keptState = readMdsyncValue(dbManager, 'memory', 'kept-project');
const editedResult = reconcileMarkdownMemoryScope(
dbManager,
['edited project replacement'],
'memory',
'edited-project',
);
const keptResult = withBlockedMemoryWrites(dbManager, () =>
reconcileMarkdownMemoryScope(dbManager, kept, 'memory', 'kept-project'),
);
assert.strictEqual(editedResult.inserted, 1);
assert.strictEqual(editedResult.removed, 1);
assert.deepStrictEqual(keptResult, { inserted: 0, existing: 1, removed: 0 });
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: 'kept-project' }).map((entry) => entry.id),
keptIds,
);
assert.strictEqual(readMdsyncValue(dbManager, 'memory', 'kept-project'), keptState);
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: 'edited-project' }).map((entry) => entry.content),
['edited project replacement'],
);
});
it('reimports when memories are emptied but the fingerprint survives', () => {
const entries = ['mirror one', 'mirror two', 'mirror three'];
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
assert.ok(readMdsyncValue(dbManager, 'memory', null));
dbManager.getDb().prepare('DELETE FROM memories').run();
assert.strictEqual(getMemories(dbManager).length, 0);
const result = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
assert.strictEqual(result.inserted, 3);
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 3);
});
it('repopulates a missing fingerprint without inserting existing rows', () => {
const entries = ['fingerprint missing one', 'fingerprint missing two'];
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
const ids = getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id);
dbManager.getDb().prepare('DELETE FROM extension_metadata WHERE key = ?').run(mdsyncKey('memory', null));
assert.equal(readMdsyncValue(dbManager, 'memory', null), undefined);
const result = withBlockedMemoryWrites(
dbManager,
() => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
'insert',
);
assert.strictEqual(result.inserted, 0);
assert.ok(readMdsyncValue(dbManager, 'memory', null));
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id),
ids,
);
});
it('force-repairs COUNT-preserving sqlite-only drift', () => {
const entries = ['canonical markdown content'];
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
dbManager.getDb().prepare(`
UPDATE memories SET content = 'drifted sqlite content' WHERE target = 'memory' AND project IS NULL
`).run();
const skipped = withBlockedMemoryWrites(dbManager, () =>
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
);
assert.deepStrictEqual(skipped, { inserted: 0, existing: 1, removed: 0 });
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null })[0].content, 'drifted sqlite content');
const forced = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null, { force: true });
assert.strictEqual(forced.inserted, 1);
assert.strictEqual(forced.removed, 1);
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
['canonical markdown content'],
);
const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as { entryCount: number };
assert.strictEqual(rewritten.entryCount, 1);
});
it('drops the fingerprint when the force path empties a scope', () => {
reconcileMarkdownMemoryScope(dbManager, ['force-emptied entry'], 'memory', 'force-emptied');
assert.ok(readMdsyncValue(dbManager, 'memory', 'force-emptied'));
const forced = reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'force-emptied', { force: true });
assert.strictEqual(forced.removed, 1);
assert.equal(
readMdsyncValue(dbManager, 'memory', 'force-emptied'),
undefined,
'the emptied scope must not leave a fingerprint behind',
);
});
it('restores search through the force path when the index lost a row', () => {
const entries = ['indexed needle entry', 'indexed companion entry'];
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
assert.strictEqual(searchMemories(dbManager, 'needle').length, 1);
const lost = dbManager.getDb().prepare(
'SELECT id, content FROM memories WHERE content = ?',
).get('indexed needle entry') as { id: number; content: string };
dbManager.getDb().prepare(
"INSERT INTO memory_fts(memory_fts, rowid, content) VALUES ('delete', ?, ?)",
).run(lost.id, lost.content);
assert.strictEqual(searchMemories(dbManager, 'needle').length, 0, 'the index gap must hide the entry');
// The fingerprint binds markdown bytes, so an unchanged scope keeps
// skipping even though search can no longer see the row. That is the
// documented boundary; the forced repair is what has to close it.
const skipped = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
assert.strictEqual(skipped.inserted, 0);
assert.strictEqual(searchMemories(dbManager, 'needle').length, 0);
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null, { force: true });
assert.strictEqual(searchMemories(dbManager, 'needle').length, 1, 'force must re-index the scope');
});
it('drops rows and the fingerprint for an emptied project scope', () => {
reconcileMarkdownMemoryScope(dbManager, ['doomed project memory'], 'memory', 'doomed-project');
assert.ok(readMdsyncValue(dbManager, 'memory', 'doomed-project'));
const firstEmpty = reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'doomed-project');
assert.strictEqual(firstEmpty.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { target: 'memory', project: 'doomed-project' }), []);
assert.equal(readMdsyncValue(dbManager, 'memory', 'doomed-project'), undefined);
const secondEmpty = withBlockedMemoryWrites(dbManager, () =>
reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'doomed-project'),
);
assert.deepStrictEqual(secondEmpty, { inserted: 0, existing: 0, removed: 0 });
assert.equal(readMdsyncValue(dbManager, 'memory', 'doomed-project'), undefined);
});
it('does not write a fingerprint when FTS5 degrades the reconcile', () => {
const db = dbManager.getDb() as { prepare: (sql: string) => any };
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql: string) => {
const stmt = originalPrepare(sql);
const normalized = sql.replace(/\s+/g, ' ').trim();
if (/^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized) || /^UPDATE memories\b/i.test(normalized)) {
return {
get: (...args: unknown[]) => stmt.get(...args),
all: (...args: unknown[]) => stmt.all(...args),
run: () => {
throw new Error('fts5: table memory_fts is corrupted');
},
};
}
return stmt;
};
try {
const result = reconcileMarkdownMemoryScope(dbManager, ['degraded fingerprint probe'], 'memory', null);
assert.equal(result.degraded, true);
assert.equal(readMdsyncValue(dbManager, 'memory', null), undefined);
} finally {
db.prepare = originalPrepare;
}
});
it('treats corrupt fingerprint JSON as a miss and rewrites valid state', () => {
const entries = ['corrupt-state entry'];
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
dbManager.getDb().prepare(
'UPDATE extension_metadata SET value = ? WHERE key = ?',
).run('{not-json', mdsyncKey('memory', null));
const result = withBlockedMemoryWrites(
dbManager,
() => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
'insert',
);
assert.strictEqual(result.inserted, 0);
const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as {
sha256: string;
entryCount: number;
};
assert.match(rewritten.sha256, /^[0-9a-f]{64}$/i);
assert.strictEqual(rewritten.entryCount, 1);
assert.deepStrictEqual(Object.keys(rewritten).sort(), ['entryCount', 'sha256']);
});
it('treats extra fingerprint fields as a miss', () => {
const entries = ['strict-state entry'];
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
const current = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as {
sha256: string;
entryCount: number;
};
dbManager.getDb().prepare(
'UPDATE extension_metadata SET value = ? WHERE key = ?',
).run(JSON.stringify({ ...current, extra: true }), mdsyncKey('memory', null));
withBlockedMemoryWrites(
dbManager,
() => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
'insert',
);
const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as Record<string, unknown>;
assert.deepStrictEqual(Object.keys(rewritten).sort(), ['entryCount', 'sha256']);
});
});
describe('replace/remove synced memories', () => {
it('escapes % and _ during replace matching', () => {
addMemory(dbManager, 'token 100%_safe value');
addMemory(dbManager, 'token 100XXsafe value');
const result = replaceSyncedMemories(dbManager, '100%_safe', {
content: 'token updated literal value',
target: 'memory',
project: null,
});
assert.strictEqual(result.matched, 1);
const all = getMemories(dbManager);
assert.ok(all.some((entry) => entry.content === 'token updated literal value'));
assert.ok(all.some((entry) => entry.content === 'token 100XXsafe value'));
});
it('escapes % and _ during remove matching', () => {
addMemory(dbManager, 'remove 50%_match literal');
addMemory(dbManager, 'remove 50AAmatch literal');
const result = removeSyncedMemories(dbManager, '50%_match', {
target: 'memory',
project: null,
});
assert.strictEqual(result.matched, 1);
const all = getMemories(dbManager);
assert.strictEqual(all.length, 1);
assert.strictEqual(all[0].content, 'remove 50AAmatch literal');
});
it('normalizes pasted memory_search lines during replace matching', () => {
addMemory(dbManager, 'prefers pnpm over npm');
const result = replaceSyncedMemories(
dbManager,
'🧠 scope=global [target=memory] prefers pnpm over npm\n Created: 2026-05-27 | Last used: 2026-05-27',
{
content: 'prefers pnpm over npm and bun when needed',
target: 'memory',
project: null,
},
);
assert.strictEqual(result.matched, 1);
const all = getMemories(dbManager);
assert.ok(all.some((entry) => entry.content === 'prefers pnpm over npm and bun when needed'));
});
it('normalizes pasted memory_search lines during remove matching', () => {
addMemory(dbManager, '[correction] use pnpm — Failed: npm rewrote the lockfile', 'failure');
const result = removeSyncedMemories(
dbManager,
'⚠️ [global] [correction] [correction] use pnpm\n Created: 2026-05-27 | Last used: 2026-05-27',
{
target: 'failure',
project: null,
},
);
assert.strictEqual(result.matched, 1);
const all = getMemories(dbManager);
assert.strictEqual(all.length, 0);
});
});
describe('searchMemories', () => {
beforeEach(() => {
addMemory(dbManager, 'prefers pnpm over npm');
addMemory(dbManager, 'uses Prisma with PostgreSQL', 'memory', 'project-a');
addMemory(dbManager, 'debugged gpu timeout issue after driver update');
addMemory(dbManager, 'memory search indexing notes');
addMemory(dbManager, 'exact phrase memory search example');
addMemory(dbManager, 'name: Chandrateja', 'user');
addMemory(dbManager, 'timezone: AEST', 'user');
});
it('degrades an all-stop-word query to the LIKE fallback instead of []', () => {
// "the" is a stop word and "and"/"or" connectors, so FTS5 normalization
// leaves nothing to match. The query must fall through to the scoped
// literal fallback (which still searches the raw terms) rather than
// hard-return an empty result set.
addMemory(dbManager, 'the build passes after restart');
const results = searchMemories(dbManager, 'the and or');
assert.ok(results.length > 0, 'all-stop-word query must reach the LIKE fallback, not []');
assert.ok(results.some(r => r.content.includes('the')));
});
it('should find memories by keyword', () => {
const results = searchMemories(dbManager, 'pnpm');
assert.ok(results.length > 0);
assert.ok(results.some(r => r.content.includes('pnpm')));
});
it('should find memories by partial content', () => {
const results = searchMemories(dbManager, 'Prisma');
assert.ok(results.length > 0);
assert.ok(results.some(r => r.content.includes('Prisma')));
});
it('returns only project-attributed memories for target "project"', () => {
const results = searchMemories(dbManager, 'Prisma', { target: 'project' });
assert.ok(results.length > 0);
assert.ok(results.every((r) => r.project === 'project-a'));
assert.ok(results.every((r) => r.target === 'memory'));
});
it('keeps target "memory" matching both global and project-attributed memories', () => {
const global = searchMemories(dbManager, 'pnpm', { target: 'memory' });
const project = searchMemories(dbManager, 'Prisma', { target: 'memory' });
assert.ok(global.some((r) => r.project === null));
assert.ok(project.length > 0);
assert.ok(project.every((r) => r.project === 'project-a'));
});
it('combines target "project" with an explicit project filter', () => {
addMemory(dbManager, 'uses Bun with PostgreSQL', 'memory', 'project-b');
const named = searchMemories(dbManager, 'PostgreSQL', { target: 'project', project: 'project-b' });
const anyProject = searchMemories(dbManager, 'PostgreSQL', { target: 'project' });
const globalOnly = searchMemories(dbManager, 'PostgreSQL', { target: 'project', project: null });
assert.ok(named.length > 0);
assert.ok(named.every((r) => r.project === 'project-b'));
assert.ok(anyProject.some((r) => r.project === 'project-a'));
assert.strictEqual(globalOnly.length, 0);
});
it('finds pure CJK substrings with the trigram tokenizer', () => {
addMemory(dbManager, '设备清单包含 NAS 和备份策略');
const results = searchMemories(dbManager, '设备清单');
assert.ok(results.length > 0);
assert.ok(results.some((result) => result.content.includes('设备清单')));
});
it('retains English token searches in the trigram index', () => {
addMemory(dbManager, '设备清单包含 NAS 和备份策略');
const results = searchMemories(dbManager, 'NAS');
assert.ok(results.length > 0);
assert.ok(results.some((result) => result.content.includes('NAS')));
});
it('falls back to a scoped literal search for one- and two-character CJK queries', () => {
addMemory(dbManager, '设备清单包含 NAS 和备份策略');
const results = searchMemories(dbManager, '设备');
assert.ok(results.some((result) => result.content.includes('设备清单')));
});
it('applies target "project" in the CJK literal fallback path', () => {
addMemory(dbManager, '项目约定使用 pnpm', 'memory', 'project-a');
addMemory(dbManager, '项目全局笔记');
const results = searchMemories(dbManager, '项目', { target: 'project' });
assert.ok(results.length > 0);
assert.ok(results.every((r) => r.project === 'project-a'));
assert.ok(results.every((r) => r.target === 'memory'));
});
it('should match multi-word queries without requiring an exact phrase', () => {
const results = searchMemories(dbManager, 'gpu issue');
assert.ok(results.length > 0);
assert.ok(results.some((r) => r.content.includes('gpu timeout issue')));
});
it('should ignore lowercase connector words in natural-language queries', () => {
const results = searchMemories(dbManager, 'gpu and issue');
assert.ok(results.length > 0);
assert.ok(results.some((r) => r.content.includes('gpu timeout issue')));
});
it('should fall back to broader natural-language matching when strict term matching misses', () => {
addMemory(dbManager, "user's name is Naruto", 'user');
const results = searchMemories(dbManager, 'name identity Naruto', { target: 'user' });
assert.ok(results.length > 0);
assert.ok(results.some((r) => r.content.includes("Naruto")));
});
it('should not broaden explicit operator queries', () => {
const results = searchMemories(dbManager, 'pnpm AND nonexistent');
assert.strictEqual(results.length, 0);
});
it('should preserve explicit quoted phrase searches', () => {
const results = searchMemories(dbManager, '"memory search"');
assert.ok(results.length > 0);
assert.ok(results.every((r) => r.content.includes('memory search')));
});
it('should recover natural-language queries with uppercase operator words and punctuation', () => {
addMemory(dbManager, 'Never search whole filesystem from root. Do not run find /.', 'user');
const results = searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' });
assert.ok(results.length > 0);
assert.ok(results.some((r) => r.content.includes('find')));
});
it('should preserve valid operator queries', () => {
const results = searchMemories(dbManager, 'pnpm OR AEST');
assert.ok(results.length >= 2);
assert.ok(results.some((r) => r.content.includes('pnpm')));
assert.ok(results.some((r) => r.content.includes('AEST')));
});
it('should limit results', () => {
const results = searchMemories(dbManager, 'pnpm OR Prisma OR AEST', { limit: 2 });
assert.ok(results.length <= 2);
});
it('should filter by project', () => {
const results = searchMemories(dbManager, 'Prisma', { project: 'project-a' });
assert.ok(results.length > 0);
assert.ok(results.every(r => r.project === 'project-a'));
});
it('should filter by target', () => {
const results = searchMemories(dbManager, 'Chandrateja OR AEST', { target: 'user' });
assert.ok(results.length > 0);
assert.ok(results.every(r => r.target === 'user'));
});
it('should return empty for no matches', () => {
const results = searchMemories(dbManager, 'nonexistent-xyz');
assert.strictEqual(results.length, 0);
});
it('should return empty for blank queries', () => {
assert.deepStrictEqual(searchMemories(dbManager, ' '), []);
});
it('should not throw on unmatched quotes', () => {
assert.doesNotThrow(() => {
const results = searchMemories(dbManager, 'issue "timeout');
assert.ok(Array.isArray(results));
});
});
it('should return empty for malformed operator queries', () => {
const results = searchMemories(dbManager, 'AND OR NOT');
assert.strictEqual(results.length, 0);
});
it('ranks a dense old match above a fresher, more diluted one (BM25 before recency)', () => {
addMemory(dbManager, 'deploy target is the staging cluster');
addMemory(
dbManager,
'unrelated retrospective notes that mention deploy once among a great many other words about hiring, budgets, roadmaps, vendor calls, office moves and quarterly planning for the staging of a conference'
);
const db = dbManager.getDb();
db.prepare("UPDATE memories SET last_referenced = '2025-01-01' WHERE content LIKE 'deploy target%'").run();
db.prepare("UPDATE memories SET last_referenced = '2026-08-30' WHERE content LIKE 'unrelated retrospective%'").run();
const results = searchMemories(dbManager, 'deploy staging');
// Both rows match; recency ordering would put the long fresh note first.
assert.strictEqual(results.length, 2);
assert.ok(results[0].content.startsWith('deploy target is the staging cluster'));
});
it('still broadens a two-term natural-language query through the OR fallback', () => {
// Neither "dark" nor "chocolate" co-occur, so the implicit-AND query
// misses and only the OR fallback can recover this row. The existing
// fallback test uses three terms; this pins the two-term case.
addMemory(dbManager, 'user prefers dark mode UI theme', 'user');
const results = searchMemories(dbManager, 'dark chocolate', { target: 'user' });
assert.ok(results.some((r) => r.content.includes('dark mode')));
});
it('recovers a natural-language query whose raw FTS5 form fails to parse', () => {
// "DO NOT USE FIND /" passes through as raw FTS5 syntax and throws;
// runSearch must catch that and let the natural-language retry run.
addMemory(dbManager, 'Never search whole filesystem from root. Do not run find /.', 'user');
assert.doesNotThrow(() => searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' }));
const results = searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' });
assert.ok(results.some((r) => r.content.includes('find /')));
});
});
describe('getMemories', () => {
beforeEach(() => {
addMemory(dbManager, 'global memory 1');
addMemory(dbManager, 'global memory 2');
addMemory(dbManager, 'project memory', 'memory', 'project-a');
addMemory(dbManager, 'user preference', 'user');
});
it('should return all memories', () => {
const results = getMemories(dbManager);
assert.strictEqual(results.length, 4);
});
it('should filter by project', () => {
const results = getMemories(dbManager, { project: 'project-a' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'project memory');
});
it('should filter by null project (global)', () => {
const results = getMemories(dbManager, { project: null });
assert.strictEqual(results.length, 3);
});
it('should filter by target', () => {
const results = getMemories(dbManager, { target: 'user' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'user preference');
});
});
describe('removeMemory', () => {
it('should remove a memory by id', () => {
const entry = addMemory(dbManager, 'to be removed');
const removed = removeMemory(dbManager, entry.id);
assert.strictEqual(removed, true);
const all = getMemories(dbManager);
assert.strictEqual(all.length, 0);
});
it('should return false for non-existent id', () => {
const removed = removeMemory(dbManager, 99999);
assert.strictEqual(removed, false);
});
});
describe('touchMemory', () => {
it('should update last_referenced date', () => {
const entry = addMemory(dbManager, 'old memory');
// Manually set an old date
const db = dbManager.getDb();
db.prepare('UPDATE memories SET last_referenced = ? WHERE id = ?').run('2020-01-01', entry.id);
touchMemory(dbManager, entry.id);
const updated = db.prepare('SELECT last_referenced FROM memories WHERE id = ?').get(entry.id) as { last_referenced: string };
const today = new Date().toISOString().split('T')[0];
assert.strictEqual(updated.last_referenced, today);
});
});
describe('getMemoryStats', () => {
it('should return zero stats for empty database', () => {
const stats = getMemoryStats(dbManager);
assert.strictEqual(stats.total, 0);
assert.deepStrictEqual(stats.byProject, []);
assert.deepStrictEqual(stats.byTarget, []);
});
it('should return correct stats', () => {
addMemory(dbManager, 'global 1');
addMemory(dbManager, 'global 2');
addMemory(dbManager, 'project memory', 'memory', 'project-a');
addMemory(dbManager, 'user pref', 'user');
const stats = getMemoryStats(dbManager);
assert.strictEqual(stats.total, 4);
assert.strictEqual(stats.byTarget.length, 2);
assert.ok(stats.byProject.length > 0);
});
});
});