Repository navigation
Description
Version: 1.6.0, main at 7e06519357777badd7a115d2014a7ef845904310.
When a transcript that has already been indexed is truncated or rewritten in place (same path, newer mtime), incremental sync keeps the old exchanges and never indexes the replacement content.
Reproduction
The three scripts below are in the collapsed sections at the end. Put them in examples/ in a directory that also contains a built checkout named upstream, then run from that directory. Only the embedding module is stubbed with a constant 384-dimensional vector; the parser, archive copy, SQLite and sqlite-vec are real, and summaries are skipped. All transcript content is synthetic.
python3 examples/make_logs.py rewrite initial --root demo-rewrite
node --loader ./examples/fake-embedding-loader.mjs examples/reproduce.mjs rewrite initial upstream demo-rewrite
python3 examples/make_logs.py rewrite changed --root demo-rewrite
node --loader ./examples/fake-embedding-loader.mjs examples/reproduce.mjs rewrite changed upstream demo-rewrite
The first phase writes two user/assistant exchanges. The second overwrites the same file with one new exchange and advances its mtime.
Expected: only the replacement exchange remains: target_marker_hits = 1, old_tail_hits = 0, one vector row.
Actual: target_marker_hits = 0 and old_tail_hits = 1. The old exchanges stay indexed and the replacement is skipped. A rewrite that keeps the same line count is skipped too, because the exchange IDs' line bounds don't change.
Likely cause
src/sync.ts:303-316 and src/indexer.ts:357-378 only keep exchanges whose start line is greater than the stored MAX(line_end), which assumes the file is append-only. After a rewrite the old watermark filters out all of the new exchanges, and neither path removes IDs that no longer exist in the file. src/parser.ts:187-208 derives IDs from line bounds rather than content, so a same-length rewrite looks unchanged.
Related
- The same watermark also drops an assistant-only continuation appended to an exchange that is already indexed (the "straddle" case raised in #168 (comment)). Running the scripts with
continuation/demo-continuationin place ofrewrite/demo-rewriteshows it:target_marker_hitsstays 0. - feat(sync): archive Codex's compressed archived rollouts #190 discusses rewritten line references for compressed Codex archives; this report is about ordinary JSONL input.
I have a local fix with regression tests for both syncConversations and indexUnprocessed (rewrite, truncation, continuation, removal without insertion, and rollback on embedding failure). Happy to open a PR if that would help.
examples/make_logs.py
"""Generate only synthetic transcript records for the two incremental bugs."""
import argparse,json,os,time
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("case",choices=["continuation","rewrite"])
parser.add_argument("phase",choices=["initial","changed"])
parser.add_argument("--root",type=Path,default=Path("demo"))
args=parser.parse_args()
project=args.root/"claude/projects/example-project"
project.mkdir(parents=True,exist_ok=True)
file=project/"00000000-0000-4000-8000-000000000001.jsonl"
old_mtime=file.stat().st_mtime if file.exists() else 0
def line(role,text,seq):
return json.dumps({"type":role,"uuid":role+"-PLACEHOLDER-"+str(seq),
"sessionId":"00000000-0000-4000-8000-000000000001",
"timestamp":"2026-01-01T00:00:00.000Z","isSidechain":False,
"message":{"role":role,"content":text if role=="user" else [{"type":"text","text":text}]}},separators=(",",":"))+"\n"
if args.case=="continuation":
if args.phase=="initial":
file.write_text(line("user","USER_PLACEHOLDER",1)+line("assistant","FIRST_ANSWER_PLACEHOLDER",1))
else:
assert file.is_file(),"Generate initial transcript and index it first"
with file.open("a") as stream: stream.write(line("assistant","CONTINUATION_PLACEHOLDER",2))
else:
content=(line("user","OLD_USER_PLACEHOLDER",1)+line("assistant","OLD_ANSWER_PLACEHOLDER",1)+
line("user","OLD_TAIL_USER_PLACEHOLDER",2)+line("assistant","OLD_TAIL_ANSWER_PLACEHOLDER",2))
if args.phase=="changed": content=line("user","NEW_USER_PLACEHOLDER",1)+line("assistant","REPLACEMENT_PLACEHOLDER",1)
file.write_text(content)
stamp=max(time.time(),old_mtime)+5
os.utime(file,(stamp,stamp))
print(json.dumps({"case":args.case,"phase":args.phase,"file":file.relative_to(args.root).as_posix()}))
examples/reproduce.mjs
// Run only against examples/make_logs.py output; no model, account or real session.
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const [caseName, phase, checkout = 'upstream', state = 'demo'] = process.argv.slice(2);
if (!['continuation', 'rewrite'].includes(caseName) || !['initial', 'changed'].includes(phase)) {
throw new Error('Usage: reproduce.mjs continuation|rewrite initial|changed [checkout] [state-directory]');
}
const bundle = process.cwd();
const repo = path.resolve(checkout);
const root = path.resolve(state);
for (const dir of [repo, root]) {
if (!dir.startsWith(bundle + path.sep)) throw new Error('Use a directory within this local bundle');
}
for (const key of ['home', 'archive', 'config']) fs.mkdirSync(path.join(root, key), { recursive: true });
Object.assign(process.env, {
HOME: path.join(root, 'home'),
TEST_PROJECTS_DIR: path.join(root, 'claude', 'projects'),
TEST_ARCHIVE_DIR: path.join(root, 'archive'),
TEST_DB_PATH: path.join(root, 'index.db'),
EPISODIC_MEMORY_CONFIG_DIR: path.join(root, 'config'),
});
const transcript = path.join(process.env.TEST_PROJECTS_DIR, 'example-project', '00000000-0000-4000-8000-000000000001.jsonl');
if (!fs.readFileSync(transcript, 'utf8').includes('PLACEHOLDER')) throw new Error('Expected synthetic fixture');
const { syncConversations } = await import(pathToFileURL(path.join(repo, 'dist', 'sync.js')).href);
const result = await syncConversations(process.env.TEST_PROJECTS_DIR, process.env.TEST_ARCHIVE_DIR, { skipSummaries: true });
if (result.errors.length) throw new Error('Synthetic sync failed: ' + result.errors.map(item => item.error).join('; '));
const require = createRequire(path.join(repo, 'package.json'));
const Database = require('better-sqlite3');
const db = new Database(process.env.TEST_DB_PATH);
require('sqlite-vec').load(db);
try {
const rows = db.prepare('SELECT user_message, assistant_message FROM exchanges ORDER BY line_start').all();
const marker = caseName === 'continuation' ? 'CONTINUATION_PLACEHOLDER' : 'REPLACEMENT_PLACEHOLDER';
const count = (value) => rows.filter(row => row.assistant_message.includes(value)).length;
console.log(JSON.stringify({
case: caseName, phase, deterministic_embedding_stub: true, indexed_rows: rows.length,
target_marker_hits: count(marker), old_tail_hits: count('OLD_TAIL_ANSWER_PLACEHOLDER'),
vector_rows: db.prepare('SELECT COUNT(*) AS n FROM vec_exchanges').get().n,
rows,
}));
} finally { db.close(); }
examples/fake-embedding-loader.mjs
// Test-only encoder stub. Parser, sync, SQLite and vector persistence stay real.
const fake = `
export const initEmbeddingsFailed = false;
export async function initEmbeddings() {}
export async function generateExchangeEmbedding() { return new Array(384).fill(0); }
export async function generateQueryEmbedding() { return new Array(384).fill(0); }
export async function generateEmbedding() { return new Array(384).fill(0); }
`;
export async function load(url, context, nextLoad) {
if (url.endsWith('/dist/embeddings.js')) return { format: 'module', shortCircuit: true, source: fake };
return nextLoad(url, context);
}
Activity
HengYangDS commentedon Nov 5, 2026
#176 keeps session indexing incremental for append-only input, and #177 verifies with the same line watermark. Your rewrite and truncation cases expose the limit of that assumption.
It would help to run the same-length rewrite fixture through
--verifyas well: stale content should be reported before repair. The integration check should cover both indexing paths, replacing removed or changed exchanges while preserving unchanged rows on a true append. Please link your fix here if you open a PR so we can check the changes together.