@quantiya/codevibe-antigravity-plugin 2.0.51 → 2.0.52

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 (2) hide show
  1. package/dist/server.js +109 -11
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -350,6 +350,10 @@ var OFFSET_FLUSH_INTERVAL_MS = 3e4;
350
350
  var ERROR_LOG_MIN_INTERVAL_MS = 1e4;
351
351
  var LRU_CAP = 50;
352
352
  var TRANSCRIPT_FILE_REGEX = /\/brain\/([0-9a-fA-F-]{36})\/\.system_generated\/logs\/transcript\.jsonl$/;
353
+ var CONVERSATION_DIR_REGEX = /^[0-9a-fA-F-]{36}$/;
354
+ var SYSTEM_GENERATED_DIR = ".system_generated";
355
+ var LOGS_DIR = "logs";
356
+ var TRANSCRIPT_BASENAME = "transcript.jsonl";
353
357
  function defaultPaths() {
354
358
  const home = os2.homedir();
355
359
  return {
@@ -428,17 +432,69 @@ var TranscriptTailer = class extends import_events.EventEmitter {
428
432
  // partial-line carryover handles partial writes
429
433
  usePolling: this.usePolling,
430
434
  interval: this.usePolling ? 50 : void 0,
431
- ignored: (filePath) => {
432
- const isDir = (() => {
433
- try {
434
- return fs2.statSync(filePath).isDirectory();
435
- } catch {
436
- return false;
437
- }
438
- })();
439
- if (isDir) return false;
440
- return !TRANSCRIPT_FILE_REGEX.test(filePath);
441
- }
435
+ // R24 — admit ONLY the exact path shape that can lead to a transcript.
436
+ //
437
+ // THE DEFECT THIS REPLACES: the previous predicate returned `false` for
438
+ // EVERY directory ("allow directories so chokidar can descend into
439
+ // them"), so chokidar descended into the whole brain. Measured on a real
440
+ // machine 2026-09-22: **40,345 directories across 234 conversations**
441
+ // (7.8 GB), on chokidar's kqueue backend which needs roughly one file
442
+ // descriptor per watched directory. The watcher exhausted its descriptors
443
+ // and threw EMFILE continuously — 77,359 errors in a single 28-minute
444
+ // daemon start, 89% of the whole log file across the day.
445
+ //
446
+ // That was not merely wasteful, it was a CORRECTNESS failure, and the
447
+ // exact shape matters because it is worse than a slow start:
448
+ //
449
+ // 1. The initial scan storms for tens of minutes.
450
+ // 2. When it finally completes, `ready` fires and
451
+ // backfillActiveTranscripts() does a ONE-SHOT catch-up read. That
452
+ // uses its own readdirSync, NOT the watcher, so it succeeds — mobile
453
+ // shows a sudden burst of backlog.
454
+ // 3. But the per-file WATCH was never established, because the
455
+ // descriptors were spent on old conversations during the scan. So no
456
+ // `change` event ever arrives again and the tailer STALLS PERMANENTLY
457
+ // at whatever offset the backfill reached.
458
+ //
459
+ // Measured live on the owner's machine while this fix was being written,
460
+ // and RETAINED — every figure here comes from that capture, not from
461
+ // recollection:
462
+ // .hard-gate/r24-watch-scope/evidence/prefix-descriptor-accounting.log
463
+ // The daemon held file descriptors for 231 OLD conversations and **zero**
464
+ // for the active one, while its offset sat frozen at 987,484 bytes
465
+ // against a transcript of 1,656,028 on disk — 668 KB behind and not
466
+ // advancing, with the EMFILE rate back to 0/sec and the daemon otherwise
467
+ // idle at 0.3% CPU. The gap WIDENED between two samplings twenty minutes
468
+ // apart (231 KB, then 668 KB), which is what distinguishes a permanent
469
+ // stall from slow catch-up.
470
+ //
471
+ // User-visible result: a burst of backlog, then silence. Interactive
472
+ // prompts degrade to "Prompt candidate fired but no active conversation
473
+ // known {observedConvCount:0}", which renders on mobile as "a prompt is
474
+ // waiting in your desktop terminal" with NO selectable options — the user
475
+ // can see that a prompt exists but cannot answer it, which is the entire
476
+ // purpose of the mobile companion.
477
+ //
478
+ // NOTE this description was corrected twice. It was first written as
479
+ // "permanent", then weakened to "a bounded dead window" after observing a
480
+ // recovery, then corrected again once the descriptor accounting above was
481
+ // actually measured. The recovery was the one-shot backfill of step 2, not
482
+ // the watcher healing. Stage 1 NB-4 predicted this sub-case before it was
483
+ // measured.
484
+ //
485
+ // THE FIX: the transcript lives at exactly one shape, four levels deep:
486
+ // brain/<uuid>/.system_generated/logs/transcript.jsonl
487
+ // so decide purely LEXICALLY on that shape and refuse everything else.
488
+ // Same machine, after: **699** directories (1 root + 234 conversations +
489
+ // 232 .system_generated + 232 logs) — a 57x reduction that also excludes
490
+ // the actual bulk: scratch/ (230 dirs), .user_uploaded/ (232) and the
491
+ // thousands of .system_generated/tasks/ dirs, none of which can ever hold
492
+ // a transcript.
493
+ //
494
+ // Deciding lexically also removes the per-path `fs.statSync` the old
495
+ // predicate ran during traversal, which was its own CPU cost on a tree
496
+ // this size.
497
+ ignored: (filePath) => this.isOutsideTranscriptPath(filePath)
442
498
  });
443
499
  this.watcher.on("add", (filePath) => this.onFileAdded(filePath));
444
500
  this.watcher.on("addDir", (dirPath) => this.onDirAdded(dirPath));
@@ -568,6 +624,48 @@ var TranscriptTailer = class extends import_events.EventEmitter {
568
624
  this.emit("transcript-add", filePath, conversationId);
569
625
  void this.scheduleReadNewLines(filePath, conversationId);
570
626
  }
627
+ /**
628
+ * R24 traversal filter: true = chokidar must NOT descend into / watch this.
629
+ *
630
+ * Purely lexical — no filesystem call — so it is cheap enough to run on every
631
+ * traversed path and cannot be fooled by a stat race. The only paths admitted
632
+ * are the ones that can lead to a transcript:
633
+ *
634
+ * <brainRoot> depth 0
635
+ * <brainRoot>/<uuid> depth 1
636
+ * <brainRoot>/<uuid>/.system_generated depth 2
637
+ * <brainRoot>/<uuid>/.system_generated/logs depth 3
638
+ * <brainRoot>/<uuid>/.system_generated/logs/transcript.jsonl depth 4
639
+ *
640
+ * Anything else — scratch/, evidence/, .user_uploaded/, the task logs, and
641
+ * every file that is not the transcript — is refused, which stops chokidar
642
+ * descending into it at all.
643
+ *
644
+ * NOTE the depth-1 UUID check is what keeps this bounded as the brain grows:
645
+ * a non-conversation entry at the top level can never be a conversation, so
646
+ * it is never traversed. `depth: 4` on the watcher remains as a second,
647
+ * independent bound.
648
+ */
649
+ isOutsideTranscriptPath(filePath) {
650
+ const rel = path2.relative(this.brainRoot, path2.resolve(filePath));
651
+ if (rel === "") return false;
652
+ if (rel === ".." || rel.startsWith(".." + path2.sep)) return true;
653
+ const seg = rel.split(path2.sep);
654
+ if (!CONVERSATION_DIR_REGEX.test(seg[0])) return true;
655
+ switch (seg.length) {
656
+ case 1:
657
+ return false;
658
+ // a well-formed conversation directory
659
+ case 2:
660
+ return seg[1] !== SYSTEM_GENERATED_DIR;
661
+ case 3:
662
+ return seg[2] !== LOGS_DIR;
663
+ case 4:
664
+ return seg[3] !== TRANSCRIPT_BASENAME;
665
+ default:
666
+ return true;
667
+ }
668
+ }
571
669
  onDirAdded(dirPath) {
572
670
  if (this.isShuttingDown) return;
573
671
  const base = path2.basename(dirPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.51",
3
+ "version": "2.0.52",
4
4
  "description": "Control Antigravity CLI from your iPhone and Android — real-time sync, approve file edits, send prompts by voice. Part of CodeVibe.",
5
5
  "main": "dist/server.js",
6
6
  "codevibe": {