chat-recall 0.5.5 → 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 (90) hide show
  1. package/README.md +332 -0
  2. package/dist/cli.js +11473 -8310
  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-diagnostics.d.ts.map +1 -1
  24. package/dist/mcp-diagnostics.js +10 -8
  25. package/dist/mcp-diagnostics.js.map +1 -1
  26. package/dist/mcp.d.ts.map +1 -1
  27. package/dist/mcp.js +5763 -3045
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/project-tasks.d.ts.map +1 -1
  30. package/dist/project-tasks.js +13 -8
  31. package/dist/project-tasks.js.map +1 -1
  32. package/dist/redact-deep.d.ts +4 -0
  33. package/dist/redact-deep.d.ts.map +1 -0
  34. package/dist/redact-deep.js +26 -0
  35. package/dist/redact-deep.js.map +1 -0
  36. package/dist/scan-pool.d.ts +56 -0
  37. package/dist/scan-pool.d.ts.map +1 -0
  38. package/dist/scan-pool.js +245 -0
  39. package/dist/scan-pool.js.map +1 -0
  40. package/dist/scan-worker.d.ts +78 -0
  41. package/dist/scan-worker.d.ts.map +1 -0
  42. package/dist/scan-worker.js +11175 -0
  43. package/dist/scan-worker.js.map +1 -0
  44. package/dist/self-restart.d.ts +20 -2
  45. package/dist/self-restart.d.ts.map +1 -1
  46. package/dist/self-restart.js +16 -6
  47. package/dist/self-restart.js.map +1 -1
  48. package/dist/server-probe.d.ts +76 -0
  49. package/dist/server-probe.d.ts.map +1 -0
  50. package/dist/server-probe.js +106 -0
  51. package/dist/server-probe.js.map +1 -0
  52. package/dist/service-installer.d.ts +1 -1
  53. package/dist/service-installer.d.ts.map +1 -1
  54. package/dist/service-installer.js +20 -6
  55. package/dist/service-installer.js.map +1 -1
  56. package/dist/sync-client.d.ts +25 -0
  57. package/dist/sync-client.d.ts.map +1 -1
  58. package/dist/sync-client.js +590 -176
  59. package/dist/sync-client.js.map +1 -1
  60. package/dist/sync-ledger.d.ts +22 -0
  61. package/dist/sync-ledger.d.ts.map +1 -1
  62. package/dist/sync-ledger.js +108 -1
  63. package/dist/sync-ledger.js.map +1 -1
  64. package/dist/target-breaker.d.ts +61 -0
  65. package/dist/target-breaker.d.ts.map +1 -0
  66. package/dist/target-breaker.js +85 -0
  67. package/dist/target-breaker.js.map +1 -0
  68. package/dist/telemetry-consent.d.ts +31 -0
  69. package/dist/telemetry-consent.d.ts.map +1 -0
  70. package/dist/telemetry-consent.js +115 -0
  71. package/dist/telemetry-consent.js.map +1 -0
  72. package/dist/telemetry.d.ts +22 -0
  73. package/dist/telemetry.d.ts.map +1 -0
  74. package/dist/telemetry.js +125 -0
  75. package/dist/telemetry.js.map +1 -0
  76. package/dist/tick-queue.d.ts +67 -0
  77. package/dist/tick-queue.d.ts.map +1 -0
  78. package/dist/tick-queue.js +104 -0
  79. package/dist/tick-queue.js.map +1 -0
  80. package/dist/transport-safety.d.ts +43 -0
  81. package/dist/transport-safety.d.ts.map +1 -0
  82. package/dist/transport-safety.js +100 -0
  83. package/dist/transport-safety.js.map +1 -0
  84. package/dist/verify-repair.js +1 -1
  85. package/dist/verify-repair.js.map +1 -1
  86. package/dist/watch.js +8887 -6028
  87. package/hooks/chat_recall_escalate_hook.sh +28 -2
  88. package/hooks/chat_recall_save_hook.sh +17 -11
  89. package/hooks/chat_recall_wakeup_hook.sh +27 -1
  90. package/package.json +27 -4
@@ -31,6 +31,7 @@
31
31
  * server owns them.
32
32
  */
33
33
  import { join, dirname, basename } from 'node:path';
34
+ import { envCredentials } from '@chat-recall/engine/core/credentials-env.js';
34
35
  import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, chmodSync, unlinkSync, readdirSync, statSync } from 'node:fs';
35
36
  import { tmpdir, hostname } from 'node:os';
36
37
  import { createHash } from 'node:crypto';
@@ -44,13 +45,11 @@ import { dropFuzzyFindings } from '@chat-recall/engine/core/secret-precision.js'
44
45
  import { loadSettings, saveSettings, isProjectSyncable } from '@chat-recall/engine/core/settings.js';
45
46
  import { getDataDir } from '@chat-recall/engine/core/paths.js';
46
47
  import { listAvailableBackends } from '@chat-recall/engine/core/tool-backend.js';
47
- import { extractTurnsAny, replaySessionAny } from '@chat-recall/engine/core/session-multi-tool.js';
48
48
  import { parseTranscript, trimTranscriptForSync, TRANSCRIPT_VERSION, gzipContainer, mapContainerText, buildRawContainer, containerSrcHash, parseTranscriptFromContainer, updateShadow } from '@chat-recall/engine/transcript/index.js';
49
49
  import { parseClaudeTranscriptText } from '@chat-recall/engine/transcript/claude.js';
50
50
  import { parseGeminiTranscriptText } from '@chat-recall/engine/transcript/gemini.js';
51
51
  import { parseCodexTranscriptText } from '@chat-recall/engine/transcript/codex.js';
52
52
  import { getBackendForId, getBackend } from '@chat-recall/engine/core/tool-backend.js';
53
- import { computeOutcome } from '@chat-recall/engine/core/session-outcome.js';
54
53
  import { fileURLToPath } from 'node:url';
55
54
  let ownVersion = '0.0.0';
56
55
  try {
@@ -65,16 +64,70 @@ catch {
65
64
  }
66
65
  catch { }
67
66
  }
68
- import { markPrompt, summarizeMarkers } from '@chat-recall/engine/core/session-sentiment.js';
69
67
  import { parseSessionFile, readCwdFromJsonl } from '@chat-recall/engine/parsers/session.js';
70
68
  import { resolveProjectId } from '@chat-recall/engine/core/project-resolver.js';
71
69
  import { extractEntities } from '@chat-recall/engine/core/entity-extractor.js';
72
70
  import { buildSourceRegistry } from '@chat-recall/engine/parsers/all-sources.js';
73
- import { getSyncedRows, markSynced, getLedgerData, persistLedgerData, fieldNeedsScan, markFieldCoverage, fieldNeedsFullPass, markFieldFullPassDone, syncMode, markFullResync, loadItemVersions, saveItemVersions } from './sync-ledger.js';
71
+ import { getSyncedRows, markSynced, getLedgerData, persistLedgerData, fieldNeedsScan, markFieldCoverage, fieldNeedsFullPass, markFieldFullPassDone, syncMode, markFullResync, loadItemVersions, saveItemVersions, flushLedger, pruneLedgerTargets } from './sync-ledger.js';
74
72
  import { extractorVersionForTool, extractorVersionForId, toolOfId, EXTRACTOR_VERSION } from '@chat-recall/engine/core/extractor-version.js';
75
73
  import { SYNC_FIELDS } from '@chat-recall/engine/core/sync-fields.js';
76
74
  import { acquireIndexLock } from '@chat-recall/engine/core/index-lock.js';
77
75
  import { fetchWithTimeout } from './http.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';
78
131
  import '@chat-recall/engine/core/backends/index.js'; // register the tool backends
79
132
  // Lives under the data dir so CHAT_RECALL_DATA_DIR isolates credentials the
80
133
  // same way it isolates the index (tests, multi-profile setups).
@@ -86,6 +139,10 @@ const credPath = () => join(getDataDir(), 'credentials.json');
86
139
  * form still readable.
87
140
  */
88
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);
89
146
  mkdirSync(dirname(credPath()), { recursive: true });
90
147
  const targets = loadAllCredentials().filter((t) => t.serverUrl !== c.serverUrl);
91
148
  // Newest login FIRST: single-target consumers (loadCredentials → targets[0],
@@ -107,6 +164,12 @@ export function saveCredentials(c) {
107
164
  catch { /* settings unwritable — manual `chat-recall sync` still works */ }
108
165
  }
109
166
  export function loadAllCredentials() {
167
+ // A container, a CI job or a headless daemon has no credentials.json. When
168
+ // the environment carries an explicit token it IS the login — see
169
+ // credentials-env.ts for why the file is not consulted in that case.
170
+ const fromEnv = envCredentials();
171
+ if (fromEnv)
172
+ return [{ serverUrl: fromEnv.serverUrl, token: fromEnv.token }];
110
173
  try {
111
174
  const parsed = JSON.parse(readFileSync(credPath(), 'utf-8'));
112
175
  // token may be '' for a no-auth local self-host server (AUTH_PROVIDER=none) —
@@ -156,6 +219,10 @@ async function fetchTenantSecurityConfig(cred) {
156
219
  const packSpec = body.pack?.rules
157
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 })) }
158
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 };
159
226
  const pack = installServerRulePack(packSpec);
160
227
  rulePackVersion = serverRulePackVersion();
161
228
  for (const r of pack.rejected) {
@@ -173,6 +240,19 @@ async function fetchTenantSecurityConfig(cred) {
173
240
  const body = await settingsRes.json().catch(() => ({}));
174
241
  if (typeof body.verifySecrets === 'boolean')
175
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
+ }
176
256
  }
177
257
  const config = { tenantRules, verifySecrets, rulePackVersion };
178
258
  _securityConfigCache.set(base, { fetchedAt: Date.now(), config });
@@ -288,22 +368,6 @@ export function removeCredentials(serverUrl) {
288
368
  /** Tokenize an absolute project path so the server can group by project
289
369
  * without learning the developer's filesystem layout. */
290
370
  const hashPath = (p) => (p ? 'p_' + createHash('sha256').update(p).digest('hex').slice(0, 12) : '');
291
- /** Redact every string anywhere inside a JSON-serializable value. Used for
292
- * derived payloads (diffs carry file content; outcomes carry prompt text)
293
- * where field-by-field redaction would be fragile. */
294
- function redactDeep(v, count) {
295
- if (typeof v === 'string')
296
- return redactSecrets(v, { force: true, count });
297
- if (Array.isArray(v))
298
- return v.map((x) => redactDeep(x, count));
299
- if (v && typeof v === 'object') {
300
- const o = {};
301
- for (const [k, val] of Object.entries(v))
302
- o[k] = redactDeep(val, count);
303
- return o;
304
- }
305
- return v;
306
- }
307
371
  /** compute_cache kinds shipped to the server. Must stay in step with the
308
372
  * kinds the server's conversation routes read (heavyCacheGet keys). */
309
373
  const COMPUTE_KINDS = ['diff', 'outcome', 'commits', 'markers'];
@@ -316,35 +380,44 @@ const MAX_DERIVED_ROW_BYTES = 2 * 1024 * 1024;
316
380
  * is no shared-tenant or rate-limiter to be polite to, so they get a higher
317
381
  * in-flight cap and zero inter-batch sleep. Remote/SaaS hosts stay gentler.
318
382
  */
319
- function isLocalHost(serverUrl) {
320
- let host = '';
321
- try {
322
- host = new URL(serverUrl).hostname.toLowerCase();
323
- }
324
- catch {
325
- return false;
326
- }
327
- if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0')
328
- return true;
329
- if (host.endsWith('.local') || host.endsWith('.lan'))
330
- return true;
331
- if (/^10\./.test(host))
332
- return true; // 10.0.0.0/8
333
- if (/^192\.168\./.test(host))
334
- return true; // 192.168.0.0/16
335
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
336
- return true; // 172.16.0.0/12
337
- return false;
338
- }
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;
339
391
  export async function syncSessions(opts = {}) {
340
392
  const targets = loadAllCredentials();
341
393
  if (targets.length === 0)
342
394
  throw new Error('Not logged in — run `chat-recall login <server-url> --token <token>`');
395
+ // Once per walk: forget servers we no longer sync to, so their rows stop
396
+ // riding along in every write of this single-file ledger.
397
+ try {
398
+ pruneLedgerTargets(targets.map((t) => t.serverUrl));
399
+ }
400
+ catch { /* never block a sync on housekeeping */ }
343
401
  let agg = null;
344
402
  const errors = [];
403
+ const skippedTargets = [];
345
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
+ }
346
418
  try {
347
419
  const r = await syncToTarget(cred, opts);
420
+ noteTargetSuccess(cred.serverUrl);
348
421
  agg = agg ? {
349
422
  uploaded: agg.uploaded + r.uploaded, skipped: agg.skipped + r.skipped, redactions: agg.redactions + r.redactions,
350
423
  items: agg.items + r.items, links: agg.links + r.links, findings: agg.findings + r.findings,
@@ -353,9 +426,59 @@ export async function syncSessions(opts = {}) {
353
426
  } : r;
354
427
  }
355
428
  catch (e) {
356
- 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
+ });
357
446
  }
358
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
+ }
455
+ // Ledger writes are coalesced during the walk (see persist() in sync-ledger).
456
+ // This is the durability point: everything acked above reaches disk before we
457
+ // report success, so a crash after this cannot re-ship what the server has.
458
+ // In the finally, because a partial walk's acks are still worth keeping.
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(() => { });
359
482
  if (!agg)
360
483
  throw new Error(`sync failed for all targets:\n ${errors.join('\n ')}`);
361
484
  if (errors.length > 0)
@@ -582,7 +705,95 @@ export function batchScanExternal(items, opts = {}) {
582
705
  }
583
706
  return { findings, scanned, slices, scanMs };
584
707
  }
708
+ /**
709
+ * Server API version check, cached per target for an hour.
710
+ *
711
+ * Only SUCCESS is cached. A server that is down, or too old, is re-checked on
712
+ * the next tick — caching a failure would turn a thirty-second outage into an
713
+ * hour of refusing to sync.
714
+ */
715
+ const CAPS_TTL_MS = 60 * 60_000;
716
+ const capsOk = new Map(); // serverUrl -> epoch ms verified
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
+ }
749
+ async function verifyServerApi(serverUrl, minApi) {
750
+ const base = serverUrl.replace(/\/$/, '');
751
+ const seen = capsOk.get(base);
752
+ if (seen !== undefined && Date.now() - seen < CAPS_TTL_MS)
753
+ return true;
754
+ const caps = await fetchWithTimeout(`${base}/api/capabilities`).then((r) => r.json());
755
+ if ((caps.apiVersion ?? 0) < minApi) {
756
+ throw new Error(`server too old: apiVersion ${caps.apiVersion ?? 'none'} < required ${minApi} — update the server image before syncing`);
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);
770
+ capsOk.set(base, Date.now());
771
+ return true;
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
+ */
585
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);
586
797
  const sync = loadSettings().sync;
587
798
  // Exclusions = local settings ∪ server-side tenant config (dashboard-edited).
588
799
  const serverCfg = await fetchTenantSyncConfig(cred);
@@ -618,16 +829,18 @@ async function syncToTarget(cred, opts = {}) {
618
829
  // server ignores payload fields it doesn't know — which looks like
619
830
  // success and produces gutted data (lived experience, June 2026).
620
831
  const MIN_SERVER_API = 2;
621
- try {
622
- const caps = await fetchWithTimeout(`${cred.serverUrl.replace(/\/$/, '')}/api/capabilities`).then((r) => r.json());
623
- if ((caps.apiVersion ?? 0) < MIN_SERVER_API) {
624
- throw new Error(`server too old: apiVersion ${caps.apiVersion ?? 'none'} < required ${MIN_SERVER_API} — update the server image before syncing`);
625
- }
626
- }
627
- catch (e) {
628
- if (e instanceof Error && e.message.startsWith('server too old'))
629
- throw e;
630
- throw new Error(`cannot verify server version (${e instanceof Error ? e.message : e}) — refusing to sync blind`);
832
+ {
833
+ // Cached for an hour per target. This ran on EVERY sync — every file-change
834
+ // debounce, every heartbeat, per target — to re-learn a number that changes
835
+ // only when the server is redeployed. The check itself stays: refusing to
836
+ // sync blind is the point, and an old server silently ignoring payload fields
837
+ // is how gutted data shipped in June 2026. Only the frequency changes.
838
+ //
839
+ // A FAILURE IS NEVER CACHED. An unreachable server must be retried on the
840
+ // next tick, not written off for an hour.
841
+ const ok = await verifyServerApi(cred.serverUrl, MIN_SERVER_API);
842
+ if (!ok)
843
+ throw new Error(`cannot verify server version — refusing to sync blind`);
631
844
  }
632
845
  // Fetch tenant-specific secret-scan configuration from the server. Rules are
633
846
  // configured in the dashboard and must be applied to raw session text on the
@@ -677,7 +890,21 @@ async function syncToTarget(cred, opts = {}) {
677
890
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
678
891
  const explicitThrottle = typeof opts.throttleMs === 'number' && opts.throttleMs > 0;
679
892
  const throttleMs = explicitThrottle ? opts.throttleMs : 0;
680
- 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);
681
908
  // Per-attempt upload timeout. WITHOUT this, a half-open/slow connection makes
682
909
  // `fetch` hang FOREVER (Node fetch has no default timeout) — observed as a
683
910
  // sync stuck for 20-40min at idle CPU with zero progress. With it, a hung
@@ -686,8 +913,41 @@ async function syncToTarget(cred, opts = {}) {
686
913
  // Single POST with retry. Retryable: network/abort errors, HTTP 429 (obeying
687
914
  // Retry-After — the server's own rate signal), and 5xx. Non-429 4xx (bad
688
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);
689
932
  const post = async (body) => {
690
- 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
+ })();
691
951
  const RETRY_DELAYS_MS = [2000, 8000, 30000];
692
952
  for (let attempt = 0;; attempt++) {
693
953
  const ac = new AbortController();
@@ -697,14 +957,38 @@ async function syncToTarget(cred, opts = {}) {
697
957
  method: 'POST',
698
958
  // No token ⇒ no auth header: a no-auth local server (AUTH_PROVIDER=none)
699
959
  // accepts it as the single 'default' tenant.
700
- headers: cred.token
701
- ? { 'content-type': 'application/json', authorization: `Bearer ${cred.token}` }
702
- : { 'content-type': 'application/json' },
960
+ headers: {
961
+ 'content-type': 'application/json',
962
+ ...(gzipBody ? { 'content-encoding': 'gzip' } : {}),
963
+ ...(cred.token ? { authorization: `Bearer ${cred.token}` } : {}),
964
+ },
703
965
  body: payload,
704
966
  signal: ac.signal,
705
967
  });
706
968
  if (res.ok) {
707
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
+ }
708
992
  if (body.cli && body.cli.version) {
709
993
  const { planAutoUpdate, runAutoUpdate } = await import('./auto-update.js');
710
994
  const authHeaders = cred.token ? { authorization: `Bearer ${cred.token}` } : {};
@@ -733,10 +1017,14 @@ async function syncToTarget(cred, opts = {}) {
733
1017
  try {
734
1018
  const body = JSON.parse(text);
735
1019
  detail = body.error ?? text;
1020
+ // `detail` carries the part that stops this reading as breakage —
1021
+ // what still works, and the one command that restores the rest.
1022
+ if (body.detail)
1023
+ detail += `\n ${body.detail}`;
736
1024
  if (body.resetsAt)
737
1025
  detail += ` (resets ${new Date(body.resetsAt).toISOString().slice(0, 10)})`;
738
1026
  if (body.upgradeUrl)
739
- detail += ` — ${body.upgradeUrl}`;
1027
+ detail += `\n ${body.upgradeUrl}`;
740
1028
  }
741
1029
  catch { /* non-JSON 402 — print as-is */ }
742
1030
  throw new Error(`sync paused by the server: ${detail}`);
@@ -776,15 +1064,29 @@ async function syncToTarget(cred, opts = {}) {
776
1064
  // whole sync (matching the old serial behaviour).
777
1065
  const inflight = new Set();
778
1066
  let poolError = null;
779
- const submit = async (body, after) => {
780
- 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)) {
781
1081
  await Promise.race(inflight);
1082
+ }
782
1083
  if (poolError)
783
1084
  throw poolError;
1085
+ inflightBytes += bytesHint;
784
1086
  let task;
785
1087
  task = (async () => { await post(body); after(); })()
786
1088
  .catch((e) => { poolError = poolError ?? e; })
787
- .finally(() => { inflight.delete(task); });
1089
+ .finally(() => { inflight.delete(task); inflightBytes -= bytesHint; });
788
1090
  inflight.add(task);
789
1091
  };
790
1092
  const drainInflight = async () => {
@@ -813,7 +1115,12 @@ async function syncToTarget(cred, opts = {}) {
813
1115
  return {
814
1116
  sent: 0,
815
1117
  async add(row) {
816
- 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));
817
1124
  if (batch.length > 0 && (batchBytes + size > MAX_BATCH_BYTES || batch.length >= maxItems))
818
1125
  await this.drain();
819
1126
  batch.push(row);
@@ -823,11 +1130,14 @@ async function syncToTarget(cred, opts = {}) {
823
1130
  if (batch.length === 0)
824
1131
  return;
825
1132
  const rows = flatten ? batch.flat() : batch;
1133
+ const bytes = batchBytes;
826
1134
  batch = [];
827
1135
  batchBytes = 0;
828
1136
  // Hand off to the concurrency pool; `sent`/onFlush (e.g. markSynced)
829
- // fire only after the server acks THIS batch, same as before.
830
- 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);
831
1141
  },
832
1142
  };
833
1143
  };
@@ -954,29 +1264,50 @@ async function syncToTarget(cred, opts = {}) {
954
1264
  // and redaction of everything shipped is unconditional.
955
1265
  const scanSecrets = isSecretScannerAvailable() && upload.findings && process.env.CHAT_RECALL_SCAN_SECRETS !== '0';
956
1266
  const verifySecretsScan = verifySecrets;
957
- // ── Live KG accumulator. Triples are extracted on the spot from each
958
- // item's redacted text (no local knowledge-graph DB is read). Dedupe by
1267
+ let skipped = 0, redactions = 0, walked = 0;
1268
+ // ── Live KG accumulator. Triples are extracted on the spot from each item's
1269
+ // redacted text (no local knowledge-graph DB is read). Dedupe by
959
1270
  // subject|predicate|object|valid_from; entity types come from `is_a`
960
1271
  // triples seen in the same pass, defaulting to 'unknown' otherwise.
1272
+ //
1273
+ // TRIPLES SHIP IMMEDIATELY. They used to pile into an array that was drained
1274
+ // only after the ENTIRE walk — on this developer's box that is 15,700
1275
+ // sessions, so the array grew for the whole run and every row stayed live and
1276
+ // reachable. A full Mark-Compact freed 0 MB at 1602 MB before the daemon
1277
+ // aborted, which is the signature of retention, not of transient copies:
1278
+ // nothing transient survives a mark-compact. Handing each triple to the
1279
+ // batcher as it is produced bounds the resident set to one 4 MB window.
1280
+ //
1281
+ // Two small structures still span the walk, and both are bounded by DISTINCT
1282
+ // entity count rather than by session count:
1283
+ // kgSeen — dedup. Stores a 16-hex digest, because the old key
1284
+ // repeated all three long strings in a fourth string.
1285
+ // kgEntityNames — the names to emit entities for at the end. This is the
1286
+ // only reason the row array was ever kept; a Set of names
1287
+ // it already references costs a pointer, not a row.
961
1288
  const kgSeen = new Set();
962
1289
  const kgEntityType = new Map();
963
- const kgRows = [];
964
- const accumulateKg = (triples, sourceSession) => {
1290
+ const kgEntityNames = new Set();
1291
+ const kgKey = (subject, predicate, object, validFrom) => createHash('sha1').update(`${subject}|${predicate}|${object}|${validFrom}`).digest('hex').slice(0, 16);
1292
+ const accumulateKg = async (triples, sourceSession) => {
965
1293
  for (const t of triples) {
966
- const key = `${t.subject}|${t.predicate}|${t.object}|${t.validFrom ?? ''}`;
1294
+ const key = kgKey(t.subject, t.predicate, t.object, t.validFrom ?? '');
967
1295
  if (kgSeen.has(key))
968
1296
  continue;
969
1297
  kgSeen.add(key);
970
1298
  if (t.predicate === 'is_a' && !kgEntityType.has(t.subject))
971
1299
  kgEntityType.set(t.subject, t.object);
972
- kgRows.push({
1300
+ kgEntityNames.add(t.subject);
1301
+ kgEntityNames.add(t.object);
1302
+ const count = { redactions: 0 };
1303
+ await kgTripleBatch.add(redactDeep({
973
1304
  subject: t.subject, predicate: t.predicate, object: t.object,
974
1305
  valid_from: t.validFrom ?? null, valid_to: null,
975
1306
  confidence: t.confidence ?? 1.0, source_session: sourceSession,
976
- });
1307
+ }, count));
1308
+ redactions += count.redactions;
977
1309
  }
978
1310
  };
979
- let skipped = 0, redactions = 0, walked = 0;
980
1311
  let scanMs = 0, scanned = 0; // secret-scan metrics
981
1312
  const slice = refs.slice(0, opts.limit ?? refs.length);
982
1313
  // CHAT_RECALL_SYNC_TRACE=1: phase + progress markers with heap usage, to
@@ -1076,8 +1407,68 @@ async function syncToTarget(cred, opts = {}) {
1076
1407
  trace?.(`batch secret scan done: ${scanned} session(s) in ${batch.slices} slice(s)`);
1077
1408
  }
1078
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 };
1079
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;
1080
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 };
1081
1472
  if (trace && ++traceN % 500 === 0)
1082
1473
  trace(`walk ${traceN}/${slice.length} · at ${ref.prefixedId}`);
1083
1474
  localSessionIds.add(ref.prefixedId);
@@ -1214,7 +1605,7 @@ async function syncToTarget(cred, opts = {}) {
1214
1605
  // Knowledge graph: extract triples live from the redacted conversation
1215
1606
  // text (no local KG DB is read). Accumulated + deduped, flushed at the end.
1216
1607
  if (upload.sessionMeta) {
1217
- accumulateKg(extractEntities(built.kgText, { projectPath: built.projectPath, sourceType: 'session', sessionId: ref.prefixedId, validFrom: ref.mtime ? new Date(ref.mtime).toISOString().slice(0, 10) : undefined }), ref.prefixedId);
1608
+ await accumulateKg(extractEntities(built.kgText, { projectPath: built.projectPath, sourceType: 'session', sessionId: ref.prefixedId, validFrom: ref.mtime ? new Date(ref.mtime).toISOString().slice(0, 10) : undefined }), ref.prefixedId);
1218
1609
  }
1219
1610
  // Derived data — the CLI has the FS/git/transcript, the server doesn't.
1220
1611
  // Always computed live (diff/outcome/commits/markers + badge row); no
@@ -1225,8 +1616,32 @@ async function syncToTarget(cred, opts = {}) {
1225
1616
  if (upload.sessionMeta && sessionFileBytes(ref) <= FULL_BUILD_MAX_BYTES) {
1226
1617
  try {
1227
1618
  const count = { redactions: 0 };
1228
- await derivedBatch.add(collectDerived(ref, mtime, count));
1229
- 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
+ }
1230
1645
  }
1231
1646
  catch (err) {
1232
1647
  // Derived is best-effort — the conversation still shipped — but it must
@@ -1326,7 +1741,7 @@ async function syncToTarget(cred, opts = {}) {
1326
1741
  });
1327
1742
  // KG: extract triples live from this item's redacted chunk text.
1328
1743
  if (upload.sessionMeta && redactedChunks.length > 0) {
1329
- accumulateKg(extractEntities(redactedChunks.map((c) => c.text).join('\n'), { projectPath: item.projectPath || '', sourceType: item.sourceType, validFrom: item.mtime ? new Date(Number(item.mtime)).toISOString().slice(0, 10) : undefined }), null);
1744
+ await accumulateKg(extractEntities(redactedChunks.map((c) => c.text).join('\n'), { projectPath: item.projectPath || '', sourceType: item.sourceType, validFrom: item.mtime ? new Date(Number(item.mtime)).toISOString().slice(0, 10) : undefined }), null);
1330
1745
  }
1331
1746
  try {
1332
1747
  for (const l of await source.extractLinks(item)) {
@@ -1355,16 +1770,12 @@ async function syncToTarget(cred, opts = {}) {
1355
1770
  // also the incremental-correct set: no manual-fact replay, no local KG
1356
1771
  // DB). Entities are derived from the triple subjects/objects, typed from
1357
1772
  // any `is_a` triple seen in the same pass.
1358
- if (upload.sessionMeta && kgRows.length > 0) {
1359
- const entityNames = new Set();
1360
- for (const t of kgRows) {
1361
- const count = { redactions: 0 };
1362
- await kgTripleBatch.add(redactDeep(t, count));
1363
- redactions += count.redactions;
1364
- entityNames.add(t.subject);
1365
- entityNames.add(t.object);
1366
- }
1367
- for (const name of entityNames) {
1773
+ // Triples already shipped inside accumulateKg (see above). Entities cannot:
1774
+ // a name's type comes from an `is_a` triple that may appear in any later
1775
+ // session, so the type is only known once the walk is over. The names are
1776
+ // held as a Set, bounded by distinct entities rather than by session count.
1777
+ if (upload.sessionMeta && kgEntityNames.size > 0) {
1778
+ for (const name of kgEntityNames) {
1368
1779
  const count = { redactions: 0 };
1369
1780
  await kgEntityBatch.add(redactDeep({ name, type: kgEntityType.get(name) ?? 'unknown', properties: {} }, count));
1370
1781
  redactions += count.redactions;
@@ -1431,6 +1842,12 @@ async function syncToTarget(cred, opts = {}) {
1431
1842
  }
1432
1843
  if (opts.prune)
1433
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 };
1434
1851
  return {
1435
1852
  uploaded: convBatch.sent + appendResults.uploaded, skipped, redactions,
1436
1853
  items: itemsBatch.sent, links: linksBatch.sent, findings: findingsBatch.sent, derived: derivedBatch.sent,
@@ -1516,18 +1933,53 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
1516
1933
  // is what stops a resume-truncated or mtime-touched session from being
1517
1934
  // fully rebuilt on every tick (the export/read still happens — it's how we
1518
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;
1519
1941
  if (exp) {
1520
1942
  try {
1521
- srcHash = containerSrcHash(buildRawContainer(exp));
1943
+ built = buildRawContainer(exp);
1944
+ srcHash = containerSrcHash(built);
1522
1945
  }
1523
1946
  catch {
1947
+ built = undefined;
1524
1948
  srcHash = undefined;
1525
1949
  }
1526
1950
  if (srcHash && opts.priorContentHash && srcHash === opts.priorContentHash) {
1527
1951
  return { unchanged: true, srcHash };
1528
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
+ }
1529
1981
  }
1530
- const shadow = updateShadow(ref.rawId, exp);
1982
+ const shadow = updateShadow(ref.rawId, exp, built);
1531
1983
  if (shadow.container) {
1532
1984
  fullestContainer = shadow.container;
1533
1985
  if (shadow.status === 'rewrite-merged' && shadow.recovered > 0) {
@@ -1641,11 +2093,42 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
1641
2093
  // applied to the raw text on the client so unredacted secrets stay local.
1642
2094
  const findings = [];
1643
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
+ }
1644
2126
  if (container) {
1645
2127
  const t0 = performance.now();
1646
2128
  const rawText = container.files.map((f) => f.text).join('\n');
1647
2129
  try {
1648
- for (const f of scanTextForFindings(rawText)) {
2130
+ const builtin = offloaded ? offloaded.findings : scanTextForFindings(rawText);
2131
+ for (const f of builtin) {
1649
2132
  findings.push({ session_id: ref.prefixedId, detector: 'builtin', rule: f.rule, line: f.line, preview: f.preview });
1650
2133
  }
1651
2134
  }
@@ -1727,17 +2210,27 @@ export async function buildConversationSync(ref, mtime, opts = {}) {
1727
2210
  let raw_b64;
1728
2211
  let raw_size;
1729
2212
  if (includeRaw && container) {
1730
- try {
1731
- const count2 = { redactions: 0 };
1732
- const redacted = mapContainerText(container, (t) => redactSecrets(t, { force: true, count: count2 }));
1733
- count.redactions += count2.redactions;
1734
- const { gz, size } = gzipContainer(redacted);
1735
- if (gz.length <= 8 * 1024 * 1024) {
1736
- raw_b64 = gz.toString('base64');
1737
- 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
+ }
1738
2231
  }
2232
+ catch { /* raw is additive — conversation still ships */ }
1739
2233
  }
1740
- catch { /* raw is additive — conversation still ships */ }
1741
2234
  }
1742
2235
  const envTexts = envelope.messages.filter((m) => m.content?.trim());
1743
2236
  const redactedText = envTexts.map((m) => m.content).join('\n');
@@ -1869,85 +2362,6 @@ export async function buildConversationTail(ref, fromOffset, opts = {}) {
1869
2362
  findings,
1870
2363
  };
1871
2364
  }
1872
- /**
1873
- * Per-session derived payload: the four compute kinds the server's deep-dive
1874
- * routes read (diff/outcome/commits/markers) + the outcome-badge row. ALWAYS
1875
- * computed live from the transcript/git (no local compute/outcome cache is
1876
- * read or warmed — thin collector). Everything is deep-redacted — diffs carry
1877
- * file content.
1878
- */
1879
- function collectDerived(ref, mtime, count) {
1880
- const id = ref.prefixedId;
1881
- const compute = [];
1882
- let fullOutcome = null;
1883
- for (const kind of COMPUTE_KINDS) {
1884
- let data = null;
1885
- try {
1886
- if (kind === 'diff') {
1887
- const replay = replaySessionAny(id);
1888
- if (replay.found)
1889
- data = replay;
1890
- }
1891
- else if (kind === 'outcome') {
1892
- fullOutcome = computeOutcome(id);
1893
- if (fullOutcome.found)
1894
- data = fullOutcome;
1895
- }
1896
- else if (kind === 'commits') {
1897
- // computeOutcome already ran git for the session window — reuse.
1898
- if (!fullOutcome)
1899
- fullOutcome = computeOutcome(id);
1900
- if (fullOutcome.found)
1901
- data = fullOutcome.commits;
1902
- }
1903
- else if (kind === 'markers') {
1904
- // Analysis-only use of the turns extractor (R4) — markers need
1905
- // per-prompt line/ts, not render fidelity.
1906
- const turns = extractTurnsAny(id, { maxTurns: 50_000 });
1907
- const prompts = turns.turns
1908
- .filter((t) => t.kind === 'user' && t.text)
1909
- .map((t) => ({ line: t.line, ts: t.ts, tsIso: t.tsIso, ...markPrompt(t.text) }));
1910
- data = { sessionId: id, prompts, summary: summarizeMarkers(prompts) };
1911
- }
1912
- }
1913
- catch {
1914
- data = null;
1915
- }
1916
- if (data === null)
1917
- continue;
1918
- const redacted = redactDeep(data, count);
1919
- if (JSON.stringify(redacted).length > MAX_DERIVED_ROW_BYTES)
1920
- continue;
1921
- compute.push({ kind, mtime, data: redacted });
1922
- }
1923
- // Outcome-badge row (session_outcome_cache) — what the list badges read.
1924
- // Computed live from the outcome we already ran above.
1925
- let outcomeRow = null;
1926
- if (fullOutcome?.found) {
1927
- outcomeRow = {
1928
- sessionId: id,
1929
- tool: ref.toolId,
1930
- status: fullOutcome.status,
1931
- reason: fullOutcome.reason,
1932
- fileMtime: mtime,
1933
- fileSize: 0,
1934
- contentHash: '',
1935
- fileCount: fullOutcome.fileCount,
1936
- linesAdded: fullOutcome.totalLinesAdded,
1937
- linesRemoved: fullOutcome.totalLinesRemoved,
1938
- commits: fullOutcome.commits?.totalCommits ?? 0,
1939
- isFull: true,
1940
- classifiedAt: Date.now(),
1941
- lastScannedOffset: 0,
1942
- };
1943
- }
1944
- return {
1945
- session_id: id,
1946
- mtime,
1947
- compute,
1948
- outcome_row: outcomeRow ? redactDeep({ ...outcomeRow, sessionId: undefined }, count) : null,
1949
- };
1950
- }
1951
2365
  export function isSyncSkip(r) {
1952
2366
  return typeof r.skipped === 'string';
1953
2367
  }