From a2ddd6fb22757286dff3b481d2bfc3aad1a4a40f Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 16 Aug 2026 15:42:06 +0800 Subject: [PATCH 1/6] fix: enforce native store lifecycle --- src/memory-bodies.ts | 34 ++++++++++++++++++++++++---------- tests/memory-bodies.spec.ts | 20 ++++++++++++-------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/memory-bodies.ts b/src/memory-bodies.ts index 9a30d26..eaac460 100644 --- a/src/memory-bodies.ts +++ b/src/memory-bodies.ts @@ -75,8 +75,10 @@ export class MemoryBodyRegistry { async create(request: CreateMemoryBodyRequest, signal?: AbortSignal): Promise { const name = requiredText(request.name, 'name', 100) const description = requiredText(request.description, 'description', 1000) - let id = validateMemoryBodyId(randomUUID()) - while (this.list().some(body => body.id === id)) id = validateMemoryBodyId(randomUUID()) + const reservedIds = new Set(this.list().map(body => body.id)) + const nativeStoreIds = this.nativeStoreIds() + let id = nativeStoreIds.length === 0 && !reservedIds.has('default') ? 'default' : validateMemoryBodyId(randomUUID()) + while (reservedIds.has(id) || nativeStoreIds.includes(id)) id = validateMemoryBodyId(randomUUID()) await this.runner.runText(['store', 'create', id], { ...(signal === undefined ? {} : { signal }), store: id }) const timestamp = this.now().toISOString() const body: StoredMemoryBody = { @@ -111,20 +113,24 @@ export class MemoryBodyRegistry { async remove(id: string, signal?: AbortSignal): Promise { const body = this.get(id) + const nativeStoreIds = this.nativeStoreIds() + if (nativeStoreIds.includes(body.id) && nativeStoreIds.length === 1) { + throw new Error(`cannot delete the last Mnemon Store "${body.id}"; disable it for DSH or create another Memory Space first`) + } const persistedStore = this.runner.persistedStore() const commands: MnemonTextCommand[] = [] let commandStore = persistedStore if (persistedStore === body.id) { + const nativeIds = new Set(nativeStoreIds) const replacement = this.list() - .filter(candidate => candidate.id !== body.id) - .sort((left, right) => Number(right.active) - Number(left.active) || left.id.localeCompare(right.id))[0] - if (replacement === undefined) { - throw new Error(`cannot delete the only Mnemon Store "${body.id}"; create another Memory Space first`) - } - commandStore = replacement.id + .filter(candidate => candidate.id !== body.id && nativeIds.has(candidate.id)) + .sort((left, right) => Number(right.active) - Number(left.active) || left.id.localeCompare(right.id))[0]?.id + ?? nativeStoreIds.filter(candidate => candidate !== body.id).sort()[0] + if (replacement === undefined) throw new Error(`cannot switch away from Mnemon Store "${body.id}" before deleting it`) + commandStore = replacement commands.push({ - args: ['store', 'set', replacement.id], - options: { ...(signal === undefined ? {} : { signal }), store: replacement.id }, + args: ['store', 'set', replacement], + options: { ...(signal === undefined ? {} : { signal }), store: replacement }, }) } commands.push({ @@ -205,6 +211,14 @@ export class MemoryBodyRegistry { if (changed) this.save() } + private nativeStoreIds(): string[] { + if (!existsSync(this.directory)) return [] + return readdirSync(this.directory, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && ID_PATTERN.test(entry.name)) + .map(entry => entry.name) + .sort() + } + private view(body: StoredMemoryBody): MemoryBody { return { ...body, dbPath: join(this.directory, body.id, 'mnemon.db') } } diff --git a/tests/memory-bodies.spec.ts b/tests/memory-bodies.spec.ts index ac6d3aa..1e5e53b 100644 --- a/tests/memory-bodies.spec.ts +++ b/tests/memory-bodies.spec.ts @@ -68,26 +68,29 @@ describe('MemoryBodyRegistry', () => { expect(readFileSync(join(dataDir, 'data', 'default', 'mnemon.db'), 'utf8')).toBe('existing database') }) - it('persists names, descriptions, and activation independently from Mnemon data', async () => { + it('uses default for the first native Store and persists DSH metadata independently', async () => { const dataDir = temporaryDirectory() const process = vi.fn(async () => ({ stdout: 'Created store', stderr: '', exitCode: 0 })) const runner = createRunner(resolveConfig({ cliPath: '/fake/mnemon', dataDir, store: 'default' }), process) const registry = new MemoryBodyRegistry(runner, true, () => new Date('2026-08-13T00:00:00.000Z')) const created = await registry.create({ name: '产品决策', description: '产品范围、取舍与稳定决策;规划或复盘产品方向时召回。' }) - expect(created.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + expect(created.id).toBe('default') + expect(created.active).toBe(false) registry.update(created.id, { active: true, description: '稳定产品上下文;规划或复盘产品方向时召回。' }) const reloaded = new MemoryBodyRegistry(runner, true) expect(reloaded.get(created.id)).toMatchObject({ name: '产品决策', description: '稳定产品上下文;规划或复盘产品方向时召回。', active: true }) - expect(process).toHaveBeenCalledWith('/fake/mnemon', expect.arrayContaining(['--store', created.id, 'store', 'create', created.id]), expect.anything()) + expect(process).toHaveBeenCalledWith('/fake/mnemon', expect.arrayContaining(['--store', 'default', 'store', 'create', 'default']), expect.anything()) }) it('removes the native store before deleting its catalog entry', async () => { const dataDir = temporaryDirectory() const storeDirectory = join(dataDir, 'data', 'project') - mkdirSync(storeDirectory, { recursive: true }) - writeFileSync(join(storeDirectory, 'mnemon.db'), 'existing database') + for (const store of ['default', 'project']) { + mkdirSync(join(dataDir, 'data', store), { recursive: true }) + writeFileSync(join(dataDir, 'data', store, 'mnemon.db'), 'existing database') + } const process = vi.fn(async (_command, args) => { if (args.includes('remove')) rmSync(storeDirectory, { recursive: true, force: true }) return { stdout: 'Removed store', stderr: '', exitCode: 0 } @@ -96,7 +99,7 @@ describe('MemoryBodyRegistry', () => { const registry = new MemoryBodyRegistry(runner, true) await expect(registry.remove('project')).resolves.toMatchObject({ id: 'project', name: 'project' }) - expect(registry.list()).toEqual([]) + expect(registry.list()).toEqual([expect.objectContaining({ id: 'default' })]) expect(process).toHaveBeenCalledWith('/fake/mnemon', ['--data-dir', dataDir, '--store', 'default', 'store', 'remove', 'project'], expect.anything()) }) @@ -128,7 +131,7 @@ describe('MemoryBodyRegistry', () => { ]) }) - it('preserves the last active Store when Mnemon has nowhere to switch', async () => { + it('preserves the last native Store even when it is disabled for DSH', async () => { const dataDir = temporaryDirectory() mkdirSync(join(dataDir, 'data', 'default'), { recursive: true }) writeFileSync(join(dataDir, 'data', 'default', 'mnemon.db'), 'default database') @@ -137,7 +140,7 @@ describe('MemoryBodyRegistry', () => { const registry = new MemoryBodyRegistry(runner, true) registry.setActive('default', false) - await expect(registry.remove('default')).rejects.toThrow('create another Memory Space first') + await expect(registry.remove('default')).rejects.toThrow('disable it for DSH or create another Memory Space first') expect(registry.list()).toEqual([expect.objectContaining({ id: 'default', active: false })]) expect(process).not.toHaveBeenCalled() }) @@ -149,6 +152,7 @@ describe('MemoryBodyRegistry', () => { const registry = new MemoryBodyRegistry(runner, true) await expect(registry.create({ name: '含义不足', description: '' })).rejects.toThrow('description is required') + await registry.create({ name: '基础空间', description: '首次初始化使用 Mnemon 原生 default Store。' }) const created = await registry.create({ name: '发布与交付', description: '发布门禁、部署约束与回滚经验;准备发布时召回。' }) expect(created.id).not.toContain('发布') expect(created.id).toMatch(/^[0-9a-f-]{36}$/) From f2a3fa9a4456fcb6bc1d58ac111c871e5c8c4c45 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 16 Aug 2026 15:43:44 +0800 Subject: [PATCH 2/6] fix: preserve native store lifecycle during pack import --- src/pack.ts | 35 +++++++++++++++++++++++++++++++++++ tests/pack.spec.ts | 14 ++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/pack.ts b/src/pack.ts index 72a6df2..50caef8 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -551,6 +551,31 @@ function mergeRegistry(root: string, pack: ParsedPack): { registry: BodyRegistry return current } +function persistedStore(root: string): string { + try { + const value = readFileSync(join(root, 'active'), 'utf8').trim() + if (BODY_ID.test(value)) return value + } catch {} + return 'default' +} + +function reconcilePersistedStore(root: string): void { + const current = readCurrentRegistry(root).registry.bodies + const ids = new Set(current.map(body => body.id)) + const selected = persistedStore(root) + if (ids.has(selected)) return + const replacement = ids.has('default') + ? 'default' + : current.filter(body => body.active).map(body => body.id).sort()[0] ?? [...ids].sort()[0] ?? 'default' + const temporary = join(root, `.active-${process.pid}-${randomUUID()}.tmp`) + try { + writeFileSync(temporary, `${replacement}\n`, { mode: 0o600 }) + renameSync(temporary, join(root, 'active')) + } finally { + rmSync(temporary, { force: true }) + } +} + function stageImport(root: string, pack: ParsedPack, components: MnemonPackComponent[], mode: MnemonPackImportMode): string { const staging = join(root, `.dsh-pack-stage-${randomUUID()}`) mkdirSync(staging, { recursive: true, mode: 0o700 }) @@ -565,6 +590,9 @@ function stageImport(root: string, pack: ParsedPack, components: MnemonPackCompo } if (components.includes('memory-spaces')) { const memory = mode === 'merge' ? mergeRegistry(root, pack) : archiveRegistry(pack) + if (mode === 'replace' && readCurrentRegistry(root).registry.bodies.length > 0 && memory.registry.bodies.length === 0) { + throw new Error('cannot replace the last Mnemon Store with an empty Memory Space set') + } writeRegistry(join(staging, 'data'), memory.registry, memory.databases) } return staging @@ -581,6 +609,8 @@ function commitStaging(root: string, staging: string, components: MnemonPackComp const replacementLocks = components.flatMap(component => component === 'runtime' ? [join(staging, 'runtime', '.memories.lock')] : component === 'documents' ? [join(staging, 'documents', '.index.lock')] : []) + const activePath = join(root, 'active') + const previousActive = existsSync(activePath) ? readFileSync(activePath) : undefined try { for (const lock of replacementLocks) writeFileSync(lock, 'pack-import\n', { mode: 0o600 }) for (const component of components) { @@ -597,6 +627,7 @@ function commitStaging(root: string, staging: string, components: MnemonPackComp } committed.push({ directory, hadPrevious }) } + if (components.includes('memory-spaces')) reconcilePersistedStore(root) if (components.includes('runtime')) rmSync(join(root, 'runtime', '.memories.lock'), { force: true }) if (components.includes('documents')) rmSync(join(root, 'documents', '.index.lock'), { force: true }) } catch (error) { @@ -605,6 +636,10 @@ function commitStaging(root: string, staging: string, components: MnemonPackComp rmSync(target, { recursive: true, force: true }) if (entry.hadPrevious) renameSync(join(backup, entry.directory), target) } + if (components.includes('memory-spaces')) { + if (previousActive === undefined) rmSync(activePath, { force: true }) + else writeFileSync(activePath, previousActive, { mode: 0o600 }) + } throw error } finally { rmSync(staging, { recursive: true, force: true }) diff --git a/tests/pack.spec.ts b/tests/pack.spec.ts index bf6f424..ce5acca 100644 --- a/tests/pack.spec.ts +++ b/tests/pack.spec.ts @@ -47,6 +47,7 @@ async function fixture(label: string, seed: number, bodyId = 'project') { version: 1, bodies: [{ id: bodyId, name: `Space ${seed}`, description: `Seed ${seed}`, active: true, createdAt: now().toISOString(), updatedAt: now().toISOString() }], }, null, 2)}\n`) + writeFileSync(join(root, 'active'), `${bodyId}\n`) return { root, workspace, ...created, manager: new MnemonPackManager(created.runner, created.config, undefined, now) } } @@ -113,6 +114,7 @@ describe('Mnemon Pack', () => { expect(readdirSync(join(target.root, 'data')).filter(name => !name.startsWith('.'))).toEqual(['source']) expect(readFileSync(join(target.root, 'data', 'source', 'mnemon.db'))).toEqual(sqlite(4)) + expect(readFileSync(join(target.root, 'active'), 'utf8')).toBe('source\n') expect(refreshed).toHaveBeenCalledWith(['runtime', 'documents', 'memory-spaces']) expect(readdirSync(target.root).some(name => name.startsWith('.dsh-pack-stage-') || name.startsWith('.dsh-pack-backup-'))).toBe(false) }) @@ -146,6 +148,18 @@ describe('Mnemon Pack', () => { expect(readdirSync(join(target.root, 'data')).filter(name => !name.startsWith('.'))).toEqual(['target']) }) + it('refuses to replace an initialized root with an empty Memory Space set', async () => { + const empty = runner(temporary('pack-empty-source')) + const emptyManager = new MnemonPackManager(empty.runner, empty.config, undefined, now) + const exported = await emptyManager.exportPack('memory-spaces') + const target = await fixture('pack-empty-target', 10, 'default') + + await expect(target.manager.importPack(exported.base64, { mode: 'replace' })).rejects.toThrow('last Mnemon Store') + + expect(readFileSync(join(target.root, 'data', 'default', 'mnemon.db'))).toEqual(sqlite(10)) + expect(readFileSync(join(target.root, 'active'), 'utf8')).toBe('default\n') + }) + it('leaves the target untouched when staging a merge fails', async () => { const source = await fixture('pack-rollback-source', 10) const target = await fixture('pack-rollback-target', 11) From 80279f80fb549b52f40db117354cbcf3658b0a6f Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 16 Aug 2026 15:45:00 +0800 Subject: [PATCH 3/6] feat: expose native and DSH store selection separately --- src/commands.ts | 3 ++- src/service.ts | 14 +++++++++----- src/shared/contracts.ts | 5 +++++ tests/commands.spec.ts | 7 ++++++- tests/service.spec.ts | 4 ++++ 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 0402045..20427b6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -53,7 +53,8 @@ async function execute(serviceOrSource: MnemonService | AgentServiceSource, coor return { kind: 'success', text: [ - `Mnemon ${status.version ?? ''} · store=${status.store}`.trim(), + `Mnemon ${status.version ?? ''} · default=${status.mnemonDefaultStore}`.trim(), + `DSH 已激活: ${status.dshActiveStores.join(', ') || 'none'}`, `CLI: ${status.cliPath}`, `数据目录: ${status.dataDir}`, `有效记忆: ${stats?.totalInsights ?? 0} · 连接: ${stats?.edgeCount ?? 0} · 已删除: ${stats?.deletedInsights ?? 0}`, diff --git a/src/service.ts b/src/service.ts index d52177f..adb419c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -189,7 +189,8 @@ export class MnemonService { async bodies(signal?: AbortSignal): Promise { const items: MemoryBodyView[] = [] - for (const body of this.memoryBodies.list()) items.push(await this.bodyStatus(body, signal)) + const mnemonDefaultStore = this.runner.persistedStore() + for (const body of this.memoryBodies.list()) items.push(await this.bodyStatus(body, body.id === mnemonDefaultStore, signal)) return { items, total: items.length, @@ -202,11 +203,14 @@ export class MnemonService { async status(signal?: AbortSignal): Promise { const catalog = await this.bodies(signal) const active = catalog.items.filter(body => body.active) + const dshActiveStores = active.map(body => body.id) const base = { cliPath: this.runner.command, commandFound: this.runner.commandFound, dataDir: this.runner.effectiveDataDir(), - store: active.map(body => body.id).join(', ') || 'none', + store: dshActiveStores.join(', ') || 'none', + mnemonDefaultStore: this.runner.persistedStore(), + dshActiveStores, writeEnabled: this.config.writeEnabled, timeoutMs: this.config.timeoutMs, defaultRecallLimit: this.config.defaultRecallLimit, @@ -429,14 +433,14 @@ export class MnemonService { } } - private async bodyStatus(body: MemoryBody, signal?: AbortSignal): Promise { + private async bodyStatus(body: MemoryBody, mnemonDefault: boolean, signal?: AbortSignal): Promise { try { const raw = await this.runner.runJson(['status'], { ...(signal === undefined ? {} : { signal }), store: body.id }) const status = record(raw) if (status === undefined) throw new Error('mnemon status returned an unexpected payload') - return { ...body, healthy: true, stats: this.parseStats(status) } + return { ...body, mnemonDefault, healthy: true, stats: this.parseStats(status) } } catch (error) { - return { ...body, healthy: false, error: error instanceof Error ? error.message : String(error) } + return { ...body, mnemonDefault, healthy: false, error: error instanceof Error ? error.message : String(error) } } } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index c06da01..42b90da 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -189,6 +189,8 @@ export interface MemoryBodyStats { } export interface MemoryBodyView extends MemoryBody { + /** True when Mnemon's persisted active-file selection points to this Store. */ + mnemonDefault: boolean healthy: boolean error?: string stats?: MemoryBodyStats @@ -500,7 +502,10 @@ export interface StatusView { cliPath: string commandFound: boolean dataDir: string + /** Legacy comma-separated DSH-enabled Store list. */ store: string + mnemonDefaultStore: string + dshActiveStores: string[] writeEnabled: boolean timeoutMs: number defaultRecallLimit: number diff --git a/tests/commands.spec.ts b/tests/commands.spec.ts index 5383fbc..625fd96 100644 --- a/tests/commands.spec.ts +++ b/tests/commands.spec.ts @@ -29,13 +29,18 @@ describe('/mnemon command', () => { cliPath: '/usr/local/bin/mnemon', dataDir: '/tmp/mnemon', store: 'project', + mnemonDefaultStore: 'default', + dshActiveStores: ['project'], writeEnabled: true, defaultRecallLimit: 10, stats: { totalInsights: 3, edgeCount: 2, deletedInsights: 1 }, })), } as unknown as MnemonService const result = await createMnemonCommand(service, coordinator()).handler(invocation('status')) - expect(result).toEqual(expect.objectContaining({ kind: 'success', text: expect.stringContaining('store=project') })) + expect(result).toEqual(expect.objectContaining({ + kind: 'success', + text: expect.stringMatching(/default=default[\s\S]*DSH 已激活: project/u), + })) expect(service.status).toHaveBeenCalledOnce() }) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index d8af960..4f99464 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -25,6 +25,7 @@ function populatedDataDir(): string { temporaryDirectories.push(dataDir) mkdirSync(join(dataDir, 'data', 'work'), { recursive: true }) writeFileSync(join(dataDir, 'data', 'work', 'mnemon.db'), 'fixture database') + writeFileSync(join(dataDir, 'active'), 'work\n') return dataDir } @@ -80,10 +81,13 @@ describe('MnemonService', () => { healthy: true, version: '0.1.2', store: 'work', + mnemonDefaultStore: 'work', + dshActiveStores: ['work'], dataDir, timeoutMs: 4321, stats: { totalInsights: 3, edgeCount: 4, byCategory: { decision: 2 } }, }) + expect(status.memoryBodies).toEqual([expect.objectContaining({ id: 'work', active: true, mnemonDefault: true })]) expect(process).toHaveBeenCalledWith('/fake/mnemon', ['--data-dir', dataDir, '--store', 'work', 'status'], expect.anything()) expect(process).toHaveBeenCalledWith('/fake/mnemon', ['--version'], expect.anything()) }) From 6905521b61c4ba5d2577bd50624aeee1502f9069 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 16 Aug 2026 15:47:33 +0800 Subject: [PATCH 4/6] feat: clarify DSH activation and Mnemon default in UI --- src/client/MnemonView.module.css | 1 + src/client/MnemonView.tsx | 9 +++++---- src/client/locales.ts | 8 ++++++-- tests/client.spec.tsx | 17 ++++++++++++++++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/client/MnemonView.module.css b/src/client/MnemonView.module.css index 7dd3f57..9231d70 100644 --- a/src/client/MnemonView.module.css +++ b/src/client/MnemonView.module.css @@ -168,6 +168,7 @@ .bodyCardTop code { overflow: hidden; color: var(--mn-faint); font: 9px var(--mn-code); text-overflow: ellipsis; white-space: nowrap; } .bodyHealth { color: var(--mn-success); font: 650 8px var(--mn-code); letter-spacing: .07em; text-transform: uppercase; } .bodyCard:not([data-healthy]) .bodyHealth { color: var(--mn-danger); } +.mnemonDefaultBadge { width: fit-content; padding: 2px 5px; border: 1px solid color-mix(in srgb, var(--mn-accent) 24%, var(--mn-line)); border-radius: 999px; color: var(--mn-accent); background: color-mix(in srgb, var(--mn-accent) 7%, transparent); font: 650 8px var(--mn-code); white-space: nowrap; } .bodySwitch { display: flex; min-height: 32px; align-items: center; gap: 6px; border: 0; padding: 0 1px; color: var(--mn-faint); background: transparent; font-size: 9.5px; cursor: pointer; } .bodySwitchTrack { position: relative; width: 29px; height: 17px; flex: 0 0 auto; border: 1px solid var(--mn-line-strong); border-radius: 999px; background: var(--mn-layer-2); transition: border-color .18s, background-color .18s; } .bodySwitchTrack i { position: absolute; top: 2px; left: 2px; width: 11px; height: 11px; border-radius: 50%; background: var(--mn-faint); transition: transform .2s cubic-bezier(.2, .8, .2, 1), background-color .18s; } diff --git a/src/client/MnemonView.tsx b/src/client/MnemonView.tsx index 635016a..75313eb 100644 --- a/src/client/MnemonView.tsx +++ b/src/client/MnemonView.tsx @@ -929,6 +929,7 @@ function OverviewPage(props: { client: MnemonClient; revision: number; writeEnab const selectedKind = selected === null ? null : graphNodeKind(selected) const editingBodyView = editingBody === null ? undefined : catalog?.items.find(body => body.id === editingBody) const deletingBodyView = confirmingDeleteBody === null ? undefined : catalog?.items.find(body => body.id === confirmingDeleteBody) + const canDeleteBody = (catalog?.total ?? 0) > 1 const bodyEditForm = (body: MemoryBodyView) =>
void saveEdit(event, body)}>