sweet-search 2.5.13 → 2.6.0

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 (52) hide show
  1. package/README.md +36 -9
  2. package/core/cli.js +41 -3
  3. package/core/embedding/embedding-local-model.js +106 -10
  4. package/core/embedding/embedding-service.js +59 -1
  5. package/core/embedding/model-client.mjs +257 -0
  6. package/core/embedding/model-server.mjs +217 -0
  7. package/core/incremental-indexing/application/maintenance-handlers.mjs +19 -98
  8. package/core/incremental-indexing/application/maintenance-worker.mjs +46 -9
  9. package/core/incremental-indexing/application/operator-cli.mjs +14 -5
  10. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +40 -0
  11. package/core/incremental-indexing/application/production-reconciler.mjs +718 -54
  12. package/core/incremental-indexing/application/reconciler.mjs +87 -15
  13. package/core/incremental-indexing/domain/cutoff-cache.mjs +191 -0
  14. package/core/incremental-indexing/domain/interval-autotune.mjs +84 -1
  15. package/core/incremental-indexing/domain/reconcile-counters.mjs +0 -4
  16. package/core/incremental-indexing/domain/watermark-scheduler.mjs +0 -24
  17. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +2 -26
  18. package/core/incremental-indexing/infrastructure/manifest.mjs +1 -9
  19. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +72 -0
  20. package/core/indexing/artifact-builder.js +1 -1
  21. package/core/indexing/dedup/dedup-phase.js +36 -17
  22. package/core/indexing/dedup/exemplar-selector.js +5 -0
  23. package/core/indexing/index-codebase-v21.js +37 -14
  24. package/core/indexing/index-maintainer.mjs +337 -6
  25. package/core/indexing/indexer-ann.js +27 -434
  26. package/core/indexing/indexer-build.js +30 -14
  27. package/core/indexing/indexer-manifest.js +0 -3
  28. package/core/indexing/indexer-phases.js +101 -25
  29. package/core/indexing/maintainer-launcher.mjs +22 -0
  30. package/core/indexing/maintainer-watcher.mjs +397 -0
  31. package/core/indexing/os-priority.mjs +160 -0
  32. package/core/indexing/rss-budget.mjs +425 -0
  33. package/core/indexing/streaming-vectors.js +450 -0
  34. package/core/infrastructure/config/platform.js +14 -10
  35. package/core/infrastructure/onnx-session-utils.js +37 -0
  36. package/core/infrastructure/sparse-gram-delta-reader.js +11 -1
  37. package/core/ranking/late-interaction-index.js +58 -7
  38. package/core/search/daemon-registry.js +199 -0
  39. package/core/search/search-read-semantic.js +9 -3
  40. package/core/search/search-semantic.js +6 -29
  41. package/core/search/search-server.js +527 -27
  42. package/core/search/session-daemon-prewarm.mjs +110 -1
  43. package/core/search/sweet-search.js +0 -38
  44. package/core/vector-store/binary-hnsw-index.js +692 -78
  45. package/core/vector-store/index.js +1 -4
  46. package/eval/agent-read-workflows/bin/_ss-argparse.mjs +51 -5
  47. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +95 -44
  48. package/eval/agent-read-workflows/bin/ss-read +2 -0
  49. package/mcp/tool-handlers.js +1 -2
  50. package/package.json +11 -8
  51. package/scripts/uninstall.js +2 -0
  52. package/core/vector-store/hnsw-index.js +0 -751
@@ -43,7 +43,8 @@ import { dirname, join, relative, isAbsolute, resolve } from 'node:path';
43
43
  import { fileURLToPath } from 'node:url';
44
44
  import { spawn } from 'node:child_process';
45
45
  import { randomUUID } from 'node:crypto';
46
- import { startupInterval, tierForHardware, reconcileEnablement } from '../incremental-indexing/domain/interval-autotune.mjs';
46
+ import os from 'node:os';
47
+ import { startupInterval, tierForHardware, reconcileEnablement, nextInterval, backstopWalkIntervalMs, resolveMaintainerMemoryProfile } from '../incremental-indexing/domain/interval-autotune.mjs';
47
48
  import { detectHardwareCapability } from '../infrastructure/hardware-capability.js';
48
49
  import { sweepStaleArtifactTemps, DEFAULT_TMP_SWEEP_MAX_AGE_MS } from '../incremental-indexing/infrastructure/artifact-temp-sweep.mjs';
49
50
  import { hasCompleteBaseIndex, WAITING_FOR_INITIAL_INDEX } from '../incremental-indexing/infrastructure/baseline-readiness.mjs';
@@ -909,9 +910,16 @@ export async function runReconcileV2Tick(ctx) {
909
910
  // `sweet-search index --add` or an editor hook (release-gate finding C1). Runs
910
911
  // before the consume step below; best-effort so a scan failure never blocks
911
912
  // reconcile of already-queued work.
913
+ // G6 backstop demotion: when the watcher is the primary producer the daemon
914
+ // sets `ctx.skipFullWalk` on the ticks between backstop walks (it leaves it
915
+ // unset/false on the first tick, on the periodic backstop, and on overflow).
916
+ // The watcher has already fed the queue from filesystem events, so the
917
+ // expensive full stat-walk is redundant on those ticks. When the watcher is
918
+ // inactive `skipFullWalk` is never set ⇒ the walk runs every tick (today's
919
+ // behavior, unchanged).
912
920
  try {
913
921
  const { dirtyScanEnabled, scanDirtyAndEnqueue } = await import('../incremental-indexing/application/dirty-scan.mjs');
914
- if (dirtyScanEnabled()) {
922
+ if (dirtyScanEnabled() && !ctx.skipFullWalk) {
915
923
  const { createAdmissionPolicy } = await import('../indexing/admission-policy.js');
916
924
  const admissionPolicy = createAdmissionPolicy({ projectRoot: ctx.projectRoot });
917
925
  progress('dirty-scan:start');
@@ -1059,12 +1067,18 @@ export async function drainMaintenanceInline(ctx) {
1059
1067
  }
1060
1068
  }
1061
1069
 
1062
- async function sleepWithProgress(totalMs, lockFile) {
1070
+ async function sleepWithProgress(totalMs, lockFile, opts = {}) {
1063
1071
  const deadline = Date.now() + totalMs;
1072
+ // G6 watcher early-wake: when the watcher feeds the queue mid-sleep it sets a
1073
+ // `pendingEvents` flag; the daemon breaks out of the sleep so the next tick
1074
+ // reconciles fresh edits without waiting out the full interval. Off by default
1075
+ // (no watcher → `wokenByWatcher` is never callable → behavior is today's).
1076
+ const wokenByWatcher = typeof opts.wokenByWatcher === 'function' ? opts.wokenByWatcher : null;
1064
1077
  while (!shutdownRequested) {
1065
1078
  if (!stillOwnsLock(lockFile)) {
1066
1079
  throw new MaintainerLifecycleAbort('lock ownership lost during sleep');
1067
1080
  }
1081
+ if (wokenByWatcher && wokenByWatcher()) return;
1068
1082
  const remaining = deadline - Date.now();
1069
1083
  if (remaining <= 0) return;
1070
1084
  await new Promise((resolveSleep) => setTimeout(resolveSleep, Math.min(LOCK_REFRESH_INTERVAL, remaining)));
@@ -1072,6 +1086,131 @@ async function sleepWithProgress(totalMs, lockFile) {
1072
1086
  }
1073
1087
  }
1074
1088
 
1089
+ // ---- G4 lever gates (each default OFF unless noted; off ⇒ exact prior behavior).
1090
+ // Mirror the G2 reconciler convention: strict `'1'` opt-in.
1091
+ const flagOn = (name) => process.env[name] === '1';
1092
+ // SWEET_SEARCH_RECONCILE_AUTOTUNE is DEFAULT-ON (disable with =0): the daemon's
1093
+ // consume-half mirrors the reconciler config-half so the tuned interval is
1094
+ // actually applied to the sleep loop. Verified recall-neutral + soak == baseline.
1095
+ // Set to '0' to restore the fixed-interval sleep loop.
1096
+ export const reconcileAutotuneEnabled = (env = process.env) => env.SWEET_SEARCH_RECONCILE_AUTOTUNE !== '0';
1097
+
1098
+ /**
1099
+ * A.4-consume: recompute the daemon's sleep interval from the just-finished
1100
+ * tick. The reconciler config-half (G2) only re-tunes an ephemeral per-tick
1101
+ * Reconciler instance, so the daemon MUST recompute the interval in its OWN
1102
+ * loop or the autotune is "doubly dead" (flag on but output disconnected).
1103
+ *
1104
+ * Pure + testable: takes the tick counters snapshot + a measured maintenance
1105
+ * backlog and returns the next interval. When the flag is off, returns
1106
+ * `currentMs` unchanged (today's behavior). When an explicit interval is pinned
1107
+ * via the startup env, the daemon never calls this (the resolver marked it
1108
+ * pinned). Windows: `os.loadavg()` returns `[0,0,0]`, so the per-core load is 0
1109
+ * and the loosen-under-load signal is naturally skipped (flat interval) — no
1110
+ * special-casing needed beyond not treating 0 as "idle headroom" spuriously
1111
+ * (0 < 0.2 only nudges down by 0.95, well within the rate-limit, harmless).
1112
+ *
1113
+ * @param {object} args
1114
+ * @param {number} args.currentMs interval the just-finished tick used
1115
+ * @param {object|null} args.counters runReconcileV2Tick return (snapshot)
1116
+ * @param {number} args.maintenanceBacklog pending maintenance jobs
1117
+ * @param {NodeJS.ProcessEnv} [args.env]
1118
+ * @param {() => number[]} [args.loadavg] injectable for tests
1119
+ * @param {() => number} [args.cpuCount] injectable for tests
1120
+ * @returns {{ nextMs:number, tuned:boolean, reasons:string[] }}
1121
+ */
1122
+ export function computeNextIntervalMs({
1123
+ currentMs,
1124
+ counters,
1125
+ maintenanceBacklog,
1126
+ env = process.env,
1127
+ loadavg = () => os.loadavg(),
1128
+ cpuCount = () => os.cpus().length,
1129
+ }) {
1130
+ if (!reconcileAutotuneEnabled(env)) {
1131
+ return { nextMs: currentMs, tuned: false, reasons: [] };
1132
+ }
1133
+ // A skipped tick (dormant baseline / paused) has no usable signal — leave the
1134
+ // interval untouched rather than feed `nextInterval` zeros that would creep up.
1135
+ if (!counters || counters.skipped) {
1136
+ return { nextMs: currentMs, tuned: false, reasons: [] };
1137
+ }
1138
+ let cores = 1;
1139
+ try { cores = Math.max(1, cpuCount() || 1); } catch { cores = 1; }
1140
+ let load1 = 0;
1141
+ try { load1 = (loadavg()?.[0] ?? 0); } catch { load1 = 0; }
1142
+ const cpuLoadAvg = load1 / cores; // Windows → 0/N = 0 → loosen-under-load skipped.
1143
+ const tuned = nextInterval({
1144
+ currentMs,
1145
+ lastTickMs: Number(counters.tick_ms) || 0,
1146
+ dirtyAtTickStart: Number(counters.dirty_paths_seen) || 0,
1147
+ cpuLoadAvg,
1148
+ maintenanceBacklog: Number(maintenanceBacklog) || 0,
1149
+ });
1150
+ return { nextMs: tuned.nextMs, tuned: tuned.nextMs !== currentMs, reasons: tuned.reasons };
1151
+ }
1152
+
1153
+ /**
1154
+ * D.1: idle-TTL. Default 0/disabled. Returns the configured wall-clock idle
1155
+ * budget (ms) after which an unattended maintainer self-shuts-down so N resident
1156
+ * model-loaded daemons collapse to 1–2 (the ~16 GB cross-repo footprint fix).
1157
+ * 0 (or invalid / negative) ⇒ disabled (today's "run forever" behavior).
1158
+ */
1159
+ export function maintainerIdleTtlMs(env = process.env, totalMemBytes = os.totalmem()) {
1160
+ const rawStr = env.SWEET_SEARCH_MAINTAINER_IDLE_TTL_MS;
1161
+ if (rawStr != null) {
1162
+ // Explicitly set (incl. '' / invalid / 0 / negative → disabled): the env
1163
+ // always wins over the tier default.
1164
+ const raw = Number.parseInt(rawStr, 10);
1165
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
1166
+ }
1167
+ // Unset → auto from the system-RAM tier (resolveMaintainerMemoryProfile):
1168
+ // small-RAM hosts (laptops, the OOM case) get idle-TTL ON; roomy hosts → 0/off.
1169
+ return resolveMaintainerMemoryProfile({ totalMemBytes }).idleTtlMs;
1170
+ }
1171
+
1172
+ /**
1173
+ * D.1 idle bookkeeping (pure + testable). The maintainer has NO query route, so
1174
+ * idle is keyed on consecutive ticks that found NOTHING to do — `dirtyAtTickStart
1175
+ * === 0` AND an empty maintenance queue — NOT on the self-heartbeats
1176
+ * (recordProgress/writeStateLock always tick). Any indexed change resets the
1177
+ * counter and the wall-clock idle anchor.
1178
+ *
1179
+ * @param {object} state mutable { idleTicks:number, idleSinceMs:number|null }
1180
+ * @param {object} args
1181
+ * @param {number} args.dirtyAtTickStart
1182
+ * @param {number} args.maintenanceBacklog
1183
+ * @param {boolean} [args.skipped] a dormant/paused tick is NOT activity, but is
1184
+ * also not "idle work done" — treat as idle.
1185
+ * @param {number} [args.nowMs]
1186
+ * @returns {{ idle:boolean }}
1187
+ */
1188
+ export function recordIdleTick(state, { dirtyAtTickStart, maintenanceBacklog, skipped = false, nowMs = Date.now() }) {
1189
+ const didWork = !skipped && ((Number(dirtyAtTickStart) || 0) > 0 || (Number(maintenanceBacklog) || 0) > 0);
1190
+ if (didWork) {
1191
+ state.idleTicks = 0;
1192
+ state.idleSinceMs = null;
1193
+ return { idle: false };
1194
+ }
1195
+ state.idleTicks = (state.idleTicks || 0) + 1;
1196
+ if (state.idleSinceMs == null) state.idleSinceMs = nowMs;
1197
+ return { idle: true };
1198
+ }
1199
+
1200
+ /**
1201
+ * D.1: has the maintainer been continuously idle (no indexed change, empty
1202
+ * maintenance queue) for at least `ttlMs` wall-clock across consecutive ticks?
1203
+ * Requires BOTH at least one consecutive idle tick AND the wall-clock budget to
1204
+ * have elapsed since idleness began. ttlMs<=0 ⇒ never (disabled).
1205
+ */
1206
+ export function idleTtlExceeded(state, ttlMs, nowMs = Date.now()) {
1207
+ if (!(ttlMs > 0)) return false;
1208
+ // Use a null check, NOT truthiness: an idle anchor of exactly 0ms is valid.
1209
+ if (state.idleSinceMs == null) return false;
1210
+ if ((state.idleTicks || 0) < 1) return false;
1211
+ return (nowMs - state.idleSinceMs) >= ttlMs;
1212
+ }
1213
+
1075
1214
  async function runReconcileV2Main({ runOnce, merkleOnce }) {
1076
1215
  const ctx = reconcileV2Context();
1077
1216
  mkdirSync(ctx.stateDir, { recursive: true });
@@ -1104,8 +1243,110 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
1104
1243
  }
1105
1244
 
1106
1245
  const resolved = resolveReconcileV2Interval();
1107
- const intervalMs = resolved.intervalMs;
1246
+ // A.4-consume: the sleep interval is now mutable so the autotune can move it
1247
+ // each iteration. When the operator pins an explicit interval, the resolver
1248
+ // marks it `pinned` and the autotune never fires (parity with the reconciler
1249
+ // config's `pinnedIntervalMs` short-circuit).
1250
+ let intervalMs = resolved.intervalMs;
1251
+ const autotunePinned = resolved.pinned === true;
1108
1252
  log('INFO', `Reconcile v2 interval ${intervalMs}ms (source=${resolved.source}${resolved.tier ? `, tier=${resolved.tier}` : ''})`);
1253
+
1254
+ // G3 arming: configure the BACKGROUND/maintainer ORT profile BEFORE the first
1255
+ // tick embeds. The ONNX session singleton is built once on first encode;
1256
+ // configuring after is a silent no-op (mirrors indexer-phases.js:491).
1257
+ //
1258
+ // MAINTAINER-SCOPED DEFAULT-ON (disable with SWEET_SEARCH_ORT_BACKGROUND=0):
1259
+ // arm the background ORT profile (force_spinning_stop + arena-off + 2–4
1260
+ // intra-op threads) by default, but ONLY in this maintainer daemon process. We
1261
+ // arm it explicitly here via configureLocalModelRuntime({ background: true }),
1262
+ // which sets the process-local runtime config — it does NOT touch
1263
+ // buildLocalSessionOptions / isBackgroundOrtProfile's env fallback, so every
1264
+ // OTHER process (the latency-critical search/query embedding path) stays on the
1265
+ // FOREGROUND profile. Verified ORT embeddings byte-identical. Set
1266
+ // SWEET_SEARCH_ORT_BACKGROUND=0 to keep even the maintainer on foreground.
1267
+ // Best-effort — never block startup on the embedding module.
1268
+ if (process.env.SWEET_SEARCH_ORT_BACKGROUND !== '0') {
1269
+ try {
1270
+ const [{ configureLocalModelRuntime }, { backgroundIntraOpThreads }] = await Promise.all([
1271
+ import('../embedding/embedding-local-model.js'),
1272
+ import('../infrastructure/onnx-session-utils.js'),
1273
+ ]);
1274
+ configureLocalModelRuntime({ intraOpThreads: backgroundIntraOpThreads(), background: true });
1275
+ log('INFO', 'ORT background profile armed for maintainer daemon (force_spinning_stop + arena-off + bg threads)');
1276
+ } catch (err) {
1277
+ log('WARN', `ORT background profile arming failed (continuing on foreground profile): ${err?.message ?? err}`);
1278
+ }
1279
+ }
1280
+
1281
+ // G6 watcher hook (integration seam; module + behavior owned by G6). When the
1282
+ // module is absent or the flag is off, behavior is EXACTLY today: the per-tick
1283
+ // full stat-walk in runReconcileV2Tick stays the sole dirty-set producer. The
1284
+ // watcher (when active) feeds the queue from filesystem events, sets
1285
+ // `watcherState.pendingEvents` for early-wake, and the producer block demotes
1286
+ // the full walk to a periodic backstop.
1287
+ const watcherState = { active: false, pendingEvents: false, forceBackstopWalk: false, lastBackstopWalkMs: 0, handle: null };
1288
+ const backstopMs = backstopWalkIntervalMs().intervalMs;
1289
+ if (process.env.SWEET_SEARCH_MAINTAINER_WATCH === '1') {
1290
+ try {
1291
+ const mod = await import('./maintainer-watcher.mjs');
1292
+ if (typeof mod.startWatcher === 'function') {
1293
+ const { createAdmissionPolicy } = await import('../indexing/admission-policy.js');
1294
+ const admissionPolicy = createAdmissionPolicy({ projectRoot: ctx.projectRoot });
1295
+ watcherState.handle = await mod.startWatcher({
1296
+ stateDir: ctx.stateDir,
1297
+ projectRoot: ctx.projectRoot,
1298
+ admissionPolicy,
1299
+ onEvent: () => { watcherState.pendingEvents = true; },
1300
+ onOverflow: () => { watcherState.forceBackstopWalk = true; watcherState.pendingEvents = true; },
1301
+ });
1302
+ watcherState.active = !!watcherState.handle;
1303
+ if (watcherState.active) log('INFO', `Maintainer file watcher active (backstop walk every ${backstopMs}ms)`);
1304
+ }
1305
+ } catch (err) {
1306
+ // Module absent or failed → no-op; the full per-tick walk remains primary.
1307
+ log('WARN', `Maintainer watcher unavailable (full per-tick walk remains primary): ${err?.message ?? err}`);
1308
+ }
1309
+ }
1310
+
1311
+ // G7 RSS-budget registry hook (integration seam; module owned by G7).
1312
+ // Registers when the soft cap is enabled — which is now tier-aware:
1313
+ // `isEnabled()` is true when SWEET_SEARCH_RSS_BUDGET_FRACTION is set OR the
1314
+ // system-RAM tier auto-enables a cap (small-RAM hosts). Best-effort, guarded
1315
+ // so a missing module is a no-op.
1316
+ let rssRegistration = null;
1317
+ try {
1318
+ const mod = await import('./rss-budget.mjs');
1319
+ if (typeof mod.isEnabled === 'function' && mod.isEnabled()
1320
+ && typeof mod.registerDaemon === 'function') {
1321
+ rssRegistration = await mod.registerDaemon({ pid: process.pid, stateDir: ctx.stateDir, kind: 'maintainer' });
1322
+ }
1323
+ } catch (err) {
1324
+ log('WARN', `RSS-budget registry unavailable (no soft cap on this daemon): ${err?.message ?? err}`);
1325
+ }
1326
+
1327
+ // D.1 idle-TTL: an unattended maintainer self-shuts-down after the configured
1328
+ // wall-clock idle budget so N resident model-loaded daemons collapse to 1–2
1329
+ // (the ~16 GB cross-repo footprint fix). Default 0/disabled ⇒ run forever
1330
+ // (today's behavior). Idle is keyed on consecutive ticks that found nothing to
1331
+ // do (dirtyAtTickStart===0 AND empty maintenance queue), NEVER on the
1332
+ // self-heartbeats. NOTE: the search-server daemon has its own idle-TTL+LRU,
1333
+ // but it never enumerates/registers the maintainer (search-server.js:918), so
1334
+ // there is no double-mechanism here.
1335
+ const idleTtl = maintainerIdleTtlMs();
1336
+ const idleState = { idleTicks: 0, idleSinceMs: null };
1337
+ // Unref'd idle timer mirrors search-server.js:907–913 — it never keeps the
1338
+ // event loop alive on its own and only REQUESTS shutdown (it does NOT
1339
+ // process.exit); the main loop drains the current tick to `finally`.
1340
+ const idleTimer = idleTtl > 0
1341
+ ? setInterval(() => {
1342
+ if (idleTtlExceeded(idleState, idleTtl)) {
1343
+ log('INFO', `Maintainer idle for ≥${idleTtl}ms with no indexed change; requesting clean shutdown for on-demand respawn`);
1344
+ shutdownRequested = true;
1345
+ }
1346
+ }, Number(process.env.SWEET_SEARCH_MAINTAINER_IDLE_CHECK_MS ?? 60_000))
1347
+ : null;
1348
+ if (idleTimer?.unref) idleTimer.unref();
1349
+
1109
1350
  // Lifecycle fix: only refresh the heartbeat if we still own the lock. If a
1110
1351
  // wedged-backstop takeover stole it, the lockfile now names another pid —
1111
1352
  // we must NOT clobber that successor with our pid. The main loop's
@@ -1137,16 +1378,60 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
1137
1378
  // acquireStateLock distinguish a busy-but-progressing daemon from one
1138
1379
  // hung on a never-resolving await — see the WEDGED_KILL_GRACE_MS block.
1139
1380
  recordProgress(lock.lockFile);
1381
+ // G6: the watcher already fed the queue from filesystem events, so the
1382
+ // expensive full stat-walk producer is demoted to a periodic backstop.
1383
+ // We pass the demotion decision into the tick via ctx so the producer
1384
+ // block (index-maintainer.mjs ~:907) can skip the walk when appropriate.
1385
+ // When the watcher is inactive this is always `false` ⇒ today's behavior.
1386
+ let runFullWalk = true;
1387
+ if (watcherState.active) {
1388
+ const nowMs = Date.now();
1389
+ const dueForBackstop = (nowMs - (watcherState.lastBackstopWalkMs || 0)) >= backstopMs;
1390
+ const firstTick = !watcherState.lastBackstopWalkMs;
1391
+ runFullWalk = firstTick || dueForBackstop || watcherState.forceBackstopWalk;
1392
+ if (runFullWalk) {
1393
+ watcherState.lastBackstopWalkMs = nowMs;
1394
+ watcherState.forceBackstopWalk = false;
1395
+ }
1396
+ }
1397
+ // Consume any pending watcher events for THIS tick (the queue already
1398
+ // holds them); clear so early-wake doesn't immediately re-fire post-sleep.
1399
+ watcherState.pendingEvents = false;
1140
1400
  const pause = isReconcilePaused(ctx.stateDir);
1141
1401
  if (pause.paused) {
1142
1402
  log('INFO', `Automatic reconcile v2 work paused${pause.pausedAt ? ` since ${pause.pausedAt}` : ''}`);
1143
1403
  } else {
1144
1404
  try {
1145
1405
  const onProgress = createLifecycleProgress(lock.lockFile);
1146
- await runReconcileV2Tick({ ...ctx, onProgress });
1406
+ const tickCounters = await runReconcileV2Tick({ ...ctx, onProgress, skipFullWalk: watcherState.active && !runFullWalk });
1147
1407
  onProgress('tick:post'); // post-tick checkpoint
1148
1408
  await drainMaintenanceInline({ ...ctx, onProgress });
1149
1409
  onProgress('drain:post'); // post-drain checkpoint
1410
+
1411
+ // A.4-consume + D.1 idle bookkeeping. Read the maintenance backlog
1412
+ // AFTER the drain (it reflects what remains, not what was queued).
1413
+ let backlog = 0;
1414
+ try {
1415
+ const { readMaintenanceQueue } = await import('../incremental-indexing/application/maintenance-worker.mjs');
1416
+ backlog = readMaintenanceQueue(ctx.stateDir).length;
1417
+ } catch { backlog = 0; }
1418
+ const dirtySeen = Number(tickCounters?.dirty_paths_seen) || 0;
1419
+ if (!autotunePinned) {
1420
+ const tuned = computeNextIntervalMs({
1421
+ currentMs: intervalMs,
1422
+ counters: tickCounters,
1423
+ maintenanceBacklog: backlog,
1424
+ });
1425
+ if (tuned.tuned) {
1426
+ log('INFO', `Reconcile v2 interval ${intervalMs}ms → ${tuned.nextMs}ms (${tuned.reasons.join(',')})`);
1427
+ intervalMs = tuned.nextMs;
1428
+ }
1429
+ }
1430
+ recordIdleTick(idleState, {
1431
+ dirtyAtTickStart: dirtySeen,
1432
+ maintenanceBacklog: backlog,
1433
+ skipped: tickCounters?.skipped === true,
1434
+ });
1150
1435
  } catch (err) {
1151
1436
  if (err instanceof MaintainerLifecycleAbort) {
1152
1437
  log('WARN', `Reconcile v2 lifecycle abort: ${err.message}. Cleaning up cancellation-orphaned temps and exiting cleanly.`);
@@ -1172,10 +1457,33 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
1172
1457
  log('ERROR', `Reconcile v2 tick failed: ${err?.message ?? err}`);
1173
1458
  }
1174
1459
  }
1175
- await sleepWithProgress(intervalMs, lock.lockFile);
1460
+ await sleepWithProgress(intervalMs, lock.lockFile, {
1461
+ // G6 early-wake: break the sleep the instant the watcher reports new
1462
+ // events so a fresh edit is reconciled without waiting out the interval.
1463
+ // No watcher ⇒ this is never truthy ⇒ today's full-interval sleep.
1464
+ wokenByWatcher: watcherState.active ? () => watcherState.pendingEvents : null,
1465
+ });
1176
1466
  }
1177
1467
  } finally {
1178
1468
  clearInterval(refresh);
1469
+ if (idleTimer) clearInterval(idleTimer);
1470
+ // G6 watcher teardown — best-effort, never throw from finally.
1471
+ if (watcherState.handle && typeof watcherState.handle.close === 'function') {
1472
+ try { await watcherState.handle.close(); } catch { /* best-effort */ }
1473
+ }
1474
+ // G7 RSS registry teardown — best-effort.
1475
+ if (rssRegistration && typeof rssRegistration.unregister === 'function') {
1476
+ try { await rssRegistration.unregister(); } catch { /* best-effort */ }
1477
+ }
1478
+ // D.1: release the ORT session in order on a clean (idle-TTL or signal)
1479
+ // shutdown so a respawned daemon starts from a clean slate. This canNOT go
1480
+ // in the process.on('exit') handler (synchronous, no async). releaseStateLock
1481
+ // below unlinks the O_EXCL lock so the next launchMaintainer respawns cleanly
1482
+ // (reconcile-before-serve via the new daemon's t=0 tick).
1483
+ try {
1484
+ const { unloadLocalModel } = await import('../embedding/embedding-local-model.js');
1485
+ await unloadLocalModel();
1486
+ } catch { /* best-effort: never block clean shutdown on model release */ }
1179
1487
  releaseStateLock(lock.lockFile);
1180
1488
  log('INFO', 'Reconcile v2 shutdown complete');
1181
1489
  }
@@ -2174,6 +2482,29 @@ async function main() {
2174
2482
  const dryRun = process.argv.includes('--dry-run');
2175
2483
  const merkleOnce = process.argv.includes('--merkle-once');
2176
2484
 
2485
+ // A.1 (Tier-1, UNGATED): demote the maintainer daemon to low OS priority so
2486
+ // the foreground (editor / git / shell) never feels the background indexer's
2487
+ // CPU. Identical index output — only *when* CPU is granted changes. Covers
2488
+ // BOTH the reconcile-v2 and the legacy queue/merkle paths (set before either
2489
+ // branch). Best-effort: a platform that rejects it must not crash the daemon.
2490
+ try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { /* best-effort */ }
2491
+
2492
+ // E.4 (global half): set the process-global SQLite `soft_heap_limit` ONCE,
2493
+ // before any tier connection opens. SQLite's `soft_heap_limit` is a
2494
+ // PROCESS-WIDE setting (sqlite3_soft_heap_limit64), not per-connection — so
2495
+ // running the pragma on a single throwaway in-memory connection at startup
2496
+ // caps the page-cache heap for EVERY subsequent better-sqlite3 connection in
2497
+ // this process (128 MiB). This is the process-wide lever that pairs with G2's
2498
+ // per-connection `cache_size` / `shrink_memory`. (better-sqlite3 exposes no
2499
+ // static global setter, so the throwaway-conn pragma is the documented path.)
2500
+ // Guarded + best-effort: a packaging without better-sqlite3 degrades to a
2501
+ // no-op and never crashes the daemon.
2502
+ try {
2503
+ const { default: BetterSqlite3 } = await import('better-sqlite3');
2504
+ const probe = new BetterSqlite3(':memory:');
2505
+ try { probe.pragma('soft_heap_limit = 134217728'); } finally { probe.close(); }
2506
+ } catch { /* best-effort: soft_heap_limit is an optimisation, never required */ }
2507
+
2177
2508
  // L1 FIX: Updated version to v3
2178
2509
  log('INFO', 'Starting index maintainer daemon v3...');
2179
2510
  const v2 = reconcileV2Status();