chat-recall 0.5.6 → 0.5.7

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.
Files changed (75) hide show
  1. package/README.md +332 -0
  2. package/dist/cli.js +7013 -4542
  3. package/dist/cli.js.map +1 -1
  4. package/dist/code-index-cursor.d.ts +18 -0
  5. package/dist/code-index-cursor.d.ts.map +1 -0
  6. package/dist/code-index-cursor.js +89 -0
  7. package/dist/code-index-cursor.js.map +1 -0
  8. package/dist/daemon-log.d.ts +44 -0
  9. package/dist/daemon-log.d.ts.map +1 -0
  10. package/dist/daemon-log.js +74 -0
  11. package/dist/daemon-log.js.map +1 -0
  12. package/dist/derived.d.ts +20 -0
  13. package/dist/derived.d.ts.map +1 -0
  14. package/dist/derived.js +106 -0
  15. package/dist/derived.js.map +1 -0
  16. package/dist/install-skills.d.ts.map +1 -1
  17. package/dist/install-skills.js +6 -1
  18. package/dist/install-skills.js.map +1 -1
  19. package/dist/log-rotate.d.ts +16 -0
  20. package/dist/log-rotate.d.ts.map +1 -0
  21. package/dist/log-rotate.js +50 -0
  22. package/dist/log-rotate.js.map +1 -0
  23. package/dist/mcp.d.ts.map +1 -1
  24. package/dist/mcp.js +5830 -3788
  25. package/dist/mcp.js.map +1 -1
  26. package/dist/project-tasks.js +1 -1
  27. package/dist/project-tasks.js.map +1 -1
  28. package/dist/redact-deep.d.ts +4 -0
  29. package/dist/redact-deep.d.ts.map +1 -0
  30. package/dist/redact-deep.js +26 -0
  31. package/dist/redact-deep.js.map +1 -0
  32. package/dist/scan-pool.d.ts +56 -0
  33. package/dist/scan-pool.d.ts.map +1 -0
  34. package/dist/scan-pool.js +245 -0
  35. package/dist/scan-pool.js.map +1 -0
  36. package/dist/scan-worker.d.ts +78 -0
  37. package/dist/scan-worker.d.ts.map +1 -0
  38. package/dist/scan-worker.js +11175 -0
  39. package/dist/scan-worker.js.map +1 -0
  40. package/dist/self-restart.d.ts +20 -2
  41. package/dist/self-restart.d.ts.map +1 -1
  42. package/dist/self-restart.js +16 -6
  43. package/dist/self-restart.js.map +1 -1
  44. package/dist/server-probe.d.ts +76 -0
  45. package/dist/server-probe.d.ts.map +1 -0
  46. package/dist/server-probe.js +106 -0
  47. package/dist/server-probe.js.map +1 -0
  48. package/dist/sync-client.d.ts +24 -0
  49. package/dist/sync-client.d.ts.map +1 -1
  50. package/dist/sync-client.js +496 -151
  51. package/dist/sync-client.js.map +1 -1
  52. package/dist/target-breaker.d.ts +61 -0
  53. package/dist/target-breaker.d.ts.map +1 -0
  54. package/dist/target-breaker.js +85 -0
  55. package/dist/target-breaker.js.map +1 -0
  56. package/dist/telemetry-consent.d.ts +31 -0
  57. package/dist/telemetry-consent.d.ts.map +1 -0
  58. package/dist/telemetry-consent.js +115 -0
  59. package/dist/telemetry-consent.js.map +1 -0
  60. package/dist/telemetry.d.ts +22 -0
  61. package/dist/telemetry.d.ts.map +1 -0
  62. package/dist/telemetry.js +125 -0
  63. package/dist/telemetry.js.map +1 -0
  64. package/dist/tick-queue.d.ts +67 -0
  65. package/dist/tick-queue.d.ts.map +1 -0
  66. package/dist/tick-queue.js +104 -0
  67. package/dist/tick-queue.js.map +1 -0
  68. package/dist/transport-safety.d.ts +43 -0
  69. package/dist/transport-safety.d.ts.map +1 -0
  70. package/dist/transport-safety.js +100 -0
  71. package/dist/transport-safety.js.map +1 -0
  72. package/dist/verify-repair.js +1 -1
  73. package/dist/verify-repair.js.map +1 -1
  74. package/dist/watch.js +8345 -6030
  75. package/package.json +21 -2
@@ -45,13 +45,11 @@ 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 { extractTurnsAny, replaySessionAny } from '@chat-recall/engine/core/session-multi-tool.js';
49
48
  import { parseTranscript, trimTranscriptForSync, TRANSCRIPT_VERSION, gzipContainer, mapContainerText, buildRawContainer, containerSrcHash, parseTranscriptFromContainer, updateShadow } from '@chat-recall/engine/transcript/index.js';
50
49
  import { parseClaudeTranscriptText } from '@chat-recall/engine/transcript/claude.js';
51
50
  import { parseGeminiTranscriptText } from '@chat-recall/engine/transcript/gemini.js';
52
51
  import { parseCodexTranscriptText } from '@chat-recall/engine/transcript/codex.js';
53
52
  import { getBackendForId, getBackend } from '@chat-recall/engine/core/tool-backend.js';
54
- import { computeOutcome } from '@chat-recall/engine/core/session-outcome.js';
55
53
  import { fileURLToPath } from 'node:url';
56
54
  let ownVersion = '0.0.0';
57
55
  try {
@@ -66,7 +64,6 @@ catch {
66
64
  }
67
65
  catch { }
68
66
  }
69
- import { markPrompt, summarizeMarkers } from '@chat-recall/engine/core/session-sentiment.js';
70
67
  import { parseSessionFile, readCwdFromJsonl } from '@chat-recall/engine/parsers/session.js';
71
68
  import { resolveProjectId } from '@chat-recall/engine/core/project-resolver.js';
72
69
  import { extractEntities } from '@chat-recall/engine/core/entity-extractor.js';
@@ -76,7 +73,61 @@ import { extractorVersionForTool, extractorVersionForId, toolOfId, EXTRACTOR_VER
76
73
  import { SYNC_FIELDS } from '@chat-recall/engine/core/sync-fields.js';
77
74
  import { acquireIndexLock } from '@chat-recall/engine/core/index-lock.js';
78
75
  import { fetchWithTimeout } from './http.js';
79
- import { withSessionReadCache } from '@chat-recall/engine/core/live-session-scan.js';
76
+ import { gzipSync } from 'zlib';
77
+ import { redactDeep } from './redact-deep.js';
78
+ import { collectDerivedRows } from './derived.js';
79
+ import { scanPool } from './scan-pool.js';
80
+ import { isPrivateHost, transportRisk, assertTransportSafe } from './transport-safety.js';
81
+ import { breakerState, breakerNotice, noteTargetSuccess, noteTargetFailure } from './target-breaker.js';
82
+ import { setTelemetryEligible } from './telemetry-consent.js';
83
+ import { record, flush } from './telemetry.js';
84
+ /**
85
+ * The server rule pack as it arrived, kept for the scan workers.
86
+ *
87
+ * Workers run in their own isolate with their own copy of the redactor, so they
88
+ * need the patterns as data. The installed form is compiled RegExp objects,
89
+ * which cannot cross a postMessage.
90
+ */
91
+ /**
92
+ * Walk progress for the next upload to carry.
93
+ *
94
+ * Module-scope because the producer (the walk) and the consumer (the batch
95
+ * poster) are far apart, and threading it through every batcher signature to
96
+ * deliver a cosmetic field would be worse than a single slot.
97
+ */
98
+ /**
99
+ * Reduce an error message to a bounded CLASS.
100
+ *
101
+ * Telemetry must never carry a raw error string: server errors quote paths,
102
+ * payload fragments and occasionally tokens. An enum is enough to answer "what
103
+ * is breaking for customers", and cannot leak.
104
+ */
105
+ function classifyError(msg) {
106
+ const m = msg.toLowerCase();
107
+ if (m.includes('429'))
108
+ return 'rate_limited';
109
+ if (m.includes('402'))
110
+ return 'payment_required';
111
+ if (m.includes('401') || m.includes('403') || m.includes('token'))
112
+ return 'auth';
113
+ if (m.includes('timed out') || m.includes('etimedout'))
114
+ return 'timeout';
115
+ if (m.includes('econnrefused'))
116
+ return 'refused';
117
+ if (m.includes('enotfound') || m.includes('dns'))
118
+ return 'dns';
119
+ if (m.includes('fetch failed') || m.includes('econnreset'))
120
+ return 'network';
121
+ if (m.includes('plain http') || m.includes('https'))
122
+ return 'insecure_transport';
123
+ if (/5\d\d/.test(m))
124
+ return 'server_error';
125
+ return 'other';
126
+ }
127
+ let walkProgressForUpload = null;
128
+ let lastServerPackSpec = null;
129
+ import { reportWalkProgress, readCollectorHealth, updateCollectorHealth } from '@chat-recall/engine/core/collector-health.js';
130
+ import { withSessionScanScope } from '@chat-recall/engine/core/live-session-scan.js';
80
131
  import '@chat-recall/engine/core/backends/index.js'; // register the tool backends
81
132
  // Lives under the data dir so CHAT_RECALL_DATA_DIR isolates credentials the
82
133
  // same way it isolates the index (tests, multi-profile setups).
@@ -88,6 +139,10 @@ const credPath = () => join(getDataDir(), 'credentials.json');
88
139
  * form still readable.
89
140
  */
90
141
  export function saveCredentials(c) {
142
+ // REFUSE AT THE DOOR. Every login path funnels through here, so this is the
143
+ // one place that can stop a cleartext target being persisted at all — better
144
+ // than warning at sync time, by which point the user believes they are set up.
145
+ assertTransportSafe(c.serverUrl);
91
146
  mkdirSync(dirname(credPath()), { recursive: true });
92
147
  const targets = loadAllCredentials().filter((t) => t.serverUrl !== c.serverUrl);
93
148
  // Newest login FIRST: single-target consumers (loadCredentials → targets[0],
@@ -164,6 +219,10 @@ async function fetchTenantSecurityConfig(cred) {
164
219
  const packSpec = body.pack?.rules
165
220
  ? { version: body.pack.version, rules: body.pack.rules.map((r) => ({ name: r.name, regex: r.regex, flags: r.flags, redact: true, source: r.source })) }
166
221
  : { version: body.version, rules: enabled.filter((r) => r.redact === true).map((r) => ({ name: r.name, regex: r.regex, redact: true, source: 'tenant' })) };
222
+ // Keep the SPEC, not just the compiled rules: a worker needs the raw
223
+ // patterns to install them in its own isolate, and the compiled form
224
+ // holds RegExp objects that do not survive a postMessage.
225
+ lastServerPackSpec = { version: packSpec.version || '', rules: packSpec.rules };
167
226
  const pack = installServerRulePack(packSpec);
168
227
  rulePackVersion = serverRulePackVersion();
169
228
  for (const r of pack.rejected) {
@@ -181,6 +240,19 @@ async function fetchTenantSecurityConfig(cred) {
181
240
  const body = await settingsRes.json().catch(() => ({}));
182
241
  if (typeof body.verifySecrets === 'boolean')
183
242
  verifySecrets = body.verifySecrets;
243
+ // Learned HERE because this request happens on every sync. Reading it from
244
+ // the /api/sync response meant a fully-synced machine — which uploads
245
+ // nothing, so gets no response — could never learn its own eligibility.
246
+ if (typeof body.telemetry === 'boolean') {
247
+ setTelemetryEligible(base, body.telemetry);
248
+ try {
249
+ const prior = readCollectorHealth()?.telemetryEligible ?? {};
250
+ updateCollectorHealth({
251
+ telemetryEligible: { ...prior, [base]: { allowed: body.telemetry, at: Date.now() } },
252
+ });
253
+ }
254
+ catch { /* cosmetic — never fail a sync over it */ }
255
+ }
184
256
  }
185
257
  const config = { tenantRules, verifySecrets, rulePackVersion };
186
258
  _securityConfigCache.set(base, { fetchedAt: Date.now(), config });
@@ -296,22 +368,6 @@ export function removeCredentials(serverUrl) {
296
368
  /** Tokenize an absolute project path so the server can group by project
297
369
  * without learning the developer's filesystem layout. */
298
370
  const hashPath = (p) => (p ? 'p_' + createHash('sha256').update(p).digest('hex').slice(0, 12) : '');
299
- /** Redact every string anywhere inside a JSON-serializable value. Used for
300
- * derived payloads (diffs carry file content; outcomes carry prompt text)
301
- * where field-by-field redaction would be fragile. */
302
- function redactDeep(v, count) {
303
- if (typeof v === 'string')
304
- return redactSecrets(v, { force: true, count });
305
- if (Array.isArray(v))
306
- return v.map((x) => redactDeep(x, count));
307
- if (v && typeof v === 'object') {
308
- const o = {};
309
- for (const [k, val] of Object.entries(v))
310
- o[k] = redactDeep(val, count);
311
- return o;
312
- }
313
- return v;
314
- }
315
371
  /** compute_cache kinds shipped to the server. Must stay in step with the
316
372
  * kinds the server's conversation routes read (heavyCacheGet keys). */
317
373
  const COMPUTE_KINDS = ['diff', 'outcome', 'commits', 'markers'];
@@ -324,26 +380,14 @@ const MAX_DERIVED_ROW_BYTES = 2 * 1024 * 1024;
324
380
  * is no shared-tenant or rate-limiter to be polite to, so they get a higher
325
381
  * in-flight cap and zero inter-batch sleep. Remote/SaaS hosts stay gentler.
326
382
  */
327
- function isLocalHost(serverUrl) {
328
- let host = '';
329
- try {
330
- host = new URL(serverUrl).hostname.toLowerCase();
331
- }
332
- catch {
333
- return false;
334
- }
335
- if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0')
336
- return true;
337
- if (host.endsWith('.local') || host.endsWith('.lan'))
338
- return true;
339
- if (/^10\./.test(host))
340
- return true; // 10.0.0.0/8
341
- if (/^192\.168\./.test(host))
342
- return true; // 192.168.0.0/16
343
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
344
- return true; // 172.16.0.0/12
345
- return false;
346
- }
383
+ /**
384
+ * "You own this box", for upload pacing.
385
+ *
386
+ * One definition, shared with the transport gate — these were two copies of the
387
+ * same private-address list, and the moment they disagree either politeness or
388
+ * SECURITY is wrong. transport-safety.ts owns the list.
389
+ */
390
+ const isLocalHost = isPrivateHost;
347
391
  export async function syncSessions(opts = {}) {
348
392
  const targets = loadAllCredentials();
349
393
  if (targets.length === 0)
@@ -356,9 +400,24 @@ export async function syncSessions(opts = {}) {
356
400
  catch { /* never block a sync on housekeeping */ }
357
401
  let agg = null;
358
402
  const errors = [];
403
+ const skippedTargets = [];
359
404
  for (const cred of targets) {
405
+ // CIRCUIT BREAKER. Targets are walked in SEQUENCE, so a dead one does not
406
+ // just fail — it delays every ship to the healthy targets behind it, and
407
+ // delays the walk's completion, which the ledger, the health file and the
408
+ // progress report all wait on. A LAN box that is off should cost one log
409
+ // line, not a full retry storm per session, every walk, forever.
410
+ const verdict = breakerState(cred.serverUrl);
411
+ if (verdict.open) {
412
+ const notice = breakerNotice(cred.serverUrl, verdict);
413
+ if (notice)
414
+ console.error(`[sync] ${notice}`);
415
+ skippedTargets.push(cred.serverUrl);
416
+ continue;
417
+ }
360
418
  try {
361
419
  const r = await syncToTarget(cred, opts);
420
+ noteTargetSuccess(cred.serverUrl);
362
421
  agg = agg ? {
363
422
  uploaded: agg.uploaded + r.uploaded, skipped: agg.skipped + r.skipped, redactions: agg.redactions + r.redactions,
364
423
  items: agg.items + r.items, links: agg.links + r.links, findings: agg.findings + r.findings,
@@ -367,14 +426,59 @@ export async function syncSessions(opts = {}) {
367
426
  } : r;
368
427
  }
369
428
  catch (e) {
370
- errors.push(`${cred.serverUrl}: ${e instanceof Error ? e.message : e}`);
429
+ const msg = e instanceof Error ? e.message : String(e);
430
+ errors.push(`${cred.serverUrl}: ${msg}`);
431
+ // Report the NEXT attempt in the same breath as the failure, so the log
432
+ // says what will happen rather than leaving the user to infer it.
433
+ const next = noteTargetFailure(cred.serverUrl, msg);
434
+ const notice = breakerNotice(cred.serverUrl, next);
435
+ if (notice)
436
+ console.error(`[sync] ${notice}`);
437
+ // The error CLASS, never the message: a server error string can quote a
438
+ // path or a payload, and this channel must not carry either.
439
+ record({
440
+ kind: next.open ? 'breaker_trip' : 'target_failure',
441
+ failures: next.failures,
442
+ retryInMs: next.retryInMs,
443
+ errorClass: classifyError(msg),
444
+ local: isLocalHost(cred.serverUrl) ? 1 : 0,
445
+ });
371
446
  }
372
447
  }
448
+ // A walk that shipped nothing because every target is in cooldown is NOT a
449
+ // success, and must not be reported as one — the caller marks target health
450
+ // from this, and "0 pushed, no error" would read as a healthy quiet walk.
451
+ if (!agg && skippedTargets.length > 0 && errors.length === 0) {
452
+ throw new Error(`every sync target is in failure cooldown: ${skippedTargets.join(', ')}. `
453
+ + 'They will be retried automatically; run `chat-recall doctor` for the last error.');
454
+ }
373
455
  // Ledger writes are coalesced during the walk (see persist() in sync-ledger).
374
456
  // This is the durability point: everything acked above reaches disk before we
375
457
  // report success, so a crash after this cannot re-ship what the server has.
376
458
  // In the finally, because a partial walk's acks are still worth keeping.
377
459
  flushLedger();
460
+ // OPERATIONAL TELEMETRY, gated on consent + plan (telemetry-consent.ts). These
461
+ // are the numbers that were previously only ever measured on one developer
462
+ // machine, which is why "how long is a first sync for a 5,000-session
463
+ // customer?" had no answer. Numbers only — the payload cannot carry a path,
464
+ // project or session id, structurally.
465
+ if (agg) {
466
+ record({
467
+ kind: 'sync_walk',
468
+ targets: targets.length,
469
+ skippedTargets: skippedTargets.length,
470
+ failedTargets: errors.length,
471
+ uploaded: agg.uploaded,
472
+ skipped: agg.skipped,
473
+ redactions: agg.redactions,
474
+ scanned: agg.scanned,
475
+ scanMs: Math.round(agg.scanMs),
476
+ rssPeakMb: Math.round(process.memoryUsage().rss / 1048576),
477
+ });
478
+ }
479
+ // After the walk: the connection is warm and the numbers are final. Never
480
+ // awaited into the caller's error path — telemetry must not fail a sync.
481
+ void flush().catch(() => { });
378
482
  if (!agg)
379
483
  throw new Error(`sync failed for all targets:\n ${errors.join('\n ')}`);
380
484
  if (errors.length > 0)
@@ -610,7 +714,38 @@ export function batchScanExternal(items, opts = {}) {
610
714
  */
611
715
  const CAPS_TTL_MS = 60 * 60_000;
612
716
  const capsOk = new Map(); // serverUrl -> epoch ms verified
613
- export function _resetCapsCacheForTests() { capsOk.clear(); }
717
+ /** Ingest concurrency the server says it will accept, per serverUrl. */
718
+ const advertisedIngestConc = new Map();
719
+ /** Servers that accept a gzipped request body. */
720
+ const advertisedGzipBody = new Set();
721
+ export function _resetCapsCacheForTests() {
722
+ capsOk.clear();
723
+ advertisedIngestConc.clear();
724
+ advertisedGzipBody.clear();
725
+ }
726
+ /**
727
+ * Learn the tenant-scoped ingest allowance from an authenticated response.
728
+ *
729
+ * `verifyServerApi` sets the same map from /api/capabilities, which is pre-auth
730
+ * and therefore can only report the CLASS ceiling. This overrides it with the
731
+ * number that actually applies to this tenant.
732
+ */
733
+ export function setServerIngestConcurrency(serverUrl, n) {
734
+ advertisedIngestConc.set(serverUrl.replace(/\/$/, ''), n);
735
+ }
736
+ /** Does this server accept `Content-Encoding: gzip` on a request body? */
737
+ export function serverAcceptsGzipBody(serverUrl) {
738
+ return advertisedGzipBody.has(serverUrl.replace(/\/$/, ''));
739
+ }
740
+ /**
741
+ * How many uploads may be in flight at once, according to the SERVER.
742
+ *
743
+ * Returns null when the server does not advertise it (older image) — the caller
744
+ * then keeps its own default.
745
+ */
746
+ export function serverIngestConcurrency(serverUrl) {
747
+ return advertisedIngestConc.get(serverUrl.replace(/\/$/, '')) ?? null;
748
+ }
614
749
  async function verifyServerApi(serverUrl, minApi) {
615
750
  const base = serverUrl.replace(/\/$/, '');
616
751
  const seen = capsOk.get(base);
@@ -620,10 +755,45 @@ async function verifyServerApi(serverUrl, minApi) {
620
755
  if ((caps.apiVersion ?? 0) < minApi) {
621
756
  throw new Error(`server too old: apiVersion ${caps.apiVersion ?? 'none'} < required ${minApi} — update the server image before syncing`);
622
757
  }
758
+ // Learn the server's in-flight budget instead of assuming one. The old
759
+ // hardcoded 4 was exactly twice the server's per-tenant cap of 2, so every
760
+ // walk manufactured its own 429 storm — 3,526 of them in one measured log.
761
+ // A number the server owns can be tuned without shipping a CLI release.
762
+ const adv = caps.limits?.ingestConcurrencyPerTenant;
763
+ if (typeof adv === 'number' && Number.isFinite(adv) && adv >= 1) {
764
+ advertisedIngestConc.set(base, Math.floor(adv));
765
+ }
766
+ if (caps.limits?.acceptsGzipBody === true)
767
+ advertisedGzipBody.add(base);
768
+ else
769
+ advertisedGzipBody.delete(base);
623
770
  capsOk.set(base, Date.now());
624
771
  return true;
625
772
  }
773
+ /**
774
+ * One walk = one scan scope.
775
+ *
776
+ * Locating a session used to probe every project directory in every profile
777
+ * home, PER SESSION — and the walk asks twice per session
778
+ * (`spansMultipleSources`, then `fileSize`). Measured on a 15,724-session
779
+ * corpus: 62.8 seconds of filesystem syscalls to discover that 2 sessions had
780
+ * changed. With the scope open the directories are listed once instead: 196 ms.
781
+ *
782
+ * The scope is the honest place for the cache. A walk snapshots its session
783
+ * list up front, so within it "where does this session live" genuinely cannot
784
+ * change; outside it every lookup still reads the filesystem.
785
+ */
626
786
  async function syncToTarget(cred, opts = {}) {
787
+ return withSessionScanScope(() => syncToTargetInScope(cred, opts));
788
+ }
789
+ async function syncToTargetInScope(cred, opts = {}) {
790
+ // Checked again here, not only at login: credentials.json is a plain file a
791
+ // user can edit, an env var can supply a target that never saw the login gate
792
+ // (see credentials-env.ts), and a target saved by an older version predates
793
+ // the check entirely. The upload path is the last place to say no.
794
+ const risk = transportRisk(cred.serverUrl);
795
+ if (risk)
796
+ throw new Error(risk);
627
797
  const sync = loadSettings().sync;
628
798
  // Exclusions = local settings ∪ server-side tenant config (dashboard-edited).
629
799
  const serverCfg = await fetchTenantSyncConfig(cred);
@@ -720,7 +890,21 @@ async function syncToTarget(cred, opts = {}) {
720
890
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
721
891
  const explicitThrottle = typeof opts.throttleMs === 'number' && opts.throttleMs > 0;
722
892
  const throttleMs = explicitThrottle ? opts.throttleMs : 0;
723
- const maxInflight = explicitThrottle ? 1 : (isLocalHost(cred.serverUrl) ? 6 : 4);
893
+ // In-flight budget: the server's advertised per-tenant ingest concurrency when
894
+ // it publishes one, else the historical default. Never exceed what the server
895
+ // will accept — an extra in-flight batch does not go faster, it comes back
896
+ // 429 and then sleeps out its Retry-After.
897
+ //
898
+ // A local target is yours: no politeness needed, and no advertised cap to
899
+ // respect beyond its own.
900
+ const LOCAL_INFLIGHT = 6;
901
+ const REMOTE_INFLIGHT_FALLBACK = 4;
902
+ const advertised = serverIngestConcurrency(cred.serverUrl);
903
+ const maxInflight = explicitThrottle
904
+ ? 1
905
+ : isLocalHost(cred.serverUrl)
906
+ ? Math.max(LOCAL_INFLIGHT, advertised ?? 0)
907
+ : (advertised ?? REMOTE_INFLIGHT_FALLBACK);
724
908
  // Per-attempt upload timeout. WITHOUT this, a half-open/slow connection makes
725
909
  // `fetch` hang FOREVER (Node fetch has no default timeout) — observed as a
726
910
  // sync stuck for 20-40min at idle CPU with zero progress. With it, a hung
@@ -729,8 +913,41 @@ async function syncToTarget(cred, opts = {}) {
729
913
  // Single POST with retry. Retryable: network/abort errors, HTTP 429 (obeying
730
914
  // Retry-After — the server's own rate signal), and 5xx. Non-429 4xx (bad
731
915
  // token, oversized body) is fatal: retrying can't fix it.
916
+ // COMPRESS THE BODY. Nothing compressed it before — every sync shipped raw
917
+ // JSON. Measured over five real sessions, 11.47 MB of envelope became 6.69 MB:
918
+ // 42% off the wire for one header and one gzipSync.
919
+ //
920
+ // Why gzip and not a binary/multipart redesign. The payload is ~64% `raw_b64`
921
+ // — a gzipped transcript, base64'd into JSON, which inflates it ~33%. Sending
922
+ // those bytes binary alongside a compressed metadata part measures 6.66 MB:
923
+ // the SAME bytes as just gzipping the whole thing, because gzip recovers most
924
+ // of what base64 wasted. Binary buys CPU (115 ms vs 318 ms), not bandwidth,
925
+ // and costs a new wire format on both sides. Brotli reaches 6.07 MB — another
926
+ // 5% — and is where to go if bandwidth ever matters more than simplicity.
927
+ //
928
+ // Gated on the server advertising it: body-parser inflates transparently and
929
+ // its size limit still applies to the DECOMPRESSED body, so the 32 MB ceiling
930
+ // on /api/sync is unchanged.
931
+ const gzipBody = serverAcceptsGzipBody(cred.serverUrl);
732
932
  const post = async (body) => {
733
- const payload = JSON.stringify(body);
933
+ // SERIALISE INTO A BUFFER AND KEEP ONLY THAT. Retries hold the payload for
934
+ // up to four attempts × a 90s timeout, so anything still referenced here is
935
+ // resident for minutes. The JSON string is scoped to this block so it is
936
+ // collectable the moment the Buffer exists — previously both the string and
937
+ // (with gzip on) the compressed copy stayed reachable for the whole retry
938
+ // chain, i.e. ~2.3× the payload held for as long as the server was slow.
939
+ const payload = (() => {
940
+ // Ride the progress report along on whatever batch is already going out.
941
+ // The UI had only an AGE to show ("25m behind"), which during a first sync
942
+ // is indistinguishable from broken. No extra request, no new endpoint —
943
+ // the server stashes it and serves it on the status the UI already polls.
944
+ const withProgress = walkProgressForUpload
945
+ ? { ...body, progress: walkProgressForUpload }
946
+ : body;
947
+ const json = JSON.stringify(withProgress);
948
+ const raw = Buffer.from(json, 'utf8');
949
+ return gzipBody ? gzipSync(raw, { level: 6 }) : raw;
950
+ })();
734
951
  const RETRY_DELAYS_MS = [2000, 8000, 30000];
735
952
  for (let attempt = 0;; attempt++) {
736
953
  const ac = new AbortController();
@@ -740,14 +957,38 @@ async function syncToTarget(cred, opts = {}) {
740
957
  method: 'POST',
741
958
  // No token ⇒ no auth header: a no-auth local server (AUTH_PROVIDER=none)
742
959
  // accepts it as the single 'default' tenant.
743
- headers: cred.token
744
- ? { 'content-type': 'application/json', authorization: `Bearer ${cred.token}` }
745
- : { 'content-type': 'application/json' },
960
+ headers: {
961
+ 'content-type': 'application/json',
962
+ ...(gzipBody ? { 'content-encoding': 'gzip' } : {}),
963
+ ...(cred.token ? { authorization: `Bearer ${cred.token}` } : {}),
964
+ },
746
965
  body: payload,
747
966
  signal: ac.signal,
748
967
  });
749
968
  if (res.ok) {
750
969
  const body = await res.json().catch(() => ({}));
970
+ // The server owns the plan answer; the user's opt-out is checked
971
+ // separately and wins over it.
972
+ if (typeof body.telemetry === 'boolean') {
973
+ setTelemetryEligible(base, body.telemetry);
974
+ // Persisted so `doctor` — a separate process that never syncs — can
975
+ // tell the user the truth about what leaves their machine.
976
+ try {
977
+ const prior = readCollectorHealth()?.telemetryEligible ?? {};
978
+ updateCollectorHealth({
979
+ telemetryEligible: { ...prior, [base]: { allowed: body.telemetry, at: Date.now() } },
980
+ });
981
+ }
982
+ catch { /* cosmetic — never fail a sync over it */ }
983
+ }
984
+ // The AUTHENTICATED per-tenant allowance beats the pre-auth class
985
+ // ceiling from /api/capabilities: only this response knows the plan,
986
+ // and the gate scales the ceiling down per tenant. Learning it here is
987
+ // what stops a free tenant asking for 6 slots it will never get.
988
+ const adv = body.limits?.ingestConcurrencyPerTenant;
989
+ if (typeof adv === 'number' && Number.isFinite(adv) && adv >= 1) {
990
+ setServerIngestConcurrency(base, Math.floor(adv));
991
+ }
751
992
  if (body.cli && body.cli.version) {
752
993
  const { planAutoUpdate, runAutoUpdate } = await import('./auto-update.js');
753
994
  const authHeaders = cred.token ? { authorization: `Bearer ${cred.token}` } : {};
@@ -823,15 +1064,29 @@ async function syncToTarget(cred, opts = {}) {
823
1064
  // whole sync (matching the old serial behaviour).
824
1065
  const inflight = new Set();
825
1066
  let poolError = null;
826
- const submit = async (body, after) => {
827
- while (inflight.size >= maxInflight)
1067
+ // BOUND THE BYTES, not only the request count. `maxInflight` alone permits
1068
+ // maxInflight × the largest batch resident at once, and a batch is capped at
1069
+ // 4 MB only for the row categories that go through a batcher — a single
1070
+ // oversized conversation ships ALONE and can be far larger. Counting requests
1071
+ // said "4 in flight" whether that was 4 KB or 40 MB.
1072
+ //
1073
+ // An item bigger than the whole budget still ships, alone, once the pipe is
1074
+ // empty: refusing it would silently drop a session, and waiting forever would
1075
+ // deadlock the walk.
1076
+ const MAX_INFLIGHT_BYTES = (Number(process.env.CHAT_RECALL_MAX_INFLIGHT_MB) || 32) * 1024 * 1024;
1077
+ let inflightBytes = 0;
1078
+ const submit = async (body, after, bytesHint = 0) => {
1079
+ while (inflight.size > 0
1080
+ && (inflight.size >= maxInflight || inflightBytes + bytesHint > MAX_INFLIGHT_BYTES)) {
828
1081
  await Promise.race(inflight);
1082
+ }
829
1083
  if (poolError)
830
1084
  throw poolError;
1085
+ inflightBytes += bytesHint;
831
1086
  let task;
832
1087
  task = (async () => { await post(body); after(); })()
833
1088
  .catch((e) => { poolError = poolError ?? e; })
834
- .finally(() => { inflight.delete(task); });
1089
+ .finally(() => { inflight.delete(task); inflightBytes -= bytesHint; });
835
1090
  inflight.add(task);
836
1091
  };
837
1092
  const drainInflight = async () => {
@@ -860,7 +1115,12 @@ async function syncToTarget(cred, opts = {}) {
860
1115
  return {
861
1116
  sent: 0,
862
1117
  async add(row) {
863
- const size = JSON.stringify(row).length;
1118
+ // BYTES, not UTF-16 code units. `.length` on the serialised string
1119
+ // undercounts exactly the content that blows the budget: any non-ASCII
1120
+ // character is 2–4 bytes on the wire but 1–2 units here, so a batch of
1121
+ // emoji-heavy or CJK transcript could be ~3× the 4 MB cap it was
1122
+ // measured against and get rejected by the server's body limit.
1123
+ const size = Buffer.byteLength(JSON.stringify(row));
864
1124
  if (batch.length > 0 && (batchBytes + size > MAX_BATCH_BYTES || batch.length >= maxItems))
865
1125
  await this.drain();
866
1126
  batch.push(row);
@@ -870,11 +1130,14 @@ async function syncToTarget(cred, opts = {}) {
870
1130
  if (batch.length === 0)
871
1131
  return;
872
1132
  const rows = flatten ? batch.flat() : batch;
1133
+ const bytes = batchBytes;
873
1134
  batch = [];
874
1135
  batchBytes = 0;
875
1136
  // Hand off to the concurrency pool; `sent`/onFlush (e.g. markSynced)
876
- // fire only after the server acks THIS batch, same as before.
877
- await submit({ [field]: rows }, () => { this.sent += rows.length; onFlush?.(rows); });
1137
+ // fire only after the server acks THIS batch, same as before. The byte
1138
+ // total is already known here, so the pool can bound memory by size
1139
+ // instead of guessing from the request count.
1140
+ await submit({ [field]: rows }, () => { this.sent += rows.length; onFlush?.(rows); }, bytes);
878
1141
  },
879
1142
  };
880
1143
  };
@@ -1144,8 +1407,68 @@ async function syncToTarget(cred, opts = {}) {
1144
1407
  trace?.(`batch secret scan done: ${scanned} session(s) in ${batch.slices} slice(s)`);
1145
1408
  }
1146
1409
  trace?.('conversation walk start');
1410
+ // Say that a walk STARTED, before anything is done. A first sync on a large
1411
+ // corpus runs for a long time, and "0 of 15,724" is the difference between a
1412
+ // user waiting and a user assuming it is broken.
1413
+ const walkStartedAt = Date.now();
1414
+ reportWalkProgress({ done: 0, total: slice.length, startedAt: walkStartedAt, complete: false });
1415
+ walkProgressForUpload = { done: 0, total: slice.length, complete: false };
1147
1416
  let traceN = 0;
1417
+ // ── KEEP THE EVENT LOOP ALIVE ───────────────────────────────────────────
1418
+ //
1419
+ // THIS IS WHY THE COLLECTOR LOOKED DEAD. Every `await` in this loop resolves
1420
+ // as a MICROTASK, and Node drains the entire microtask queue before it
1421
+ // advances to the timer phase. So a backlog of sessions — each one followed by
1422
+ // a multi-second synchronous secret scan — runs as ONE unbroken chain, and for
1423
+ // its whole duration nothing else in the process happens:
1424
+ //
1425
+ // · the 60-second heartbeat never fires, so the log goes silent
1426
+ // · collector-health.json freezes, so every reader says "not running"
1427
+ // · SIGCHLD is not serviced, so a finished codeindex child sits <defunct>
1428
+ // · SIGTERM is not serviced, so systemd waits 90s and then SIGKILLs
1429
+ //
1430
+ // Measured: 12 sessions, 388 MB, 24.2 seconds of walking, ZERO of 24 expected
1431
+ // heartbeats. With `setImmediate` between sessions — the check phase runs
1432
+ // AFTER the timer phase — the same work fired 11. The daemon was never hung;
1433
+ // it simply never yielded, and "never yields for nine minutes" is
1434
+ // indistinguishable from dead to everything watching it.
1435
+ //
1436
+ // Once per session, unconditionally. A first version gated this behind a
1437
+ // 50 ms budget to avoid "wasting" loop turns on the ~15,700 refs that skip in
1438
+ // microseconds — then measured it: 15,727 unconditional yields cost 37.9 ms
1439
+ // total, 2.4 µs each, against a walk that takes over a minute. The budget
1440
+ // bought nothing and cost an arbitrary constant plus a clock read in the hot
1441
+ // path, so it is gone. One yield per iteration is deterministic and needs no
1442
+ // tuning.
1443
+ //
1444
+ // NOT THE WHOLE ANSWER, and worth being clear about: this makes the process
1445
+ // RESPONSIVE while it works, it does not make the work faster. The scan is
1446
+ // pure, CPU-bound and uses one of this machine's twelve cores. Moving it to
1447
+ // worker_threads is the real fix — it would keep the main thread completely
1448
+ // free AND parallelise across cores. That is a much larger change; yielding is
1449
+ // the correct minimal one, and it is what turns "the collector is dead" back
1450
+ // into "the collector is busy".
1451
+ let considered = 0;
1148
1452
  for (const ref of slice) {
1453
+ await new Promise((resolve) => setImmediate(resolve));
1454
+ // PROGRESS COUNTS SESSIONS CONSIDERED, not sessions shipped.
1455
+ //
1456
+ // Reporting `walked` (the shipped count) looked right and was useless: on an
1457
+ // already-synced corpus the ledger skips ~15,700 of 15,727 sessions, so the
1458
+ // report sat at "0 of 15,727 (0%)" for the whole walk and then vanished.
1459
+ // What a waiting user needs to know is how much of the corpus has been
1460
+ // LOOKED AT, which is this counter and matches the denominator.
1461
+ considered++;
1462
+ // complete is `considered === slice.length`, not a constant false.
1463
+ //
1464
+ // The post-loop report below sets complete:true, but it lands after the last
1465
+ // upload has gone out, so it only ships on the NEXT post — and a walk with
1466
+ // nothing new to upload never makes one. The server was therefore left
1467
+ // holding done === total with complete:false forever, and the UI read that
1468
+ // as "syncing 99%" until something else happened to sync.
1469
+ const walkDone = considered >= slice.length;
1470
+ reportWalkProgress({ done: considered, total: slice.length, startedAt: walkStartedAt, complete: walkDone });
1471
+ walkProgressForUpload = { done: considered, total: slice.length, complete: walkDone };
1149
1472
  if (trace && ++traceN % 500 === 0)
1150
1473
  trace(`walk ${traceN}/${slice.length} · at ${ref.prefixedId}`);
1151
1474
  localSessionIds.add(ref.prefixedId);
@@ -1293,8 +1616,32 @@ async function syncToTarget(cred, opts = {}) {
1293
1616
  if (upload.sessionMeta && sessionFileBytes(ref) <= FULL_BUILD_MAX_BYTES) {
1294
1617
  try {
1295
1618
  const count = { redactions: 0 };
1296
- await derivedBatch.add(collectDerived(ref, mtime, count));
1297
- redactions += count.redactions;
1619
+ // OFF THE MAIN THREAD. This is the heaviest work in the walk: replay,
1620
+ // outcome and turns each read the whole transcript and shell out to git,
1621
+ // measured at ~5 seconds per session against 78ms for the secret scan.
1622
+ // It was the entire remaining tail of a walk — 15,500 sessions skip in
1623
+ // seconds, then the couple of hundred that need rebuilding take minutes,
1624
+ // all of it on the thread that has to answer signals.
1625
+ //
1626
+ // Only an id crosses the boundary; the worker reads the transcript
1627
+ // itself. Null means the pool could not take it, and the inline path —
1628
+ // the code that shipped before this — runs instead.
1629
+ const offloaded = await scanPool().runDerived({
1630
+ prefixedId: ref.prefixedId, toolId: ref.toolId, mtime,
1631
+ maxRowBytes: MAX_DERIVED_ROW_BYTES,
1632
+ pack: lastServerPackSpec && scanPool().needsPack(lastServerPackSpec.version)
1633
+ ? { version: lastServerPackSpec.version, rules: lastServerPackSpec.rules }
1634
+ : undefined,
1635
+ packVersion: lastServerPackSpec?.version ?? '',
1636
+ });
1637
+ if (offloaded?.rows) {
1638
+ redactions += offloaded.redactions;
1639
+ await derivedBatch.add(offloaded.rows);
1640
+ }
1641
+ else {
1642
+ await derivedBatch.add(collectDerivedRows(ref, mtime, count, MAX_DERIVED_ROW_BYTES));
1643
+ redactions += count.redactions;
1644
+ }
1298
1645
  }
1299
1646
  catch (err) {
1300
1647
  // Derived is best-effort — the conversation still shipped — but it must
@@ -1495,6 +1842,12 @@ async function syncToTarget(cred, opts = {}) {
1495
1842
  }
1496
1843
  if (opts.prune)
1497
1844
  await post({ prune_empty_sessions: true });
1845
+ // The walk finished. Marking it complete is what stops every later reader
1846
+ // showing a frozen "8,400 of 15,724" forever — and it is also how a reader
1847
+ // tells a finished walk from a daemon that died mid-walk, which is the state
1848
+ // an in-progress value left behind means.
1849
+ reportWalkProgress({ done: slice.length, total: slice.length, startedAt: walkStartedAt, complete: true });
1850
+ walkProgressForUpload = { done: slice.length, total: slice.length, complete: true };
1498
1851
  return {
1499
1852
  uploaded: convBatch.sent + appendResults.uploaded, skipped, redactions,
1500
1853
  items: itemsBatch.sent, links: linksBatch.sent, findings: findingsBatch.sent, derived: derivedBatch.sent,
@@ -1580,18 +1933,53 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
1580
1933
  // is what stops a resume-truncated or mtime-touched session from being
1581
1934
  // fully rebuilt on every tick (the export/read still happens — it's how we
1582
1935
  // know — but everything expensive after it is skipped).
1936
+ // Build the container ONCE. The hash is needed before the shadow is
1937
+ // touched (a content match short-circuits the whole session), and
1938
+ // updateShadow used to build a second identical copy from the same
1939
+ // export — a wasted copy of every byte on a 47 MB transcript.
1940
+ let built;
1583
1941
  if (exp) {
1584
1942
  try {
1585
- srcHash = containerSrcHash(buildRawContainer(exp));
1943
+ built = buildRawContainer(exp);
1944
+ srcHash = containerSrcHash(built);
1586
1945
  }
1587
1946
  catch {
1947
+ built = undefined;
1588
1948
  srcHash = undefined;
1589
1949
  }
1590
1950
  if (srcHash && opts.priorContentHash && srcHash === opts.priorContentHash) {
1591
1951
  return { unchanged: true, srcHash };
1592
1952
  }
1953
+ // THE SIZE GUARD, MEASURED ON WHAT WE ACTUALLY HAVE.
1954
+ //
1955
+ // `oversized` above is decided from sessionFileBytes(), which returns 0
1956
+ // for any backend that is not append-only — OpenCode implements neither
1957
+ // fileSize() nor isAppendOnly(), because its sessions live in SQLite and
1958
+ // have no file at all. So for those the ceiling never fired and a huge
1959
+ // session took the FULL path: replay and computeOutcome each re-read the
1960
+ // whole thing, and a 353 MB session with 100 MB JSON lines OOMed the walk.
1961
+ //
1962
+ // The export has already happened by here, and it is bounded by the
1963
+ // session's real size. What this protects is everything AFTER it — the
1964
+ // scan, the redact, the git replay, the derived compute — which is where
1965
+ // the memory actually went. Measuring the container closes the guard for
1966
+ // every backend, including any whose fileSize() under-reports.
1967
+ const containerBytes = built
1968
+ ? built.files.reduce((n, f) => n + Buffer.byteLength(f.text), 0)
1969
+ : 0;
1970
+ if (containerBytes > FULL_BUILD_MAX_BYTES) {
1971
+ console.error(`[sync] ${ref.prefixedId} is ${(containerBytes / 1048576).toFixed(0)}MB `
1972
+ + `(over the ${(FULL_BUILD_MAX_BYTES / 1048576).toFixed(0)}MB ceiling) — shipping without `
1973
+ + 'the raw archive and derived data to keep the walk in memory');
1974
+ // Size and tool only. Knowing that customers hit this, and how big
1975
+ // their sessions get, is how the ceiling gets set from evidence rather
1976
+ // than from one developer's corpus.
1977
+ record({ kind: 'oversized_session', mb: Math.round(containerBytes / 1048576), tool: ref.toolId });
1978
+ includeRaw = false;
1979
+ includeMeta = false;
1980
+ }
1593
1981
  }
1594
- const shadow = updateShadow(ref.rawId, exp);
1982
+ const shadow = updateShadow(ref.rawId, exp, built);
1595
1983
  if (shadow.container) {
1596
1984
  fullestContainer = shadow.container;
1597
1985
  if (shadow.status === 'rewrite-merged' && shadow.recovered > 0) {
@@ -1705,11 +2093,42 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
1705
2093
  // applied to the raw text on the client so unredacted secrets stay local.
1706
2094
  const findings = [];
1707
2095
  let scanMs = 0;
2096
+ // OFF THE MAIN THREAD. Scan + redact + gzip + base64 are ~98% of the CPU this
2097
+ // function burns (measured: 31.2 s over 200 MB of real sessions) and all four
2098
+ // touch the same text, so one worker task carries the text in and returns only
2099
+ // small results — findings, a redaction count and the compressed archive. The
2100
+ // 30 MB string never comes back, which is what keeps the transfer at 1.8% of
2101
+ // the work moved.
2102
+ //
2103
+ // Null means "not offloaded" for any reason at all (pool disabled, no worker
2104
+ // bundle, worker died, task threw). Every use below falls back to the inline
2105
+ // code that shipped before this existed.
2106
+ let offloaded = null;
2107
+ if (container) {
2108
+ const pool = scanPool();
2109
+ if (pool.enabled) {
2110
+ const packVersion = lastServerPackSpec?.version ?? '';
2111
+ const sendPack = lastServerPackSpec !== null && pool.needsPack(packVersion);
2112
+ offloaded = await pool.run({
2113
+ files: container.files.map((f) => ({ name: f.name, text: f.text })),
2114
+ container: { v: 1, tool: container.tool, mtime: container.mtime, srcHash: container.srcHash },
2115
+ includeRaw,
2116
+ maxRawBytes: 8 * 1024 * 1024,
2117
+ pack: sendPack && lastServerPackSpec
2118
+ ? { version: packVersion, rules: lastServerPackSpec.rules }
2119
+ : undefined,
2120
+ packVersion,
2121
+ });
2122
+ if (offloaded && sendPack)
2123
+ pool.markPackSent(packVersion);
2124
+ }
2125
+ }
1708
2126
  if (container) {
1709
2127
  const t0 = performance.now();
1710
2128
  const rawText = container.files.map((f) => f.text).join('\n');
1711
2129
  try {
1712
- for (const f of scanTextForFindings(rawText)) {
2130
+ const builtin = offloaded ? offloaded.findings : scanTextForFindings(rawText);
2131
+ for (const f of builtin) {
1713
2132
  findings.push({ session_id: ref.prefixedId, detector: 'builtin', rule: f.rule, line: f.line, preview: f.preview });
1714
2133
  }
1715
2134
  }
@@ -1791,17 +2210,27 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
1791
2210
  let raw_b64;
1792
2211
  let raw_size;
1793
2212
  if (includeRaw && container) {
1794
- try {
1795
- const count2 = { redactions: 0 };
1796
- const redacted = mapContainerText(container, (t) => redactSecrets(t, { force: true, count: count2 }));
1797
- count.redactions += count2.redactions;
1798
- const { gz, size } = gzipContainer(redacted);
1799
- if (gz.length <= 8 * 1024 * 1024) {
1800
- raw_b64 = gz.toString('base64');
1801
- raw_size = size;
2213
+ if (offloaded) {
2214
+ // The worker already redacted, gzipped and encoded it. An absent rawB64
2215
+ // means the archive exceeded the 8 MB ceiling — the same decision the
2216
+ // inline path makes, taken in the worker so the bytes never travel.
2217
+ count.redactions += offloaded.redactions;
2218
+ raw_b64 = offloaded.rawB64;
2219
+ raw_size = offloaded.rawSize;
2220
+ }
2221
+ else {
2222
+ try {
2223
+ const count2 = { redactions: 0 };
2224
+ const redacted = mapContainerText(container, (t) => redactSecrets(t, { force: true, count: count2 }));
2225
+ count.redactions += count2.redactions;
2226
+ const { gz, size } = gzipContainer(redacted);
2227
+ if (gz.length <= 8 * 1024 * 1024) {
2228
+ raw_b64 = gz.toString('base64');
2229
+ raw_size = size;
2230
+ }
1802
2231
  }
2232
+ catch { /* raw is additive — conversation still ships */ }
1803
2233
  }
1804
- catch { /* raw is additive — conversation still ships */ }
1805
2234
  }
1806
2235
  const envTexts = envelope.messages.filter((m) => m.content?.trim());
1807
2236
  const redactedText = envTexts.map((m) => m.content).join('\n');
@@ -1933,90 +2362,6 @@ export async function buildConversationTail(ref, fromOffset, opts = {}) {
1933
2362
  findings,
1934
2363
  };
1935
2364
  }
1936
- /**
1937
- * Per-session derived payload: the four compute kinds the server's deep-dive
1938
- * routes read (diff/outcome/commits/markers) + the outcome-badge row. ALWAYS
1939
- * computed live from the transcript/git (no local compute/outcome cache is
1940
- * read or warmed — thin collector). Everything is deep-redacted — diffs carry
1941
- * file content.
1942
- */
1943
- function collectDerived(ref, mtime, count) {
1944
- const id = ref.prefixedId;
1945
- const compute = [];
1946
- let fullOutcome = null;
1947
- // One read of this transcript instead of four. replay, computeOutcome (twice
1948
- // internally) and extractTurns each re-read the whole file; the scope holds
1949
- // exactly one session's text and drops it on the way out.
1950
- return withSessionReadCache(() => {
1951
- for (const kind of COMPUTE_KINDS) {
1952
- let data = null;
1953
- try {
1954
- if (kind === 'diff') {
1955
- const replay = replaySessionAny(id);
1956
- if (replay.found)
1957
- data = replay;
1958
- }
1959
- else if (kind === 'outcome') {
1960
- fullOutcome = computeOutcome(id);
1961
- if (fullOutcome.found)
1962
- data = fullOutcome;
1963
- }
1964
- else if (kind === 'commits') {
1965
- // computeOutcome already ran git for the session window — reuse.
1966
- if (!fullOutcome)
1967
- fullOutcome = computeOutcome(id);
1968
- if (fullOutcome.found)
1969
- data = fullOutcome.commits;
1970
- }
1971
- else if (kind === 'markers') {
1972
- // Analysis-only use of the turns extractor (R4) — markers need
1973
- // per-prompt line/ts, not render fidelity.
1974
- const turns = extractTurnsAny(id, { maxTurns: 50_000 });
1975
- const prompts = turns.turns
1976
- .filter((t) => t.kind === 'user' && t.text)
1977
- .map((t) => ({ line: t.line, ts: t.ts, tsIso: t.tsIso, ...markPrompt(t.text) }));
1978
- data = { sessionId: id, prompts, summary: summarizeMarkers(prompts) };
1979
- }
1980
- }
1981
- catch {
1982
- data = null;
1983
- }
1984
- if (data === null)
1985
- continue;
1986
- const redacted = redactDeep(data, count);
1987
- if (JSON.stringify(redacted).length > MAX_DERIVED_ROW_BYTES)
1988
- continue;
1989
- compute.push({ kind, mtime, data: redacted });
1990
- }
1991
- // Outcome-badge row (session_outcome_cache) — what the list badges read.
1992
- // Computed live from the outcome we already ran above.
1993
- let outcomeRow = null;
1994
- if (fullOutcome?.found) {
1995
- outcomeRow = {
1996
- sessionId: id,
1997
- tool: ref.toolId,
1998
- status: fullOutcome.status,
1999
- reason: fullOutcome.reason,
2000
- fileMtime: mtime,
2001
- fileSize: 0,
2002
- contentHash: '',
2003
- fileCount: fullOutcome.fileCount,
2004
- linesAdded: fullOutcome.totalLinesAdded,
2005
- linesRemoved: fullOutcome.totalLinesRemoved,
2006
- commits: fullOutcome.commits?.totalCommits ?? 0,
2007
- isFull: true,
2008
- classifiedAt: Date.now(),
2009
- lastScannedOffset: 0,
2010
- };
2011
- }
2012
- return {
2013
- session_id: id,
2014
- mtime,
2015
- compute,
2016
- outcome_row: outcomeRow ? redactDeep({ ...outcomeRow, sessionId: undefined }, count) : null,
2017
- };
2018
- });
2019
- }
2020
2365
  export function isSyncSkip(r) {
2021
2366
  return typeof r.skipped === 'string';
2022
2367
  }