diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e1494f2..c2fbaea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,6 +42,14 @@ jobs:
- name: Unit tests (series detection — keeps timelines out of the supersede band)
run: node series-detection.test.mjs
+ - name: Unit tests (tag-leak repair — sibling fields leaked into content)
+ run: node tag-leak-repair.test.mjs
+
+ - name: Integration test (tag-leak repair through store_memory)
+ env:
+ TOKENMEM_DB_PATH: ${{ runner.temp }}/mneme-ci-tag-leak.db
+ run: node tag-leak.integration.test.mjs
+
- name: Integration test (meta-gate write-gate)
env:
TOKENMEM_DB_PATH: ${{ runner.temp }}/mneme-ci-meta-gate.db
diff --git a/mcp-server.mjs b/mcp-server.mjs
index e709035..b24b794 100644
--- a/mcp-server.mjs
+++ b/mcp-server.mjs
@@ -48,6 +48,7 @@ import {
} from './index.mjs'
import { migrateVectorsToBlob } from './index.mjs' // Migration 013 background runner (below)
import { parseHostTokens, resolveAuthMode, resolveHost } from './auth.mjs'
+import { repairTagLeak } from './tag-leak-repair.mjs'
import { recallClaudeMarkdownMemory } from './lib/claude-markdown-memory.mjs'
// ── Load .env.local BEFORE initMemory() ────────────────────────────────
@@ -256,6 +257,19 @@ function createServer(hostId = DEFAULT_HOST) {
async ({ content, summary, importance = 6, memory_type = 'long_term', memory_level = 'semi_abstract', category = 'general', tags = [], supersedes, event_time, is_anchor, is_pinned }) => {
const out = {}
+ // Close-tag leak: sibling fields parsed into content/summary as raw XML
+ // while the real fields fell back to zod defaults. Split them back out
+ // before anything else sees the args — see tag-leak-repair.mjs.
+ const leak = repairTagLeak({ content, summary, importance, memory_type, memory_level, category, tags, supersedes, event_time, is_anchor, is_pinned })
+ if (leak.repaired) {
+ ({ content, summary, importance, memory_type, memory_level, category, tags, supersedes, event_time, is_anchor, is_pinned } = leak.args)
+ }
+ const leakNote = leak.repaired
+ ? `\n🩹 close-tag leak repaired: ${leak.moved.join(', ') || '(no valid fields)'} moved back out of ${leak.from.join(' + ')} — a field was closed with the wrong tag; each must end with `
+ : leak.suspect
+ ? `\n⚠️ possible close-tag leak: content/summary contains a closing tag followed by a field tag, but it didn't parse cleanly — recall_by_id this row and check its tail`
+ : ''
+
// v2.9: not-yet-trusted hosts write into quarantine — a separate table
// the recall pool never reads. Requested supersedes are recorded but
// execute only if the reviewer approves.
@@ -273,6 +287,7 @@ function createServer(hostId = DEFAULT_HOST) {
if (!qid) return { content: [{ type: 'text', text: 'Quarantine storage failed' }] }
let qtext = `🔒 Quarantined (qid: ${qid}, host: ${hostId}) — pending review by '${PRIMARY_HOST}'. Not recallable until approved.`
if (is_anchor || is_pinned) qtext += `\n(anchor/pinned flags are dropped for quarantined writes — the reviewer can re-add them after merge)`
+ qtext += leakNote
if (out.encodingWarning) {
const e = out.encodingWarning
qtext += `\n⚠️ ENCODING DAMAGE: ${e.qmarkCount} '?' chars (longest run ${e.maxRun}). CJK was likely lost to a non-UTF-8 code page (cp936) — this is IRREVERSIBLE, not a display glitch. If you just wrote Chinese, it did NOT save; re-store via a UTF-8-safe path (codex exec / CC-side), not Codex Desktop.`
@@ -309,7 +324,7 @@ function createServer(hostId = DEFAULT_HOST) {
if (is_anchor && !out.quotaRejected?.find(q => q.flag === 'is_anchor')) flags.push('anchor')
if (is_pinned && !out.quotaRejected?.find(q => q.flag === 'is_pinned')) flags.push('pinned')
const flagStr = flags.length ? `, flags: [${flags.join(', ')}]` : ''
- let text = `Stored memory (id: ${id}, importance: ${importance}, type: ${memory_type}, level: ${finalLevel}${flagStr})`
+ let text = `Stored memory (id: ${id}, importance: ${importance}, type: ${memory_type}, level: ${finalLevel}${flagStr})` + leakNote
// First among the warnings on purpose: the others say a policy adjusted the
// write, this one says the content that landed is already damaged.
if (out.encodingWarning) {
diff --git a/tag-leak-repair.mjs b/tag-leak-repair.mjs
new file mode 100644
index 0000000..58dfaea
--- /dev/null
+++ b/tag-leak-repair.mjs
@@ -0,0 +1,175 @@
+// tag-leak-repair.mjs — tool-call close-tag leak detection + repair
+//
+// When a caller closes a long `content` argument with the wrong tag
+// (`` instead of ``, or opens a bare ``),
+// the tool-call parser keeps reading and the sibling fields — summary,
+// importance, category, tags — land inside `content` as raw XML. The
+// fields themselves arrive empty, so the zod defaults (importance 6,
+// category general) silently replace what the caller wrote.
+//
+// An instruction-level rule against this was in place for months and the
+// leak kept happening, so the fix lives at the write path:
+// detect the leak, split the tail back into its fields, report the repair.
+//
+// Detection is structural, not substring. The closing tag must be followed
+// by a field tag AND the whole tail must parse as field tags to the end of
+// the string. A memory that *describes* this bug quotes the same strings
+// mid-prose (often in backticks) — that one must pass through untouched.
+
+const FIELDS = [
+ 'summary', 'importance', 'category', 'tags', 'memory_type', 'memory_level',
+ 'supersedes', 'event_time', 'is_anchor', 'is_pinned',
+]
+const F = FIELDS.join('|')
+
+const ENUMS = {
+ category: ['general', 'people', 'project', 'decision', 'feedback', 'bug', 'relationship', 'skill', 'preference'],
+ memory_type: ['working', 'short_term', 'long_term', 'permanent'],
+ memory_level: ['concrete_trace', 'semi_abstract', 'meta_knowledge'],
+}
+
+// zod defaults in the store_memory schema. A passed value equal to one of
+// these may be the default standing in for a value that leaked.
+export const DEFAULTS = { importance: 6, category: 'general', memory_type: 'long_term', memory_level: 'semi_abstract' }
+
+// Tail tokens, matched in place with sticky regexes — no slicing, tails can
+// be tens of KB. Only field / `parameter` closers and the tool-call envelope
+// (`invoke` / `function_calls`, optionally namespaced — real leaks usually
+// end with it) count. A tail that ends in any other closer (``,
+// ``) is markup, not a leak.
+const WS = /\s*/y
+const CLOSE = new RegExp(`(?:parameter|${F}|(?:[A-Za-z]+:)?(?:invoke|function_calls))>`, 'y')
+const OPEN = new RegExp(`<(?:parameter name="(${F})"|(${F}))>`, 'y')
+const VALUE_END = new RegExp(`|(?:${F})>|\\s*<(?:parameter name="(?:${F})"|(?:${F}))>`, 'g')
+
+ // A leaked closer has no opener in the text — the caller's own opening tag
+ // was consumed by the tool-call parser. A closer that closes an element
+ // opened earlier (`…` in a quoted Atom entry, say) is
+ // markup. Open/close depth per name, advanced as matches move right.
+ const tagRe = new RegExp(`<(/?)(${closerNames})(?=[\\s>])`, 'g')
+ const depth = Object.fromEntries(closerNames.split('|').map(n => [n, 0]))
+ let scanned = 0
+ const advanceTo = (to) => {
+ tagRe.lastIndex = scanned
+ let t
+ while ((t = tagRe.exec(text)) && t.index < to) depth[t[2]] += t[1] ? -1 : 1
+ scanned = to
+ }
+
+ let m
+ let suspect = false
+ while ((m = re.exec(text))) {
+ if (text[m.index - 1] === '`') continue // quoted in inline code
+ advanceTo(m.index)
+ if (depth[m[1]] > 0) continue
+ const r = parseTail(text, m.index + m[1].length + 3)
+ if (r.fields) return { head: text.slice(0, m.index).trimEnd(), fields: r.fields }
+ suspect = true
+ // Any later start before failAt runs into the same non-field token, so
+ // skip past it — keeps the scan linear on long, repetitive content.
+ re.lastIndex = Math.max(re.lastIndex, r.failAt)
+ }
+ return suspect ? { suspect: true } : null
+}
+
+const isEmpty = v => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
+
+/**
+ * Repair a store_memory argument set whose fields leaked into content/summary.
+ * Pure: returns a new args object, never mutates the input.
+ *
+ * Merge rule: a leaked value only replaces a value the caller didn't really
+ * choose — an empty one, or one equal to the zod default (`defaults`). An
+ * explicitly passed non-default value always wins. When content and summary
+ * both leak the same field, the content-side value is kept.
+ *
+ * The text is only truncated when the split pays for itself: at least one
+ * field is recovered and something of the original text is left. Otherwise
+ * the args pass through unchanged and the result is flagged `suspect`.
+ *
+ * @returns {{ repaired: boolean, suspect: boolean, args: object, moved: string[], from: string[] }}
+ */
+export function repairTagLeak(args, defaults = DEFAULTS) {
+ const next = { ...args }
+ const moved = new Set()
+ const from = []
+ let suspect = false
+
+ const replaceable = (name) => isEmpty(next[name]) || (name in defaults && next[name] === defaults[name])
+
+ for (const [field, closerNames] of [['content', 'content|parameter'], ['summary', 'summary|parameter']]) {
+ const hit = splitLeak(next[field], closerNames)
+ if (!hit) continue
+ if (hit.suspect) { suspect = true; continue }
+ // `accounted`: tail fields that are valid and either applied now or
+ // already recovered from the other side — proof the tail is a real leak.
+ const updates = {}
+ let accounted = 0
+ for (const [name, raw] of Object.entries(hit.fields)) {
+ if (name === field) continue
+ const v = coerce(name, raw)
+ if (v === undefined) continue
+ if (moved.has(name)) { accounted++; continue }
+ if (replaceable(name)) { updates[name] = v; accounted++ }
+ }
+ if (!accounted || !hit.head.trim()) { suspect = true; continue }
+ next[field] = hit.head
+ Object.assign(next, updates)
+ for (const k of Object.keys(updates)) moved.add(k)
+ from.push(field)
+ }
+
+ return { repaired: from.length > 0, suspect, args: next, moved: [...moved], from }
+}
diff --git a/tag-leak-repair.test.mjs b/tag-leak-repair.test.mjs
new file mode 100644
index 0000000..b9e47f7
--- /dev/null
+++ b/tag-leak-repair.test.mjs
@@ -0,0 +1,121 @@
+// Self-check for tag-leak-repair. Run: node tag-leak-repair.test.mjs
+// Leak cases are shaped after real corrupted rows found in a production store.
+import { repairTagLeak } from './tag-leak-repair.mjs'
+
+const base = { summary: undefined, importance: 6, category: 'general', tags: [], memory_type: 'long_term', memory_level: 'semi_abstract' }
+let fail = 0
+const check = (label, cond, detail) => {
+ if (!cond) { fail++; console.log(`✗ ${label}`, detail ?? '') } else console.log(`✓ ${label}`)
+}
+
+// 1. then an unclosed summary param (most common Aug shape)
+{
+ const r = repairTagLeak({ ...base, content: '测量工具介入系统就改变了系统。 性能测量前须采样空载基线' })
+ check('content leak: summary moved out', r.repaired && r.args.summary === '性能测量前须采样空载基线', r)
+ check('content leak: content truncated', r.args.content === '测量工具介入系统就改变了系统。', r.args.content)
+}
+
+// 2. bare / tags (Jun–Aug shape)
+{
+ const r = repairTagLeak({ ...base, content: '方案是假的。 备份凭据不能只存在被备份的机器上 8' })
+ check('bare tags: summary + importance', r.args.summary === '备份凭据不能只存在被备份的机器上' && r.args.importance === 8, r.args)
+}
+
+// 3. full sibling set with category / tags / level
+{
+ const content = '正文\n\nS\n9\ndecision\n["a","b"]\nmeta_knowledge'
+ const r = repairTagLeak({ ...base, content })
+ check('full set: all fields', r.args.content === '正文' && r.args.summary === 'S' && r.args.importance === 9
+ && r.args.category === 'decision' && r.args.tags.join() === 'a,b' && r.args.memory_level === 'meta_knowledge', r.args)
+}
+
+// 4. summary-side leak (9/21 shape): importance stuck inside summary
+{
+ const r = repairTagLeak({ ...base, content: '内容无损', summary: '摘要文本7' })
+ check('summary leak: importance recovered', r.args.summary === '摘要文本' && r.args.importance === 7 && r.args.content === '内容无损', r.args)
+}
+
+// 4b. tail ending in the tool-call envelope closer (most summary-side rows)
+{
+ const r = repairTagLeak({ ...base, content: '正文', summary: '摘要\n9\nproject\n["a","b"]\n' })
+ check('envelope closer at the end', r.repaired && r.args.summary === '摘要' && r.args.importance === 9 && r.args.category === 'project' && r.args.tags.join() === 'a,b', r.args)
+}
+
+// 5. an existing non-empty summary is never overwritten by a leaked one
+{
+ const r = repairTagLeak({ ...base, summary: '已有摘要', content: 'x泄漏摘要bug' })
+ check('keeps passed summary, takes leaked category', r.args.summary === '已有摘要' && r.args.category === 'bug' && r.args.content === 'x', r.args)
+}
+
+// 6. prose that DESCRIBES the bug must pass untouched
+for (const [label, content] of [
+ ['prose: backticked closer + field', '尾巴上挂着一段 `…` 的裸 XML。\n\n## 分类判据\n关键是分清两种'],
+ ['prose: closer followed by Chinese', '把结尾 `` 手滑成 , 污染其后 importance'],
+ ['prose: field tag mid-paragraph then prose', '写 时要记得关 tag,然后继续写正文。这里还有更多说明文字。'],
+ ['clean content', '普通的一条记忆,没有任何标签。'],
+]) {
+ const r = repairTagLeak({ ...base, content })
+ check(label, !r.repaired && r.args.content === content, r)
+}
+
+// 7. nothing valid recovered → no truncation, flagged suspect
+{
+ const content = 'xnonsense42'
+ const r = repairTagLeak({ ...base, content })
+ check('invalid values only → untouched + suspect', !r.repaired && r.suspect && r.args.content === content && r.args.importance === 6, r)
+}
+
+// 8. leak-shaped closer whose tail is prose → suspect, not repaired
+{
+ const content = '正文摘要 然后又接了一段正文'
+ const r = repairTagLeak({ ...base, content })
+ check('unparseable tail → suspect only', !r.repaired && r.suspect && r.args.content === content, r)
+}
+
+// 9. markup that happens to use field-named elements is not a leak
+for (const [label, content] of [
+ ['atom entry with wrapper', 'Atom feed 示例:\n\n 正文内容\n 摘要内容\n'],
+ ['atom elements at the very end', 'Atom 里正文和摘要这样写:\n正文内容\n摘要内容'],
+ ['jsx-ish', '组件结构:\nbar\nx\n'],
+ ['html with invalid enum', '这是页面结构:\n\n 正文\n news\n
'],
+]) {
+ const r = repairTagLeak({ ...base, content })
+ check(`markup: ${label}`, !r.repaired && r.args.content === content && r.args.summary === undefined, r)
+}
+
+// 10. an explicitly passed non-default value is never overridden
+{
+ const r = repairTagLeak({ ...base, importance: 10, category: 'decision', content: '示例正文3bugS' })
+ check('explicit importance/category kept', r.args.importance === 10 && r.args.category === 'decision' && r.args.summary === 'S' && r.args.content === '示例正文', r.args)
+}
+
+// 11. leak consumes the whole field → nothing left, don't store a blank row
+{
+ const content = 'S'
+ const r = repairTagLeak({ ...base, content })
+ check('empty head → untouched + suspect', !r.repaired && r.suspect && r.args.content === content, r)
+}
+
+// 12. content and summary both leak the same field → content side wins
+{
+ const r = repairTagLeak({ ...base, content: 'c3', summary: 's8' })
+ check('content-side value wins on conflict', r.args.importance === 3 && r.args.summary === 's' && r.args.content === 'c', r.args)
+}
+
+// 13. remaining fields: memory_type / supersedes / event_time / is_anchor
+{
+ const r = repairTagLeak({ ...base, content: 'xpermanent["12","34"]2026-01-02true' })
+ check('other fields recovered', r.args.memory_type === 'permanent' && r.args.supersedes.join() === '12,34' && r.args.event_time === '2026-01-02' && r.args.is_anchor === true, r.args)
+}
+
+// 14. long repetitive near-leak stays linear
+{
+ const content = 'Z' + 'Y'.repeat(8000) + '\u0000POISON'
+ const t0 = Date.now()
+ const r = repairTagLeak({ ...base, content })
+ const ms = Date.now() - t0
+ check(`${Math.round(content.length / 1000)}KB adversarial input in ${ms}ms (< 500)`, ms < 500 && !r.repaired, { ms, repaired: r.repaired })
+}
+
+console.log(fail ? `\n${fail} FAILED` : '\nall passed')
+process.exit(fail ? 1 : 0)
diff --git a/tag-leak.integration.test.mjs b/tag-leak.integration.test.mjs
new file mode 100644
index 0000000..b5cb71b
--- /dev/null
+++ b/tag-leak.integration.test.mjs
@@ -0,0 +1,90 @@
+// End-to-end: store_memory repairs sibling fields that leaked into content.
+//
+// The unit test covers the parser; this one covers the wiring — that the MCP
+// handler runs the repair before the write on both the normal and the
+// quarantine path, that repaired fields (not the zod defaults) are what land
+// in the row, that an explicitly passed value survives, and that prose or
+// markup is stored untouched.
+//
+// Run: TOKENMEM_DB_PATH=/tmp/x.db node tag-leak.integration.test.mjs
+import { spawn } from 'node:child_process'
+import { existsSync, unlinkSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import Database from 'better-sqlite3'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+
+const DB_PATH = process.env.TOKENMEM_DB_PATH
+if (!DB_PATH) { console.error('FATAL: set TOKENMEM_DB_PATH'); process.exit(2) }
+const Q_DB_PATH = DB_PATH.replace(/(\.db)?$/, '-quarantine.db')
+for (const p of [DB_PATH, Q_DB_PATH]) for (const sfx of ['', '-shm', '-wal']) if (existsSync(p + sfx)) unlinkSync(p + sfx)
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
+let pass = 0, fail = 0
+const check = (label, cond, detail = '') => {
+ if (cond) { pass++; console.log(`✓ ${label}`) }
+ else { fail++; console.log(`✗ ${label}${detail ? ' — ' + detail : ''}`) }
+}
+
+async function withServer(dbPath, extraEnv, fn) {
+ const port = 18960 + Math.floor(Math.random() * 30)
+ const srv = spawn(process.execPath, [resolve(__dirname, 'mcp-server.mjs'), '--transport=http', `--port=${port}`], {
+ env: { ...process.env, TOKENMEM_DB_PATH: dbPath, MNEME_AUTH: 'off', ...extraEnv }, stdio: 'ignore',
+ })
+ try {
+ let up = false
+ for (let i = 0; i < 40 && !up; i++) {
+ try { up = (await fetch(`http://127.0.0.1:${port}/health`)).ok } catch {}
+ if (!up) await new Promise(r => setTimeout(r, 500))
+ }
+ if (!up) throw new Error('server did not come up')
+ const client = new Client({ name: 'tag-leak-test', version: '0' })
+ await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)))
+ const store = async (args) => (await client.callTool({ name: 'store_memory', arguments: args })).content[0].text
+ try { await fn(store) } finally { await client.close() }
+ } finally {
+ srv.kill()
+ }
+}
+
+const LEAKED = 'body text.\nthe summary\n9\ndecision'
+const PROSE = 'A write-up of the bug: the tail looked like `…` and then more prose follows.'
+const MARKUP = 'An Atom entry for reference:\n\n entry body\n entry summary\n'
+
+try {
+ // ── normal path ──
+ await withServer(DB_PATH, {}, async (store) => {
+ const t1 = await store({ content: LEAKED })
+ check('response reports the repair', /close-tag leak repaired: summary, importance, category/.test(t1), t1)
+ const t2 = await store({ content: PROSE, summary: 'write-up', importance: 7 })
+ check('prose write is not flagged', !/leak/.test(t2), t2)
+ await store({ content: MARKUP, summary: 'atom example' })
+ await store({ content: 'explicit.3S', importance: 10 })
+ })
+ const db = new Database(DB_PATH, { readonly: true })
+ const [a, b, c, d] = db.prepare('SELECT content, summary, importance, category FROM memories ORDER BY rowid').all()
+ db.close()
+ check('leaked row: content truncated', a.content === 'body text.', a.content)
+ check('leaked row: fields restored over zod defaults', a.summary === 'the summary' && a.importance === 9 && a.category === 'decision', JSON.stringify(a))
+ check('prose row: stored verbatim', b.content === PROSE && b.summary === 'write-up' && b.importance === 7, JSON.stringify(b))
+ check('markup row: stored verbatim', c.content === MARKUP && c.summary === 'atom example', JSON.stringify(c))
+ check('explicit importance survives a leaked one', d.importance === 10 && d.summary === 'S' && d.content === 'explicit.', JSON.stringify(d))
+
+ // ── quarantine path: repair runs before the write is routed ──
+ await withServer(Q_DB_PATH, { MNEME_QUARANTINE_HOSTS: 'cc', MNEME_DEFAULT_HOST: 'cc', MNEME_PRIMARY_HOST: 'reviewer' }, async (store) => {
+ const t = await store({ content: LEAKED })
+ check('quarantined write reports the repair', /Quarantined/.test(t) && /close-tag leak repaired/.test(t), t)
+ })
+ const qdb = new Database(Q_DB_PATH, { readonly: true })
+ const q = qdb.prepare('SELECT content, summary, importance, category FROM memories_quarantine ORDER BY qid').get()
+ qdb.close()
+ check('quarantine row: repaired fields', q?.content === 'body text.' && q.summary === 'the summary' && q.importance === 9 && q.category === 'decision', JSON.stringify(q))
+} catch (e) {
+ fail++
+ console.log(`✗ ${e.message}`)
+}
+
+console.log(`\n${fail ? 'FAIL' : 'PASS'}: ${pass} passed / ${fail} failed`)
+process.exitCode = fail ? 1 : 0