@threadbase-sh/scanner 0.8.4 → 0.9.1
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.js +173 -29
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +172 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +172 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -83,6 +83,11 @@ interface ScanOptions {
|
|
|
83
83
|
stat: FileStatEntry;
|
|
84
84
|
meta: ConversationMeta;
|
|
85
85
|
}>;
|
|
86
|
+
/** Persistent mode only: bypass the dir-mtime discovery gate and force a
|
|
87
|
+
* full glob of every profile's projectsDir, ignoring stored watermarks.
|
|
88
|
+
* Use for an explicit user-triggered refresh that shouldn't trust the
|
|
89
|
+
* gate's optimization. */
|
|
90
|
+
fullRescan?: boolean;
|
|
86
91
|
}
|
|
87
92
|
interface ScanResult {
|
|
88
93
|
conversations: ConversationMeta[] | TreeConversation[] | GroupedConversations;
|
package/dist/index.d.ts
CHANGED
|
@@ -83,6 +83,11 @@ interface ScanOptions {
|
|
|
83
83
|
stat: FileStatEntry;
|
|
84
84
|
meta: ConversationMeta;
|
|
85
85
|
}>;
|
|
86
|
+
/** Persistent mode only: bypass the dir-mtime discovery gate and force a
|
|
87
|
+
* full glob of every profile's projectsDir, ignoring stored watermarks.
|
|
88
|
+
* Use for an explicit user-triggered refresh that shouldn't trust the
|
|
89
|
+
* gate's optimization. */
|
|
90
|
+
fullRescan?: boolean;
|
|
86
91
|
}
|
|
87
92
|
interface ScanResult {
|
|
88
93
|
conversations: ConversationMeta[] | TreeConversation[] | GroupedConversations;
|
package/dist/index.js
CHANGED
|
@@ -1215,33 +1215,33 @@ function fingerprint(filePath, size) {
|
|
|
1215
1215
|
return hash.digest("hex");
|
|
1216
1216
|
}
|
|
1217
1217
|
function classify(filePath, existing) {
|
|
1218
|
-
let
|
|
1218
|
+
let stat4;
|
|
1219
1219
|
try {
|
|
1220
1220
|
const s = statSync(filePath);
|
|
1221
|
-
|
|
1221
|
+
stat4 = { size: s.size, mtimeMs: s.mtimeMs };
|
|
1222
1222
|
} catch {
|
|
1223
1223
|
return { change: "vanished" };
|
|
1224
1224
|
}
|
|
1225
1225
|
if (!existing || existing.status !== "active" || existing.last_indexed_offset === 0) {
|
|
1226
|
-
return { change: "reindex", stat:
|
|
1226
|
+
return { change: "reindex", stat: stat4 };
|
|
1227
1227
|
}
|
|
1228
|
-
if (
|
|
1229
|
-
return { change: "reindex", stat:
|
|
1228
|
+
if (stat4.size < existing.last_indexed_offset) {
|
|
1229
|
+
return { change: "reindex", stat: stat4 };
|
|
1230
1230
|
}
|
|
1231
|
-
if (
|
|
1232
|
-
return { change: "unchanged", stat:
|
|
1231
|
+
if (stat4.size === existing.size_bytes && stat4.mtimeMs === existing.mtime_ms) {
|
|
1232
|
+
return { change: "unchanged", stat: stat4 };
|
|
1233
1233
|
}
|
|
1234
|
-
if (
|
|
1235
|
-
const fp = fingerprint(filePath,
|
|
1234
|
+
if (stat4.size === existing.last_indexed_offset) {
|
|
1235
|
+
const fp = fingerprint(filePath, stat4.size);
|
|
1236
1236
|
if (existing.content_fingerprint && fp !== existing.content_fingerprint) {
|
|
1237
|
-
return { change: "reindex", stat:
|
|
1237
|
+
return { change: "reindex", stat: stat4 };
|
|
1238
1238
|
}
|
|
1239
|
-
return { change: "unchanged", stat:
|
|
1239
|
+
return { change: "unchanged", stat: stat4 };
|
|
1240
1240
|
}
|
|
1241
1241
|
if (existing.content_fingerprint && existing.size_bytes === existing.last_indexed_offset && fingerprint(filePath, existing.last_indexed_offset) === existing.content_fingerprint) {
|
|
1242
|
-
return { change: "appended", stat:
|
|
1242
|
+
return { change: "appended", stat: stat4 };
|
|
1243
1243
|
}
|
|
1244
|
-
return { change: "reindex", stat:
|
|
1244
|
+
return { change: "reindex", stat: stat4 };
|
|
1245
1245
|
}
|
|
1246
1246
|
|
|
1247
1247
|
// src/providers/parse.ts
|
|
@@ -1297,7 +1297,7 @@ import { mkdirSync } from "fs";
|
|
|
1297
1297
|
import { dirname as dirname3 } from "path";
|
|
1298
1298
|
|
|
1299
1299
|
// src/persistent/schema.ts
|
|
1300
|
-
var SCHEMA_VERSION =
|
|
1300
|
+
var SCHEMA_VERSION = 4;
|
|
1301
1301
|
var SCHEMA_SQL = `
|
|
1302
1302
|
CREATE TABLE IF NOT EXISTS conversation_files (
|
|
1303
1303
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -1431,6 +1431,24 @@ CREATE TABLE IF NOT EXISTS message_checkpoints (
|
|
|
1431
1431
|
|
|
1432
1432
|
CREATE INDEX IF NOT EXISTS idx_message_checkpoints_lookup
|
|
1433
1433
|
ON message_checkpoints(source_path, message_index);
|
|
1434
|
+
|
|
1435
|
+
-- Per-directory mtime watermark for the discovery dir-mtime gate (skips the
|
|
1436
|
+
-- glob for a directory whose file/subdir SET hasn't changed; never skips
|
|
1437
|
+
-- per-file classify()). One row for a profile's projectsDir itself (parent_root
|
|
1438
|
+
-- IS NULL) and one row per immediate project subdirectory discovered under it
|
|
1439
|
+
-- (parent_root = the projectsDir path). has_nested marks a project dir known to
|
|
1440
|
+
-- contain files below its own top level (e.g. subagents/) \u2014 those always
|
|
1441
|
+
-- re-glob regardless of mtime, since a project-dir-level watermark can't see a
|
|
1442
|
+
-- change two levels down.
|
|
1443
|
+
CREATE TABLE IF NOT EXISTS scanned_dirs (
|
|
1444
|
+
path TEXT PRIMARY KEY,
|
|
1445
|
+
parent_root TEXT,
|
|
1446
|
+
mtime_ms INTEGER NOT NULL,
|
|
1447
|
+
has_nested INTEGER NOT NULL DEFAULT 0,
|
|
1448
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
CREATE INDEX IF NOT EXISTS idx_scanned_dirs_parent_root ON scanned_dirs(parent_root);
|
|
1434
1452
|
`;
|
|
1435
1453
|
|
|
1436
1454
|
// src/persistent/migrations.ts
|
|
@@ -1481,6 +1499,82 @@ function openDatabase(dbPath) {
|
|
|
1481
1499
|
return db;
|
|
1482
1500
|
}
|
|
1483
1501
|
|
|
1502
|
+
// src/persistent/dir-watermark.ts
|
|
1503
|
+
import { readdir, stat as stat3 } from "fs/promises";
|
|
1504
|
+
var FULL_RECONCILE_EVERY_N_SCANS = 20;
|
|
1505
|
+
async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
|
|
1506
|
+
const log = getLogger();
|
|
1507
|
+
if (options.fullRescan) {
|
|
1508
|
+
return discoverJsonlFiles(dirs);
|
|
1509
|
+
}
|
|
1510
|
+
const results = [];
|
|
1511
|
+
for (const rawDir of dirs) {
|
|
1512
|
+
const { account } = rawDir;
|
|
1513
|
+
const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
|
|
1514
|
+
const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
|
|
1515
|
+
if (resolved === null) continue;
|
|
1516
|
+
for (const projectDir of resolved.entries) {
|
|
1517
|
+
let dirStat;
|
|
1518
|
+
try {
|
|
1519
|
+
dirStat = await stat3(projectDir);
|
|
1520
|
+
} catch {
|
|
1521
|
+
scannedDirs.remove(projectDir);
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
const watermark = scannedDirs.get(projectDir);
|
|
1525
|
+
const canReuse = watermark !== void 0 && watermark.mtime_ms === dirStat.mtimeMs && watermark.has_nested === 0;
|
|
1526
|
+
if (canReuse) {
|
|
1527
|
+
for (const row of files.activePathsByParentDir(projectDir)) {
|
|
1528
|
+
results.push({ filePath: row.absolute_path, account: row.account });
|
|
1529
|
+
}
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
const found = await discoverJsonlFiles([{ projectsDir: projectDir, account }]);
|
|
1533
|
+
const hasNested = found.some((f) => dirnameOf(f.filePath) !== projectDir);
|
|
1534
|
+
scannedDirs.upsert(projectDir, projectsDir, dirStat.mtimeMs, hasNested);
|
|
1535
|
+
results.push(...found);
|
|
1536
|
+
}
|
|
1537
|
+
if (resolved.commitRoot) {
|
|
1538
|
+
scannedDirs.upsert(projectsDir, null, resolved.commitRoot.mtimeMs, false);
|
|
1539
|
+
const seen = new Set(resolved.entries);
|
|
1540
|
+
for (const known of scannedDirs.childrenOf(projectsDir)) {
|
|
1541
|
+
if (!seen.has(known.path)) scannedDirs.remove(known.path);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
log.debug(
|
|
1546
|
+
{ totalFiles: results.length, dirs: dirs.length },
|
|
1547
|
+
"dir-watermark: gated discovery complete"
|
|
1548
|
+
);
|
|
1549
|
+
return results;
|
|
1550
|
+
}
|
|
1551
|
+
async function resolveProjectDirs(projectsDir, scannedDirs) {
|
|
1552
|
+
let rootStat;
|
|
1553
|
+
try {
|
|
1554
|
+
rootStat = await stat3(projectsDir);
|
|
1555
|
+
} catch {
|
|
1556
|
+
return null;
|
|
1557
|
+
}
|
|
1558
|
+
const rootWatermark = scannedDirs.get(projectsDir);
|
|
1559
|
+
if (rootWatermark !== void 0 && rootWatermark.mtime_ms === rootStat.mtimeMs) {
|
|
1560
|
+
return { entries: scannedDirs.childrenOf(projectsDir).map((row) => row.path) };
|
|
1561
|
+
}
|
|
1562
|
+
let entries;
|
|
1563
|
+
try {
|
|
1564
|
+
entries = (await readdir(projectsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => joinPath(projectsDir, e.name));
|
|
1565
|
+
} catch {
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
|
|
1569
|
+
}
|
|
1570
|
+
function dirnameOf(filePath) {
|
|
1571
|
+
const idx = filePath.lastIndexOf("/");
|
|
1572
|
+
return idx === -1 ? filePath : filePath.slice(0, idx);
|
|
1573
|
+
}
|
|
1574
|
+
function joinPath(dir, name) {
|
|
1575
|
+
return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1484
1578
|
// src/persistent/jsonl-tail-reader.ts
|
|
1485
1579
|
import { createReadStream as createReadStream4 } from "fs";
|
|
1486
1580
|
async function tailReduce(filePath, startOffset, startLine, state, tier) {
|
|
@@ -1687,6 +1781,14 @@ var ConversationFilesRepo = class {
|
|
|
1687
1781
|
const rows = this.db.prepare("SELECT absolute_path FROM conversation_files WHERE status != 'deleted'").all();
|
|
1688
1782
|
return rows.map((r) => r.absolute_path);
|
|
1689
1783
|
}
|
|
1784
|
+
// Active files whose immediate parent is exactly parentDir (no nested
|
|
1785
|
+
// subdirectories). Backs the dir-mtime gate's reuse path: a project dir with
|
|
1786
|
+
// an unchanged mtime and no nested files can skip the glob entirely.
|
|
1787
|
+
activePathsByParentDir(parentDir) {
|
|
1788
|
+
return this.db.prepare(
|
|
1789
|
+
"SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
|
|
1790
|
+
).all(parentDir);
|
|
1791
|
+
}
|
|
1690
1792
|
};
|
|
1691
1793
|
|
|
1692
1794
|
// src/persistent/repositories/conversations.repo.ts
|
|
@@ -1935,6 +2037,36 @@ function toMatchQuery(query) {
|
|
|
1935
2037
|
return terms.map((t) => `"${t}"*`).join(" AND ");
|
|
1936
2038
|
}
|
|
1937
2039
|
|
|
2040
|
+
// src/persistent/repositories/scanned-dirs.repo.ts
|
|
2041
|
+
var ScannedDirsRepo = class {
|
|
2042
|
+
constructor(db) {
|
|
2043
|
+
this.db = db;
|
|
2044
|
+
}
|
|
2045
|
+
db;
|
|
2046
|
+
get(path) {
|
|
2047
|
+
return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path);
|
|
2048
|
+
}
|
|
2049
|
+
// Known project subdirectories under a root, path ascending (stable order).
|
|
2050
|
+
childrenOf(parentRoot) {
|
|
2051
|
+
return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
|
|
2052
|
+
}
|
|
2053
|
+
upsert(path, parentRoot, mtimeMs, hasNested) {
|
|
2054
|
+
this.db.prepare(
|
|
2055
|
+
`INSERT INTO scanned_dirs (path, parent_root, mtime_ms, has_nested, updated_at)
|
|
2056
|
+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
2057
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
2058
|
+
parent_root = excluded.parent_root,
|
|
2059
|
+
mtime_ms = excluded.mtime_ms,
|
|
2060
|
+
has_nested = excluded.has_nested,
|
|
2061
|
+
updated_at = CURRENT_TIMESTAMP`
|
|
2062
|
+
).run(path, parentRoot, mtimeMs, hasNested ? 1 : 0);
|
|
2063
|
+
}
|
|
2064
|
+
// Drop a project subdirectory's watermark row (it vanished from disk).
|
|
2065
|
+
remove(path) {
|
|
2066
|
+
this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path);
|
|
2067
|
+
}
|
|
2068
|
+
};
|
|
2069
|
+
|
|
1938
2070
|
// src/persistent/index-engine.ts
|
|
1939
2071
|
var BATCH_SIZE = 12;
|
|
1940
2072
|
var PersistentEngine = class {
|
|
@@ -1943,15 +2075,22 @@ var PersistentEngine = class {
|
|
|
1943
2075
|
conversations;
|
|
1944
2076
|
fts;
|
|
1945
2077
|
checkpoints;
|
|
2078
|
+
scannedDirs;
|
|
1946
2079
|
// When true, write a portable <file>.idx.json sidecar next to each indexed
|
|
1947
2080
|
// JSONL. Off by default.
|
|
1948
2081
|
sidecar;
|
|
2082
|
+
// Counts indexAll() passes so the dir-mtime gate's full-reconcile backstop
|
|
2083
|
+
// (FULL_RECONCILE_EVERY_N_SCANS) can fire periodically. In-memory only: a
|
|
2084
|
+
// restart just means the first few post-restart scans don't force an early
|
|
2085
|
+
// backstop pass, which is harmless (watermarks themselves persist in the DB).
|
|
2086
|
+
scanCount = 0;
|
|
1949
2087
|
constructor(dbPath, options = {}) {
|
|
1950
2088
|
this.db = openDatabase(dbPath);
|
|
1951
2089
|
this.files = new ConversationFilesRepo(this.db);
|
|
1952
2090
|
this.conversations = new ConversationsRepo(this.db);
|
|
1953
2091
|
this.fts = new FtsRepo(this.db);
|
|
1954
2092
|
this.checkpoints = new CheckpointsRepo(this.db);
|
|
2093
|
+
this.scannedDirs = new ScannedDirsRepo(this.db);
|
|
1955
2094
|
this.sidecar = options.sidecar ?? false;
|
|
1956
2095
|
}
|
|
1957
2096
|
close() {
|
|
@@ -1970,7 +2109,12 @@ var PersistentEngine = class {
|
|
|
1970
2109
|
projectsDir: getProjectsDir(p),
|
|
1971
2110
|
account: p.id
|
|
1972
2111
|
}));
|
|
1973
|
-
|
|
2112
|
+
this.scanCount++;
|
|
2113
|
+
const forceFullGlob = options.fullRescan === true || this.scanCount % FULL_RECONCILE_EVERY_N_SCANS === 0;
|
|
2114
|
+
const gated = await discoverJsonlFilesGated(configDirs, this.files, this.scannedDirs, {
|
|
2115
|
+
fullRescan: forceFullGlob
|
|
2116
|
+
});
|
|
2117
|
+
for (const f of gated) discovered.push(f);
|
|
1974
2118
|
}
|
|
1975
2119
|
const codex = new CodexCliProvider();
|
|
1976
2120
|
if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
|
|
@@ -2025,8 +2169,8 @@ var PersistentEngine = class {
|
|
|
2025
2169
|
const log = getLogger();
|
|
2026
2170
|
const tier = resolveTier(tierName, customTiers);
|
|
2027
2171
|
const existing = this.files.getByPath(filePath);
|
|
2028
|
-
const { change, stat:
|
|
2029
|
-
if (change === "vanished" || !
|
|
2172
|
+
const { change, stat: stat4 } = classify(filePath, existing);
|
|
2173
|
+
if (change === "vanished" || !stat4) {
|
|
2030
2174
|
this.markDeleted(filePath);
|
|
2031
2175
|
return null;
|
|
2032
2176
|
}
|
|
@@ -2034,7 +2178,7 @@ var PersistentEngine = class {
|
|
|
2034
2178
|
return this.conversations.getBySourcePath(filePath);
|
|
2035
2179
|
}
|
|
2036
2180
|
if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
|
|
2037
|
-
return this.indexFileWithProvider(provider, filePath, account, tier,
|
|
2181
|
+
return this.indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch);
|
|
2038
2182
|
}
|
|
2039
2183
|
const resume = change === "appended" && !force && existing?.reducer_state;
|
|
2040
2184
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
@@ -2053,15 +2197,15 @@ var PersistentEngine = class {
|
|
|
2053
2197
|
return null;
|
|
2054
2198
|
}
|
|
2055
2199
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2056
|
-
const fp =
|
|
2200
|
+
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2057
2201
|
const fileId = this.files.ensure(filePath, account);
|
|
2058
2202
|
const upsert = this.db.transaction(() => {
|
|
2059
2203
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2060
2204
|
this.fts.upsert(meta);
|
|
2061
2205
|
this.checkpoints.remove(filePath);
|
|
2062
2206
|
this.files.updateCursor(fileId, {
|
|
2063
|
-
sizeBytes:
|
|
2064
|
-
mtimeMs:
|
|
2207
|
+
sizeBytes: stat4.size,
|
|
2208
|
+
mtimeMs: stat4.mtimeMs,
|
|
2065
2209
|
// Advance only to the last fully-parsed line; a trailing partial line
|
|
2066
2210
|
// is left for the next pass.
|
|
2067
2211
|
offset: result.newOffset,
|
|
@@ -2078,8 +2222,8 @@ var PersistentEngine = class {
|
|
|
2078
2222
|
buildSidecar(
|
|
2079
2223
|
meta,
|
|
2080
2224
|
{
|
|
2081
|
-
sizeBytes:
|
|
2082
|
-
mtimeMs:
|
|
2225
|
+
sizeBytes: stat4.size,
|
|
2226
|
+
mtimeMs: stat4.mtimeMs,
|
|
2083
2227
|
offset: result.newOffset,
|
|
2084
2228
|
line: result.newLine
|
|
2085
2229
|
},
|
|
@@ -2098,7 +2242,7 @@ var PersistentEngine = class {
|
|
|
2098
2242
|
// the Threadbase path uses. The cursor records size/mtime (and offset = size)
|
|
2099
2243
|
// so the next pass classifies an unchanged file as "unchanged" and skips it;
|
|
2100
2244
|
// any change reparses from 0 again. No reducer_state is persisted.
|
|
2101
|
-
async indexFileWithProvider(provider, filePath, account, tier,
|
|
2245
|
+
async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
|
|
2102
2246
|
const log = getLogger();
|
|
2103
2247
|
const meta = await parseMetaWithProvider(provider, filePath, account, tier);
|
|
2104
2248
|
if (!meta) {
|
|
@@ -2108,18 +2252,18 @@ var PersistentEngine = class {
|
|
|
2108
2252
|
if (meta.gitBranch === null && meta.projectPath) {
|
|
2109
2253
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2110
2254
|
}
|
|
2111
|
-
const fp =
|
|
2255
|
+
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2112
2256
|
const fileId = this.files.ensure(filePath, account);
|
|
2113
2257
|
const upsert = this.db.transaction(() => {
|
|
2114
2258
|
this.conversations.upsert(fileId, meta, meta.messageCount);
|
|
2115
2259
|
this.fts.upsert(meta);
|
|
2116
2260
|
this.checkpoints.remove(filePath);
|
|
2117
2261
|
this.files.updateCursor(fileId, {
|
|
2118
|
-
sizeBytes:
|
|
2119
|
-
mtimeMs:
|
|
2262
|
+
sizeBytes: stat4.size,
|
|
2263
|
+
mtimeMs: stat4.mtimeMs,
|
|
2120
2264
|
// offset = size marks the file fully consumed (non-zero so the next pass
|
|
2121
2265
|
// can classify it "unchanged"); no resumable reducer state is kept.
|
|
2122
|
-
offset:
|
|
2266
|
+
offset: stat4.size,
|
|
2123
2267
|
line: 0,
|
|
2124
2268
|
reducerState: null,
|
|
2125
2269
|
fingerprint: fp,
|