@threadbase-sh/streamer 1.24.3 → 1.24.5
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.
- package/dist/cli.cjs +423 -102
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +145 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +145 -24
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/dist/index.cjs
CHANGED
|
@@ -3807,6 +3807,61 @@ var ConversationCache = class _ConversationCache {
|
|
|
3807
3807
|
}
|
|
3808
3808
|
return ghosts;
|
|
3809
3809
|
}
|
|
3810
|
+
/**
|
|
3811
|
+
* Reconcile the cache against the authoritative set of conversation file
|
|
3812
|
+
* paths a fresh scan surfaced: drop any cached row whose `file_path` is not
|
|
3813
|
+
* in `livePaths` (removed from disk, or now filtered out — e.g. became an
|
|
3814
|
+
* agent JSONL). This is the "removed conversations" half of a ?refresh=1
|
|
3815
|
+
* reconcile; the additions/updates half is upsertFromScannerMeta.
|
|
3816
|
+
*
|
|
3817
|
+
* Skip semantics depend on whether the file still exists on disk:
|
|
3818
|
+
* - File GONE from disk → always removed, tail or not. This matches the old
|
|
3819
|
+
* invalidate()+rebuild behavior (a deleted conversation must disappear on
|
|
3820
|
+
* refresh) and keeps refresh=1 truthful about removals. NOTE: this is an
|
|
3821
|
+
* INTENTIONAL divergence from pruneGhostFiles(), which KEEPS tailed ghosts
|
|
3822
|
+
* so their cached history stays viewable on a background prune. refresh=1
|
|
3823
|
+
* has the opposite contract (mobile relies on removals being reflected), so
|
|
3824
|
+
* do not "unify" the two — they serve different purposes.
|
|
3825
|
+
* - File STILL on disk but absent from `livePaths` → the CRITICAL #2 race:
|
|
3826
|
+
* the scan snapshot predates a just-created (and now live-tailed) file.
|
|
3827
|
+
* A tailed row here is actively maintained from real content, so it is
|
|
3828
|
+
* kept — dropping it would flicker the active conversation out of
|
|
3829
|
+
* /api/conversations. An untailed on-disk row not in the snapshot is a
|
|
3830
|
+
* transient scan/discovery gap; it is left alone (not removed) and the
|
|
3831
|
+
* next reconcile picks it up, rather than risk removing a real file the
|
|
3832
|
+
* scan simply hasn't surfaced yet.
|
|
3833
|
+
* Returns the removed IDs.
|
|
3834
|
+
*/
|
|
3835
|
+
reconcileDeletions(livePaths, opts) {
|
|
3836
|
+
const exists = opts?.exists ?? import_fs8.existsSync;
|
|
3837
|
+
const rows = this.stmts.allFilePaths.all();
|
|
3838
|
+
const removed = [];
|
|
3839
|
+
const drop = this.db.transaction((ids) => {
|
|
3840
|
+
for (const id of ids) {
|
|
3841
|
+
this.stmts.deleteTailById.run(id);
|
|
3842
|
+
this.stmts.deleteById.run(id);
|
|
3843
|
+
}
|
|
3844
|
+
});
|
|
3845
|
+
for (const row of rows) {
|
|
3846
|
+
if (livePaths.has(row.file_path)) continue;
|
|
3847
|
+
if (exists(row.file_path)) continue;
|
|
3848
|
+
removed.push(row.id);
|
|
3849
|
+
}
|
|
3850
|
+
if (removed.length > 0) {
|
|
3851
|
+
drop(removed);
|
|
3852
|
+
if (this.fileIndexLoaded) {
|
|
3853
|
+
for (const id of removed) {
|
|
3854
|
+
for (const [fp, cid] of this.fileIndex) {
|
|
3855
|
+
if (cid === id) {
|
|
3856
|
+
this.fileIndex.delete(fp);
|
|
3857
|
+
break;
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
return removed;
|
|
3864
|
+
}
|
|
3810
3865
|
};
|
|
3811
3866
|
|
|
3812
3867
|
// src/db/repositories/cacheMetadata.repository.ts
|
|
@@ -4166,6 +4221,19 @@ var ConversationWatcher = class {
|
|
|
4166
4221
|
void entry.watcher.close();
|
|
4167
4222
|
this.files.delete(filePath);
|
|
4168
4223
|
}
|
|
4224
|
+
/**
|
|
4225
|
+
* Re-drive the tail read for a file that's already being tailed. A per-file
|
|
4226
|
+
* chokidar handle can die silently (fs.watch stops firing after inode churn)
|
|
4227
|
+
* while the coarser directory watcher keeps reporting changes — calling this
|
|
4228
|
+
* from the directory-event path makes the tail self-healing. Reads are
|
|
4229
|
+
* offset-based and coalesced, so a redundant poke after a normal change
|
|
4230
|
+
* event is a cheap stat + no-op. Returns false for untailed paths.
|
|
4231
|
+
*/
|
|
4232
|
+
poke(filePath) {
|
|
4233
|
+
if (!this.files.has(filePath)) return false;
|
|
4234
|
+
void this.readNewLines(filePath);
|
|
4235
|
+
return true;
|
|
4236
|
+
}
|
|
4169
4237
|
/**
|
|
4170
4238
|
* Watch a directory of conversation JSONL files. Fires
|
|
4171
4239
|
* onConversationChanged for any add/change/unlink event so the caller
|
|
@@ -4871,6 +4939,14 @@ var StreamerServer = class {
|
|
|
4871
4939
|
// window so the self-healing kickstart-relaunch race doesn't spam warn.
|
|
4872
4940
|
binding = false;
|
|
4873
4941
|
cacheReady = false;
|
|
4942
|
+
// Every fire-and-forget task that runs a scan and then writes to this.cache
|
|
4943
|
+
// in an async continuation (startup warm-up, background count refresh, …).
|
|
4944
|
+
// close() awaits all of them before closing this.cache, so a scan's post-scan
|
|
4945
|
+
// cache writes (upsertFromScannerMeta / populateTailFromFile / pruneGhostFiles
|
|
4946
|
+
// / reconcileDeletions) can never hit a cache.db that was already closed
|
|
4947
|
+
// ("database connection is not open"), which would otherwise leave the cache
|
|
4948
|
+
// empty. Register via trackCacheWrite(); each entry removes itself on settle.
|
|
4949
|
+
inFlightCacheWrites = /* @__PURE__ */ new Set();
|
|
4874
4950
|
apiKey;
|
|
4875
4951
|
apiKeySource;
|
|
4876
4952
|
localNoAuth;
|
|
@@ -5002,6 +5078,7 @@ var StreamerServer = class {
|
|
|
5002
5078
|
}
|
|
5003
5079
|
},
|
|
5004
5080
|
onConversationChanged: (filePath) => {
|
|
5081
|
+
this.fileWatcher.poke(filePath);
|
|
5005
5082
|
this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
|
|
5006
5083
|
this.markScannerStaleDebounced();
|
|
5007
5084
|
this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
|
|
@@ -5438,6 +5515,7 @@ var StreamerServer = class {
|
|
|
5438
5515
|
});
|
|
5439
5516
|
}
|
|
5440
5517
|
});
|
|
5518
|
+
this.trackCacheWrite(warmUp);
|
|
5441
5519
|
if (opts?.awaitReady) await warmUp;
|
|
5442
5520
|
}
|
|
5443
5521
|
// Bind the HTTP listener, retrying on a transient EADDRINUSE. See the call
|
|
@@ -5485,11 +5563,23 @@ var StreamerServer = class {
|
|
|
5485
5563
|
}
|
|
5486
5564
|
}
|
|
5487
5565
|
}
|
|
5566
|
+
// Register a fire-and-forget task that writes to this.cache after a scan, so
|
|
5567
|
+
// close() can await it before closing cache.db. Removes itself on settle. The
|
|
5568
|
+
// caller keeps its own error handling; this wrapper swallows rejections so a
|
|
5569
|
+
// failed task never rejects close()'s Promise.all.
|
|
5570
|
+
trackCacheWrite(task) {
|
|
5571
|
+
const guarded = task.catch(() => void 0);
|
|
5572
|
+
this.inFlightCacheWrites.add(guarded);
|
|
5573
|
+
void guarded.finally(() => {
|
|
5574
|
+
this.inFlightCacheWrites.delete(guarded);
|
|
5575
|
+
});
|
|
5576
|
+
}
|
|
5488
5577
|
async close() {
|
|
5489
5578
|
for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
|
|
5490
5579
|
this.ptyGraceTimers.clear();
|
|
5491
5580
|
this.markScannerStaleDebounced.cancel();
|
|
5492
|
-
|
|
5581
|
+
await Promise.all([...this.inFlightCacheWrites]);
|
|
5582
|
+
await Promise.all([...this.allScanners].map((s) => s.close()));
|
|
5493
5583
|
this.allScanners.clear();
|
|
5494
5584
|
this.scanner = null;
|
|
5495
5585
|
this.cache?.close();
|
|
@@ -5629,12 +5719,23 @@ var StreamerServer = class {
|
|
|
5629
5719
|
const project = url.searchParams.get("project") ?? void 0;
|
|
5630
5720
|
const providerFilter = url.searchParams.get("provider") ?? void 0;
|
|
5631
5721
|
const bustCache = url.searchParams.get("refresh") === "1";
|
|
5632
|
-
if (bustCache) {
|
|
5633
|
-
this.
|
|
5634
|
-
|
|
5635
|
-
|
|
5722
|
+
if (bustCache && this.cache) {
|
|
5723
|
+
const scanner2 = await this.rescanForRefresh();
|
|
5724
|
+
const metas2 = [...scanner2.getMetadataCache().values()];
|
|
5725
|
+
try {
|
|
5726
|
+
this.cache.upsertFromScannerMeta(metas2);
|
|
5727
|
+
const livePaths = new Set(
|
|
5728
|
+
metas2.map((m) => m.filePath).filter((p) => Boolean(p))
|
|
5729
|
+
);
|
|
5730
|
+
this.cache.reconcileDeletions(livePaths);
|
|
5731
|
+
} catch (err) {
|
|
5732
|
+
this.log.warn(
|
|
5733
|
+
`refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
5734
|
+
{ event: "conversations.reconcile_failed" }
|
|
5735
|
+
);
|
|
5736
|
+
}
|
|
5636
5737
|
}
|
|
5637
|
-
if (this.cache
|
|
5738
|
+
if (this.cache) {
|
|
5638
5739
|
const { conversations, total: total2 } = this.cache.listConversations({
|
|
5639
5740
|
project,
|
|
5640
5741
|
provider: providerFilter,
|
|
@@ -5699,12 +5800,6 @@ var StreamerServer = class {
|
|
|
5699
5800
|
};
|
|
5700
5801
|
});
|
|
5701
5802
|
json(res, 200, { conversations: adapted, hasMore: offset + limit < total, offset, total });
|
|
5702
|
-
if (this.cache && bustCache) {
|
|
5703
|
-
try {
|
|
5704
|
-
this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
|
|
5705
|
-
} catch {
|
|
5706
|
-
}
|
|
5707
|
-
}
|
|
5708
5803
|
}
|
|
5709
5804
|
async handleConversationsCount(url, res) {
|
|
5710
5805
|
const project = url.searchParams.get("project") ?? void 0;
|
|
@@ -5733,19 +5828,21 @@ var StreamerServer = class {
|
|
|
5733
5828
|
// later count reflects new/removed conversations. Never awaited by the request
|
|
5734
5829
|
// path — refresh=1 returns the cached total synchronously and this catches up.
|
|
5735
5830
|
refreshCountInBackground() {
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
this.cache
|
|
5831
|
+
this.trackCacheWrite(
|
|
5832
|
+
(async () => {
|
|
5833
|
+
try {
|
|
5834
|
+
const scanner = await this.getFreshScanner();
|
|
5835
|
+
if (this.cache) {
|
|
5836
|
+
this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
|
|
5837
|
+
}
|
|
5838
|
+
} catch (err) {
|
|
5839
|
+
this.log.warn(
|
|
5840
|
+
`Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
5841
|
+
{ event: "count.refresh_failed" }
|
|
5842
|
+
);
|
|
5741
5843
|
}
|
|
5742
|
-
}
|
|
5743
|
-
|
|
5744
|
-
`Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
5745
|
-
{ event: "count.refresh_failed" }
|
|
5746
|
-
);
|
|
5747
|
-
}
|
|
5748
|
-
})();
|
|
5844
|
+
})()
|
|
5845
|
+
);
|
|
5749
5846
|
}
|
|
5750
5847
|
handleSessionsCount(res) {
|
|
5751
5848
|
json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
|
|
@@ -5858,6 +5955,30 @@ var StreamerServer = class {
|
|
|
5858
5955
|
this.scannerReady = null;
|
|
5859
5956
|
return this.getScanner();
|
|
5860
5957
|
}
|
|
5958
|
+
// refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
|
|
5959
|
+
// survive, so classify() still skips unchanged files) and re-run its scan
|
|
5960
|
+
// with fullRescan:true — the escape hatch that bypasses the scanner's
|
|
5961
|
+
// dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
|
|
5962
|
+
// the "don't trust the gate, check disk for real" signal. Unlike
|
|
5963
|
+
// getFreshScanner() this does NOT discard the warm scanner. scannerReady is
|
|
5964
|
+
// only ever reassigned to a live scan promise (never nulled mid-scan), so the
|
|
5965
|
+
// getScanner() anti-infinite-loop guard is preserved.
|
|
5966
|
+
async rescanForRefresh() {
|
|
5967
|
+
if (this.scannerReady) await this.scannerReady;
|
|
5968
|
+
this.scannerStale = false;
|
|
5969
|
+
if (!this.scanner) {
|
|
5970
|
+
this.scanner = new import_scanner2.ConversationScanner();
|
|
5971
|
+
this.allScanners.add(this.scanner);
|
|
5972
|
+
}
|
|
5973
|
+
const scanner = this.scanner;
|
|
5974
|
+
this.scannerReady = scanner.scan({
|
|
5975
|
+
...this.scanProfiles ? { profiles: this.scanProfiles } : {},
|
|
5976
|
+
...this.codexScanOpts(),
|
|
5977
|
+
fullRescan: true
|
|
5978
|
+
});
|
|
5979
|
+
await this.scannerReady;
|
|
5980
|
+
return scanner;
|
|
5981
|
+
}
|
|
5861
5982
|
findJsonlPath(uuid) {
|
|
5862
5983
|
const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
|
|
5863
5984
|
if (!(0, import_fs12.existsSync)(projectsDir)) return null;
|