chat-recall 0.6.9 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -229,12 +229,36 @@ export interface UnchangedConversation {
229
229
  unchanged: true;
230
230
  srcHash: string;
231
231
  }
232
- /** Transcript bytes beyond which a FULL sync must not materialize the whole
233
- * file. The FULL pipeline copies the transcript ~5× (parse, trim+redact,
234
- * raw export, raw-text join, redacted container) — a 350MB session transiently
235
- * needs >1.5GB of heap, which OOM-killed the watch daemon in a restart loop
236
- * (ledger never acked → same file retried on every startup tick, forever). */
232
+ /** Build bytes (see sessionBuildBytes) beyond which a FULL sync must not
233
+ * materialize the session. A larger session ships in chunks instead: the head
234
+ * chunk as its FULL sync, then the rest as appends.
235
+ *
236
+ * The in-memory build holds the container many times over (export, container,
237
+ * shadow merge, parse, redacted envelope, joined raw text, and a copy in the
238
+ * scan worker). Measured on a 42 MB container: 840 MB peak RSS in full, 218 MB
239
+ * in chunks, for the same 2,392 messages, under a unit with MemoryHigh=1G and
240
+ * a resting RSS near 400 MB. At about 18 bytes per container byte, 24 MB
241
+ * stays under the limit. */
237
242
  export declare const FULL_BUILD_MAX_BYTES: number;
243
+ /** Transcript bytes beyond which the derived computations (diff, outcome,
244
+ * commits, markers) are skipped. They run in the scan worker and read the
245
+ * whole transcript; a 353 MB session with 100 MB JSON lines OOMed the walk. */
246
+ export declare const DERIVED_MAX_BYTES: number;
247
+ /** Bytes of transcript one chunk of a chunked sync carries: the head of an
248
+ * oversized FULL sync, and each append. */
249
+ export declare const SYNC_CHUNK_BYTES: number;
250
+ /**
251
+ * Secret findings for a whole transcript, read one chunk at a time.
252
+ *
253
+ * A chunked session never holds its full text, so it cannot use the one-pass
254
+ * scan of the in-memory build. Line numbers are absolute: each chunk's
255
+ * findings are shifted by the lines of the chunks before it. A line longer
256
+ * than a chunk is skipped by the reader, and it counts as one line.
257
+ */
258
+ export declare function scanTranscriptFindingsChunked(ref: SessionRef, tenantRules?: Array<{
259
+ name: string;
260
+ regex: string;
261
+ }>): Promise<BuiltConversation['findings']>;
238
262
  /** Ceiling on PRE-REDACTION session text materialized on disk at any one moment
239
263
  * by the optional external-detector batch scan. The batch dir is flushed and
240
264
  * deleted every time it crosses this, so a SIGKILL/OOM mid-scan can strand at
@@ -244,6 +268,14 @@ export declare const FULL_BUILD_MAX_BYTES: number;
244
268
  export declare const BATCH_SCAN_MAX_BYTES: number;
245
269
  /** Bytes an append-only session occupies on disk (0 for non-AO backends). */
246
270
  export declare function sessionFileBytes(ref: SessionRef): number;
271
+ /**
272
+ * Bytes a FULL build of an append-only session loads (0 for non-AO backends):
273
+ * the larger of the main transcript and the shadow container, which also holds
274
+ * the subagent transcripts and any history an upstream rewrite dropped. The
275
+ * main file alone under-counts: a 19 MB transcript built from a 42 MB shadow
276
+ * and peaked at 840 MB RSS.
277
+ */
278
+ export declare function sessionBuildBytes(ref: SessionRef): number;
247
279
  export declare function buildConversationSync(ref: SessionRef, mtime: number, opts?: {
248
280
  mapPath?: (p: string) => string;
249
281
  includeRaw?: boolean;
@@ -288,6 +320,7 @@ export declare function buildConversationTail(ref: SessionRef, fromOffset: numbe
288
320
  redactions: number;
289
321
  newOffset: number;
290
322
  findings: BuiltConversation['findings'];
323
+ bytes: number;
291
324
  } | null>;
292
325
  /**
293
326
  * Per-session derived payload: the four compute kinds the server's deep-dive
@@ -1 +1 @@
1
- {"version":3,"file":"sync-client.d.ts","sourceRoot":"","sources":["../src/sync-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sync-client.d.ts","sourceRoot":"","sources":["../src/sync-client.ts"],"names":[],"mappings":"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"}
@@ -45,7 +45,7 @@ import { dropFuzzyFindings } from '@chat-recall/engine/core/secret-precision.js'
45
45
  import { loadSettings, saveSettings, isProjectSyncable } from '@chat-recall/engine/core/settings.js';
46
46
  import { getDataDir } from '@chat-recall/engine/core/paths.js';
47
47
  import { listAvailableBackends } from '@chat-recall/engine/core/tool-backend.js';
48
- import { parseTranscript, trimTranscriptForSync, TRANSCRIPT_VERSION, gzipContainer, mapContainerText, buildRawContainer, containerSrcHash, parseTranscriptFromContainer, updateShadow } from '@chat-recall/engine/transcript/index.js';
48
+ import { parseTranscript, trimTranscriptForSync, TRANSCRIPT_VERSION, gzipContainer, mapContainerText, buildRawContainer, containerSrcHash, parseTranscriptFromContainer, updateShadow, shadowUncompressedBytes } from '@chat-recall/engine/transcript/index.js';
49
49
  import { parseClaudeTranscriptText } from '@chat-recall/engine/transcript/claude.js';
50
50
  import { parseCodexTranscriptText } from '@chat-recall/engine/transcript/codex.js';
51
51
  import { getBackendForId, getBackend } from '@chat-recall/engine/core/tool-backend.js';
@@ -1466,17 +1466,68 @@ async function syncToTargetInScope(cred, opts = {}) {
1466
1466
  clearTimeout(timer);
1467
1467
  }
1468
1468
  };
1469
+ // ── Chunked head (oversized FULL) ────────────────────────────────
1470
+ // The head chunk of an oversized session ships alone, and its response is
1471
+ // read. When the server already holds a fuller copy, its shrink guard keeps
1472
+ // that copy and reports the offset it is synced through. The ledger then
1473
+ // records that offset, so the next tick appends from where the server is.
1474
+ // Recording the head's end instead makes every append miss the server's
1475
+ // offset, answer full_resync_needed, and send the head again, forever.
1476
+ const postChunkedHead = async (conv, fileSize) => {
1477
+ const headers = { 'content-type': 'application/json' };
1478
+ if (cred.token)
1479
+ headers.authorization = `Bearer ${cred.token}`;
1480
+ const ac = new AbortController();
1481
+ const timer = setTimeout(() => ac.abort(new Error(`upload timed out after ${UPLOAD_TIMEOUT_MS}ms`)), UPLOAD_TIMEOUT_MS);
1482
+ try {
1483
+ const res = await fetch(`${base}/api/sync`, {
1484
+ method: 'POST', headers, body: JSON.stringify({ conversations: [conv] }), signal: ac.signal,
1485
+ });
1486
+ if (!res.ok)
1487
+ throw new Error(`chunked head sync failed: HTTP ${res.status} ${await res.text().catch(() => '')}`);
1488
+ const body = await res.json().catch(() => ({}));
1489
+ const id = conv.session_id;
1490
+ const guarded = (body.shrink_guarded ?? []).find((g) => g.session_id === id);
1491
+ const mtime = conv.mtime;
1492
+ try {
1493
+ if (!guarded) {
1494
+ markSynced(base, [{ id, mtime, offset: conv.from_offset, size: conv.from_offset, acked: true }]);
1495
+ }
1496
+ else if (typeof guarded.o === 'number' && guarded.o > 0 && guarded.o <= fileSize) {
1497
+ markSynced(base, [{ id, mtime, offset: guarded.o, size: guarded.o, acked: true }]);
1498
+ }
1499
+ else {
1500
+ // The server holds more than this file has: the file was truncated in
1501
+ // place. The server keeps the fuller copy; stop here at this mtime.
1502
+ markSynced(base, [{ id, mtime }]);
1503
+ }
1504
+ }
1505
+ catch { /* ledger — never fail an upload over it */ }
1506
+ appendResults.uploaded++;
1507
+ }
1508
+ finally {
1509
+ clearTimeout(timer);
1510
+ }
1511
+ };
1469
1512
  let appendBatch = [];
1513
+ let appendBatchBytes = 0;
1470
1514
  const APPEND_BATCH_SIZE = 50;
1515
+ // Chunks of a chunked session are up to SYNC_CHUNK_BYTES each, so the batch
1516
+ // is bounded by bytes as well as by count.
1517
+ const APPEND_BATCH_MAX_BYTES = SYNC_CHUNK_BYTES;
1471
1518
  const appendFlush = async () => {
1472
1519
  if (appendBatch.length === 0)
1473
1520
  return;
1474
1521
  const batch = appendBatch;
1475
1522
  appendBatch = [];
1523
+ appendBatchBytes = 0;
1476
1524
  await postAppend(batch);
1477
1525
  };
1478
- const appendAdd = async (conv) => {
1526
+ const appendAdd = async (conv, bytes = 0) => {
1527
+ if (appendBatch.length > 0 && appendBatchBytes + bytes > APPEND_BATCH_MAX_BYTES)
1528
+ await appendFlush();
1479
1529
  appendBatch.push(conv);
1530
+ appendBatchBytes += bytes;
1480
1531
  if (appendBatch.length >= APPEND_BATCH_SIZE)
1481
1532
  await appendFlush();
1482
1533
  };
@@ -1643,7 +1694,7 @@ async function syncToTargetInScope(cred, opts = {}) {
1643
1694
  // Oversized sessions are excluded: exportRawSession would materialize the
1644
1695
  // whole file (hundreds of MB — this loop is what OOM-killed the daemon).
1645
1696
  // Their bounded tail still gets the builtin scan in buildConversationSync.
1646
- const toScan = slice.filter((ref) => modeOf(ref) === 'full' && sessionFileBytes(ref) <= FULL_BUILD_MAX_BYTES);
1697
+ const toScan = slice.filter((ref) => modeOf(ref) === 'full' && sessionBuildBytes(ref) <= FULL_BUILD_MAX_BYTES);
1647
1698
  trace?.(`batch secret scan start: ${toScan.length} session(s), ≤${(BATCH_SCAN_MAX_BYTES / 1048576).toFixed(0)}MB per slice`);
1648
1699
  const batch = batchScanExternal(toScan.map((ref) => ({
1649
1700
  id: ref.prefixedId,
@@ -1743,12 +1794,23 @@ async function syncToTargetInScope(cred, opts = {}) {
1743
1794
  const ack = ledger.get(ref.prefixedId);
1744
1795
  try {
1745
1796
  const tail = await buildConversationTail(ref, ack.o ?? 0, { mapPath, tenantRules });
1746
- if (tail && tail.findings.length > 0) {
1797
+ // A chunked session cannot fall through to FULL: its FULL sync is the
1798
+ // head chunk again, so the appends would reach the same finding and
1799
+ // loop. It ships the tail, then the whole file's findings, scanned one
1800
+ // chunk at a time, as the session's replacement set.
1801
+ const chunked = sessionBuildBytes(ref) > FULL_BUILD_MAX_BYTES;
1802
+ if (tail && tail.findings.length > 0 && !chunked) {
1747
1803
  promotedForFindings = true; // fall through to FULL (do NOT continue)
1748
1804
  }
1749
1805
  else if (tail) {
1750
1806
  redactions += tail.redactions;
1751
- await appendAdd(tail.conv);
1807
+ await appendAdd(tail.conv, tail.bytes);
1808
+ if (tail.findings.length > 0) {
1809
+ await appendFlush(); // the session row must exist before its findings
1810
+ const all = await scanTranscriptFindingsChunked(ref, tenantRules);
1811
+ if (all.length > 0)
1812
+ await findingsBatch.add(all);
1813
+ }
1752
1814
  walked++;
1753
1815
  if (walked % 250 === 0)
1754
1816
  console.error(`[sync] ${walked}/${slice.length} sessions…`);
@@ -1851,7 +1913,10 @@ async function syncToTargetInScope(cred, opts = {}) {
1851
1913
  // the mtime-only skip on a later tick). Undefined for the oversized-tail path.
1852
1914
  if (built.srcHash)
1853
1915
  shippedHash.set(ref.prefixedId, built.srcHash);
1854
- await convBatch.add(built.conv);
1916
+ if (built.conv.chunked)
1917
+ await postChunkedHead(built.conv, sessionFileBytes(ref));
1918
+ else
1919
+ await convBatch.add(built.conv);
1855
1920
  // Ship this session's secret findings as one group (server replaces wholesale).
1856
1921
  if (built.findings.length > 0)
1857
1922
  await findingsBatch.add(built.findings);
@@ -1866,7 +1931,7 @@ async function syncToTargetInScope(cred, opts = {}) {
1866
1931
  // excluded: replay/outcome re-read the WHOLE file (readEvents), and a
1867
1932
  // 353MB session with 100MB+ single JSON lines OOMed the walk right after
1868
1933
  // its (bounded) conversation shipped.
1869
- if (upload.sessionMeta && sessionFileBytes(ref) <= FULL_BUILD_MAX_BYTES) {
1934
+ if (upload.sessionMeta && sessionFileBytes(ref) <= DERIVED_MAX_BYTES) {
1870
1935
  try {
1871
1936
  const count = { redactions: 0 };
1872
1937
  // OFF THE MAIN THREAD. This is the heaviest work in the walk: replay,
@@ -2118,14 +2183,64 @@ async function syncToTargetInScope(cred, opts = {}) {
2118
2183
  scanned, scanMs: Math.round(scanMs),
2119
2184
  };
2120
2185
  }
2121
- /** Transcript bytes beyond which a FULL sync must not materialize the whole
2122
- * file. The FULL pipeline copies the transcript ~5× (parse, trim+redact,
2123
- * raw export, raw-text join, redacted container) — a 350MB session transiently
2124
- * needs >1.5GB of heap, which OOM-killed the watch daemon in a restart loop
2125
- * (ledger never acked → same file retried on every startup tick, forever). */
2126
- export const FULL_BUILD_MAX_BYTES = Math.max(8, parseInt(process.env.CHAT_RECALL_FULL_BUILD_MAX_MB || '64', 10)) * 1024 * 1024;
2127
- /** How much of an oversized transcript's tail still ships (newest content). */
2128
- const FULL_BUILD_TAIL_BYTES = 16 * 1024 * 1024;
2186
+ /** Build bytes (see sessionBuildBytes) beyond which a FULL sync must not
2187
+ * materialize the session. A larger session ships in chunks instead: the head
2188
+ * chunk as its FULL sync, then the rest as appends.
2189
+ *
2190
+ * The in-memory build holds the container many times over (export, container,
2191
+ * shadow merge, parse, redacted envelope, joined raw text, and a copy in the
2192
+ * scan worker). Measured on a 42 MB container: 840 MB peak RSS in full, 218 MB
2193
+ * in chunks, for the same 2,392 messages, under a unit with MemoryHigh=1G and
2194
+ * a resting RSS near 400 MB. At about 18 bytes per container byte, 24 MB
2195
+ * stays under the limit. */
2196
+ export const FULL_BUILD_MAX_BYTES = Math.max(8, parseInt(process.env.CHAT_RECALL_FULL_BUILD_MAX_MB || '24', 10)) * 1024 * 1024;
2197
+ /** Transcript bytes beyond which the derived computations (diff, outcome,
2198
+ * commits, markers) are skipped. They run in the scan worker and read the
2199
+ * whole transcript; a 353 MB session with 100 MB JSON lines OOMed the walk. */
2200
+ export const DERIVED_MAX_BYTES = Math.max(8, parseInt(process.env.CHAT_RECALL_DERIVED_MAX_MB || '64', 10)) * 1024 * 1024;
2201
+ /** Bytes of transcript one chunk of a chunked sync carries: the head of an
2202
+ * oversized FULL sync, and each append. */
2203
+ export const SYNC_CHUNK_BYTES = 8 * 1024 * 1024;
2204
+ /**
2205
+ * Secret findings for a whole transcript, read one chunk at a time.
2206
+ *
2207
+ * A chunked session never holds its full text, so it cannot use the one-pass
2208
+ * scan of the in-memory build. Line numbers are absolute: each chunk's
2209
+ * findings are shifted by the lines of the chunks before it. A line longer
2210
+ * than a chunk is skipped by the reader, and it counts as one line.
2211
+ */
2212
+ export async function scanTranscriptFindingsChunked(ref, tenantRules) {
2213
+ const backend = getBackendForId(ref.prefixedId);
2214
+ const findings = [];
2215
+ if (!backend?.readFromOffset)
2216
+ return findings;
2217
+ let offset = 0;
2218
+ let lineBase = 0;
2219
+ for (;;) {
2220
+ const chunk = await backend.readFromOffset(ref.prefixedId, offset, SYNC_CHUNK_BYTES);
2221
+ if (chunk.newOffset <= offset)
2222
+ break;
2223
+ offset = chunk.newOffset;
2224
+ if (chunk.skippedBytes) {
2225
+ lineBase += 1;
2226
+ continue;
2227
+ }
2228
+ try {
2229
+ for (const f of scanTextForFindings(chunk.text)) {
2230
+ findings.push({ session_id: ref.prefixedId, detector: 'builtin', rule: f.rule, line: lineBase + f.line, preview: f.preview });
2231
+ }
2232
+ if (tenantRules && tenantRules.length > 0) {
2233
+ for (const f of scanTenantRules(chunk.text, tenantRules)) {
2234
+ findings.push({ session_id: ref.prefixedId, detector: f.detector, rule: f.rule, line: lineBase + f.line, preview: f.preview });
2235
+ }
2236
+ }
2237
+ }
2238
+ catch { /* best-effort */ }
2239
+ for (let i = chunk.text.indexOf('\n'); i >= 0; i = chunk.text.indexOf('\n', i + 1))
2240
+ lineBase++;
2241
+ }
2242
+ return dropFuzzyFindings(findings, (f) => ({ detector: f.detector, rule: f.rule }));
2243
+ }
2129
2244
  /** Ceiling on PRE-REDACTION session text materialized on disk at any one moment
2130
2245
  * by the optional external-detector batch scan. The batch dir is flushed and
2131
2246
  * deleted every time it crosses this, so a SIGKILL/OOM mid-scan can strand at
@@ -2140,20 +2255,34 @@ export function sessionFileBytes(ref) {
2140
2255
  return 0;
2141
2256
  return backend.fileSize?.(ref.prefixedId) ?? 0;
2142
2257
  }
2258
+ /**
2259
+ * Bytes a FULL build of an append-only session loads (0 for non-AO backends):
2260
+ * the larger of the main transcript and the shadow container, which also holds
2261
+ * the subagent transcripts and any history an upstream rewrite dropped. The
2262
+ * main file alone under-counts: a 19 MB transcript built from a 42 MB shadow
2263
+ * and peaked at 840 MB RSS.
2264
+ */
2265
+ export function sessionBuildBytes(ref) {
2266
+ const main = sessionFileBytes(ref);
2267
+ if (main === 0)
2268
+ return 0;
2269
+ return Math.max(main, shadowUncompressedBytes(ref.toolId, ref.rawId));
2270
+ }
2143
2271
  export async function buildConversationSync(ref, mtime, opts = {}) {
2144
2272
  const mapPath = opts.mapPath ?? ((p) => p);
2145
2273
  let includeRaw = opts.includeRaw !== false;
2146
2274
  let includeMeta = opts.includeMeta !== false;
2147
2275
  let srcHash;
2148
- // Oversized-transcript guard: ship the newest FULL_BUILD_TAIL_BYTES as this
2149
- // session's FULL sync instead of materializing the whole file. The envelope
2150
- // is marked truncated, raw/meta (whole-file reads) are dropped, and
2151
- // from_offset = file size — so the ledger advances and every later tick is
2152
- // a normal bounded APPEND. Truncation only loses the transcript's OLD head.
2153
- const fileBytes = sessionFileBytes(ref);
2154
- const oversized = fileBytes > FULL_BUILD_MAX_BYTES;
2155
- let tailText = '';
2156
- let tailOffsetEnd = 0;
2276
+ // Oversized transcript: ship it in chunks. This FULL sync carries the HEAD
2277
+ // chunk (the first SYNC_CHUNK_BYTES, cut at a line end) with from_offset at
2278
+ // the end of that chunk. The ledger records that offset, so syncMode sees the
2279
+ // file as larger than the cursor and every later tick APPENDs the next chunk
2280
+ // until the server holds the whole transcript. The raw archive (a whole-file
2281
+ // export) is dropped; telemetry stays, because parseSessionFile reads line by
2282
+ // line.
2283
+ const oversized = sessionBuildBytes(ref) > FULL_BUILD_MAX_BYTES;
2284
+ let headOffsetEnd = 0;
2285
+ let oversizedFindings = null;
2157
2286
  // Parse the transcript live — this is the single canonical parse; the
2158
2287
  // envelope and the redacted text both come from it. When the shadow path
2159
2288
  // runs (normal-size sessions) it also yields the fullest raw container, which
@@ -2165,16 +2294,20 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
2165
2294
  if (!backend?.readFromOffset)
2166
2295
  return null;
2167
2296
  try {
2168
- const tail = await backend.readFromOffset(ref.prefixedId, fileBytes - FULL_BUILD_TAIL_BYTES);
2169
- tailText = tail.text;
2170
- tailOffsetEnd = tail.newOffset;
2171
- const messages = parseTailMessages(ref.toolId, tailText);
2297
+ // A first line longer than a chunk is skipped by the reader; read on
2298
+ // from past it so the head carries real messages.
2299
+ let head = await backend.readFromOffset(ref.prefixedId, 0, SYNC_CHUNK_BYTES);
2300
+ for (let hops = 0; head.skippedBytes && hops < 8; hops++) {
2301
+ head = await backend.readFromOffset(ref.prefixedId, head.newOffset, SYNC_CHUNK_BYTES);
2302
+ }
2303
+ headOffsetEnd = head.newOffset;
2304
+ const messages = parseTailMessages(ref.toolId, head.text);
2172
2305
  if (messages.length > 0)
2173
2306
  transcript = { messages, subagents: [] };
2174
2307
  }
2175
- catch { /* unreadable tail */ }
2308
+ catch { /* unreadable head */ }
2176
2309
  includeRaw = false; // exportRawSession would read the whole file
2177
- includeMeta = false; // parseSessionFile would read the whole file
2310
+ oversizedFindings = await scanTranscriptFindingsChunked(ref, opts.tenantRules);
2178
2311
  // NB: oversized sessions are NOT shadowed — a full export would OOM the
2179
2312
  // walk (the very reason for the tail path). A session truncated FROM
2180
2313
  // oversized down to normal falls back to a fresh 'created' shadow; the
@@ -2297,9 +2430,6 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
2297
2430
  // which is the same behaviour as an un-indexed session.
2298
2431
  let projectPath = ref.projectPath;
2299
2432
  const meta = { messageCount: textMessages.length };
2300
- // Flag tail-only FULL syncs so the UI can badge partial history.
2301
- if (oversized)
2302
- meta.truncated = true;
2303
2433
  // Single-prompt invocations (batch/bot runs) carry a flag so the UI can
2304
2434
  // badge them and lists can de-emphasize them.
2305
2435
  if (textMessages.filter((m) => m.role === 'user').length <= 1)
@@ -2456,23 +2586,10 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
2456
2586
  }
2457
2587
  scanMs = performance.now() - t0;
2458
2588
  }
2459
- // Oversized sessions: builtin regex (+ tenant rules) over the bounded tail —
2460
- // the container path above never ran for them.
2461
- if (oversized && tailText) {
2462
- const t0 = performance.now();
2463
- try {
2464
- for (const f of scanTextForFindings(tailText)) {
2465
- findings.push({ session_id: ref.prefixedId, detector: 'builtin', rule: f.rule, line: f.line, preview: f.preview });
2466
- }
2467
- if (opts.tenantRules && opts.tenantRules.length > 0) {
2468
- for (const f of scanTenantRules(tailText, opts.tenantRules)) {
2469
- findings.push({ session_id: ref.prefixedId, detector: f.detector, rule: f.rule, line: f.line, preview: f.preview });
2470
- }
2471
- }
2472
- }
2473
- catch { /* best-effort */ }
2474
- scanMs = performance.now() - t0;
2475
- }
2589
+ // Oversized sessions: the whole file was scanned chunk by chunk above —
2590
+ // the container path never ran for them.
2591
+ if (oversizedFindings)
2592
+ findings.push(...oversizedFindings);
2476
2593
  // High-precision default: drop fuzzy/low-precision detector findings
2477
2594
  // (generic-api-key, Box, URI, …) before shipping so the Security view isn't
2478
2595
  // buried in false positives. Opt back in with CHAT_RECALL_INCLUDE_FUZZY=1.
@@ -2529,7 +2646,10 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
2529
2646
  // (convBatch onFlush uses this, not a recomputed size) so the next
2530
2647
  // append's base_offset matches exactly — no spurious continuity misses on
2531
2648
  // an actively-growing session. undefined for non-append-only backends.
2532
- from_offset: oversized ? tailOffsetEnd : getBackendForId(ref.prefixedId)?.fileSize?.(ref.rawId),
2649
+ from_offset: oversized ? headOffsetEnd : getBackendForId(ref.prefixedId)?.fileSize?.(ref.rawId),
2650
+ // The head of a chunked session. The server reports back when its shrink
2651
+ // guard keeps a fuller stored copy, so the client appends from there.
2652
+ ...(oversized ? { chunked: true } : {}),
2533
2653
  meta,
2534
2654
  mtime,
2535
2655
  },
@@ -2573,15 +2693,27 @@ export async function buildConversationTail(ref, fromOffset, opts = {}) {
2573
2693
  if (!backend?.readFromOffset)
2574
2694
  return null;
2575
2695
  const mapPath = opts.mapPath ?? ((p) => p);
2576
- const { text: tailText, newOffset } = await backend.readFromOffset(ref.prefixedId, fromOffset);
2577
- if (!tailText.trim())
2578
- return null;
2579
- const messages = parseTailMessages(ref.toolId, tailText);
2580
- if (messages.length === 0)
2696
+ const { text: tailText, newOffset, skippedBytes } = await backend.readFromOffset(ref.prefixedId, fromOffset, SYNC_CHUNK_BYTES);
2697
+ if (newOffset <= fromOffset)
2581
2698
  return null;
2699
+ // A window with bytes but nothing to show (tool noise, a skipped line longer
2700
+ // than a chunk) still has to move the server's synced-through offset, or the
2701
+ // cursor reads the same window on every tick and the rest of a chunked
2702
+ // session never ships. The server merges an empty tail and advances `o`.
2703
+ const messages = skippedBytes ? [] : parseTailMessages(ref.toolId, tailText);
2582
2704
  const textMessages = messages.filter((m) => m.role !== 'summary' && m.content?.trim());
2583
- if (textMessages.length === 0 && !messages.some((m) => m.toolCalls?.length))
2584
- return null;
2705
+ const hasContent = textMessages.length > 0 || messages.some((m) => m.toolCalls?.length);
2706
+ if (!hasContent) {
2707
+ return {
2708
+ conv: {
2709
+ session_id: ref.prefixedId, tool: ref.toolId, project_path: mapPath(ref.projectPath),
2710
+ append: true, base_offset: fromOffset, from_offset: newOffset, redacted_text: '',
2711
+ envelope: { v: TRANSCRIPT_VERSION, messages: [], subagents: [] },
2712
+ mtime: Math.floor(ref.mtime) || 0,
2713
+ },
2714
+ redactions: 0, newOffset, findings: [], bytes: 0,
2715
+ };
2716
+ }
2585
2717
  // Skip chat-recall's OWN internal prompts in the tail (same gate as the
2586
2718
  // full builder). A tail that is ONLY an internal prompt ships nothing.
2587
2719
  const firstUser = textMessages.find((m) => m.role === 'user');
@@ -2631,6 +2763,7 @@ export async function buildConversationTail(ref, fromOffset, opts = {}) {
2631
2763
  redactions: count.redactions,
2632
2764
  newOffset,
2633
2765
  findings,
2766
+ bytes: Buffer.byteLength(tailText),
2634
2767
  };
2635
2768
  }
2636
2769
  export function isSyncSkip(r) {