Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions cli-supersedes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Regression tests for `--store --supersedes` on the CLI.
// Uses a fresh temp DB via TOKENMEM_DB_PATH; never touches tokenmem.db.
// Run: node cli-supersedes.test.mjs
//
// Why this flag needed a test rather than just a pass-through: a *partial*
// supersede is worse than none. If one of the requested rowids is already
// deleted/superseded/nonexistent, the write still lands and the stale version
// stays live and recallable — the correction looks applied and isn't. So the
// CLI pre-checks the targets and refuses the whole write, rather than letting
// storeMemory silently point at whatever still exists.

import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import Database from 'better-sqlite3'

const __dirname = dirname(fileURLToPath(import.meta.url))
const root = mkdtempSync(resolve(tmpdir(), 'mneme-cli-supersedes-'))
const DB_PATH = resolve(root, 'tokenmem.test.db')
const INDEX = resolve(__dirname, 'index.mjs')

let pass = 0, fail = 0
function check(label, cond, detail = '') {
if (cond) { pass++; console.log(`✓ ${label}`) }
else { fail++; console.log(`✗ ${label}${detail ? ' -- ' + detail : ''}`) }
}

function cli(...args) {
return spawnSync(process.execPath, [INDEX, ...args], {
encoding: 'utf-8',
env: { ...process.env, TOKENMEM_DB_PATH: DB_PATH, EMBEDDING_API_KEY: '' },
})
}

function storedId(r) {
const m = /^stored: (\d+)$/m.exec(r.stdout || '')
return m ? m[1] : null
}

try {
// Seed a record to supersede.
const first = cli('--store', 'first version of the claim', '--category', 'general', '--type', 'working')
const id1 = storedId(first)
check('plain --store still works without --supersedes', id1 !== null, first.stderr)

// Malformed rowid → refuse before writing.
const bad = cli('--store', 'x', '--supersedes', 'abc')
check('non-numeric target exits 1', bad.status === 1, `status=${bad.status}`)
check('non-numeric target names the bad value', /abc/.test(bad.stderr || ''), bad.stderr)

// Nonexistent rowid → refuse before writing.
const missing = cli('--store', 'x', '--supersedes', '99999999')
check('nonexistent target exits 1', missing.status === 1, `status=${missing.status}`)

// A refused call must not have written anything.
const db1 = new Database(DB_PATH, { readonly: true })
const afterRefusals = db1.prepare('SELECT COUNT(*) c FROM memories').get().c
db1.close()
check('refused calls write nothing', afterRefusals === 1, `rows=${afterRefusals}`)

// Happy path.
const second = cli('--store', 'second version of the claim', '--category', 'general', '--type', 'working', '--supersedes', id1)
const id2 = storedId(second)
check('supersede stores a new record', id2 !== null, second.stderr)
check('supersede reports which ids it replaced', new RegExp(`superseded: ${id1}`).test(second.stdout || ''), second.stdout)

const db2 = new Database(DB_PATH, { readonly: true })
const old = db2.prepare('SELECT superseded_by FROM memories WHERE rowid = ?').get(Number(id1))
db2.close()
check('old record points at the new one', String(old?.superseded_by) === String(id2), JSON.stringify(old))

// Superseding an already-superseded row is a no-op trap: refuse it too.
const again = cli('--store', 'third version', '--supersedes', id1)
check('already-superseded target exits 1', again.status === 1, `status=${again.status}`)

// Partial batch must fail whole: one live id + one dead id writes nothing.
const third = cli('--store', 'a live record', '--category', 'general', '--type', 'working')
const id3 = storedId(third)
const db3 = new Database(DB_PATH, { readonly: true })
const before = db3.prepare('SELECT COUNT(*) c FROM memories').get().c
db3.close()
const partial = cli('--store', 'x', '--supersedes', `${id3},99999999`)
const db4 = new Database(DB_PATH, { readonly: true })
const after = db4.prepare('SELECT COUNT(*) c FROM memories').get().c
const stillLive = db4.prepare('SELECT superseded_by FROM memories WHERE rowid = ?').get(Number(id3))
db4.close()
check('partly-dead target list exits 1', partial.status === 1, `status=${partial.status}`)
check('partly-dead target list writes nothing', after === before, `${before} → ${after}`)
check('partly-dead target list leaves the live target alone', stillLive?.superseded_by == null, JSON.stringify(stillLive))

console.log(`\n${pass} passed, ${fail} failed`)
process.exit(fail === 0 ? 0 : 1)
} finally {
try { rmSync(root, { recursive: true, force: true }) } catch {}
}
36 changes: 35 additions & 1 deletion index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4292,6 +4292,26 @@ if (_isMain) {
const category = getFlag('--category') || 'general'
const memoryType = getFlag('--type') || 'long_term'
const memoryLevel = getFlag('--level') || 'semi_abstract'
// Correction path: --supersedes points the old records' superseded_by at
// this new one. Fail loud on ids that are missing/already superseded —
// a partial supersede leaves the stale version live and recallable.
const supersedesRaw = getFlag('--supersedes')
let supersedes = []
if (supersedesRaw !== null) {
supersedes = supersedesRaw.split(',').map(s => s.trim()).filter(Boolean)
const bad = supersedes.filter(s => !/^\d+$/.test(s))
if (bad.length) {
process.stderr.write(`Error: --supersedes takes comma-separated rowids, got: ${bad.join(', ')}\n`)
process.exit(1)
}
const live = getMemoriesByIds(supersedes).map(r => String(r.rowid))
const missing = supersedes.filter(s => !live.includes(s))
if (missing.length) {
process.stderr.write(`Error: --supersedes target(s) not live (deleted, already superseded, or nonexistent): ${missing.join(', ')}\n`)
process.exit(1)
}
}
const out = {}
const id = storeMemory({
content: content.trim(),
memoryType,
Expand All @@ -4300,8 +4320,21 @@ if (_isMain) {
importance,
source: 'manual',
tags: ['cli', 'manual'],
})
...(supersedes.length ? { supersedes } : {}),
}, { out })
process.stdout.write(`stored: ${id}\n`)
if (id && supersedes.length) process.stdout.write(`superseded: ${supersedes.join(', ')}\n`)
// Surface the write-time guards the CLI used to swallow into the log stream.
if (out.metaDowngrade) {
process.stdout.write(`⚠ level ${out.metaDowngrade.fromLevel} → ${out.metaDowngrade.toLevel}: ${out.metaDowngrade.reasons.join(' | ')}\n`)
}
if (out.supersedeShrink?.length) {
process.stdout.write(`⚠ supersede shrink — the new version stopped carrying:\n`)
for (const s of out.supersedeShrink) process.stdout.write(` - ${typeof s === 'string' ? s : JSON.stringify(s)}\n`)
}
if (out.quotaRejected?.length) {
process.stdout.write(`⚠ quota rejected: ${out.quotaRejected.join(', ')}\n`)
}

} else if (getFlag('--store-compact-summary') !== null) {
const summary = process.env.TOKENMEM_COMPACT_SUMMARY
Expand Down Expand Up @@ -4406,6 +4439,7 @@ if (_isMain) {
' [--importance 1-10] [--category general|people|project|...]',
' [--type working|short_term|long_term|permanent]',
' [--level concrete_trace|semi_abstract|meta_knowledge] abstraction level (default semi_abstract)',
' [--supersedes 123,456] replace those live rowids with this record (fails if any is not live)',
' node index.mjs --compress <chat_id> Compress old conversations (requires claude CLI)',
' [--days 30]',
' node index.mjs --compress-all Batch compress all old conversations',
Expand Down
Loading