@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/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
import pino2 from "pino";
|
|
6
6
|
|
|
7
7
|
// package.json
|
|
8
|
-
var version = "0.
|
|
8
|
+
var version = "0.9.1";
|
|
9
9
|
|
|
10
10
|
// src/logger.ts
|
|
11
11
|
import pino from "pino";
|
|
@@ -909,33 +909,33 @@ function fingerprint(filePath, size) {
|
|
|
909
909
|
return hash.digest("hex");
|
|
910
910
|
}
|
|
911
911
|
function classify(filePath, existing) {
|
|
912
|
-
let
|
|
912
|
+
let stat4;
|
|
913
913
|
try {
|
|
914
914
|
const s = statSync(filePath);
|
|
915
|
-
|
|
915
|
+
stat4 = { size: s.size, mtimeMs: s.mtimeMs };
|
|
916
916
|
} catch {
|
|
917
917
|
return { change: "vanished" };
|
|
918
918
|
}
|
|
919
919
|
if (!existing || existing.status !== "active" || existing.last_indexed_offset === 0) {
|
|
920
|
-
return { change: "reindex", stat:
|
|
920
|
+
return { change: "reindex", stat: stat4 };
|
|
921
921
|
}
|
|
922
|
-
if (
|
|
923
|
-
return { change: "reindex", stat:
|
|
922
|
+
if (stat4.size < existing.last_indexed_offset) {
|
|
923
|
+
return { change: "reindex", stat: stat4 };
|
|
924
924
|
}
|
|
925
|
-
if (
|
|
926
|
-
return { change: "unchanged", stat:
|
|
925
|
+
if (stat4.size === existing.size_bytes && stat4.mtimeMs === existing.mtime_ms) {
|
|
926
|
+
return { change: "unchanged", stat: stat4 };
|
|
927
927
|
}
|
|
928
|
-
if (
|
|
929
|
-
const fp = fingerprint(filePath,
|
|
928
|
+
if (stat4.size === existing.last_indexed_offset) {
|
|
929
|
+
const fp = fingerprint(filePath, stat4.size);
|
|
930
930
|
if (existing.content_fingerprint && fp !== existing.content_fingerprint) {
|
|
931
|
-
return { change: "reindex", stat:
|
|
931
|
+
return { change: "reindex", stat: stat4 };
|
|
932
932
|
}
|
|
933
|
-
return { change: "unchanged", stat:
|
|
933
|
+
return { change: "unchanged", stat: stat4 };
|
|
934
934
|
}
|
|
935
935
|
if (existing.content_fingerprint && existing.size_bytes === existing.last_indexed_offset && fingerprint(filePath, existing.last_indexed_offset) === existing.content_fingerprint) {
|
|
936
|
-
return { change: "appended", stat:
|
|
936
|
+
return { change: "appended", stat: stat4 };
|
|
937
937
|
}
|
|
938
|
-
return { change: "reindex", stat:
|
|
938
|
+
return { change: "reindex", stat: stat4 };
|
|
939
939
|
}
|
|
940
940
|
|
|
941
941
|
// src/providers/codex-cli.ts
|
|
@@ -1220,7 +1220,7 @@ import { mkdirSync } from "fs";
|
|
|
1220
1220
|
import { dirname as dirname3 } from "path";
|
|
1221
1221
|
|
|
1222
1222
|
// src/persistent/schema.ts
|
|
1223
|
-
var SCHEMA_VERSION =
|
|
1223
|
+
var SCHEMA_VERSION = 4;
|
|
1224
1224
|
var SCHEMA_SQL = `
|
|
1225
1225
|
CREATE TABLE IF NOT EXISTS conversation_files (
|
|
1226
1226
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -1354,6 +1354,24 @@ CREATE TABLE IF NOT EXISTS message_checkpoints (
|
|
|
1354
1354
|
|
|
1355
1355
|
CREATE INDEX IF NOT EXISTS idx_message_checkpoints_lookup
|
|
1356
1356
|
ON message_checkpoints(source_path, message_index);
|
|
1357
|
+
|
|
1358
|
+
-- Per-directory mtime watermark for the discovery dir-mtime gate (skips the
|
|
1359
|
+
-- glob for a directory whose file/subdir SET hasn't changed; never skips
|
|
1360
|
+
-- per-file classify()). One row for a profile's projectsDir itself (parent_root
|
|
1361
|
+
-- IS NULL) and one row per immediate project subdirectory discovered under it
|
|
1362
|
+
-- (parent_root = the projectsDir path). has_nested marks a project dir known to
|
|
1363
|
+
-- contain files below its own top level (e.g. subagents/) \u2014 those always
|
|
1364
|
+
-- re-glob regardless of mtime, since a project-dir-level watermark can't see a
|
|
1365
|
+
-- change two levels down.
|
|
1366
|
+
CREATE TABLE IF NOT EXISTS scanned_dirs (
|
|
1367
|
+
path TEXT PRIMARY KEY,
|
|
1368
|
+
parent_root TEXT,
|
|
1369
|
+
mtime_ms INTEGER NOT NULL,
|
|
1370
|
+
has_nested INTEGER NOT NULL DEFAULT 0,
|
|
1371
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
CREATE INDEX IF NOT EXISTS idx_scanned_dirs_parent_root ON scanned_dirs(parent_root);
|
|
1357
1375
|
`;
|
|
1358
1376
|
|
|
1359
1377
|
// src/persistent/migrations.ts
|
|
@@ -1404,6 +1422,82 @@ function openDatabase(dbPath) {
|
|
|
1404
1422
|
return db;
|
|
1405
1423
|
}
|
|
1406
1424
|
|
|
1425
|
+
// src/persistent/dir-watermark.ts
|
|
1426
|
+
import { readdir, stat as stat3 } from "fs/promises";
|
|
1427
|
+
var FULL_RECONCILE_EVERY_N_SCANS = 20;
|
|
1428
|
+
async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
|
|
1429
|
+
const log = getLogger();
|
|
1430
|
+
if (options.fullRescan) {
|
|
1431
|
+
return discoverJsonlFiles(dirs);
|
|
1432
|
+
}
|
|
1433
|
+
const results = [];
|
|
1434
|
+
for (const rawDir of dirs) {
|
|
1435
|
+
const { account } = rawDir;
|
|
1436
|
+
const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
|
|
1437
|
+
const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
|
|
1438
|
+
if (resolved === null) continue;
|
|
1439
|
+
for (const projectDir of resolved.entries) {
|
|
1440
|
+
let dirStat;
|
|
1441
|
+
try {
|
|
1442
|
+
dirStat = await stat3(projectDir);
|
|
1443
|
+
} catch {
|
|
1444
|
+
scannedDirs.remove(projectDir);
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
const watermark = scannedDirs.get(projectDir);
|
|
1448
|
+
const canReuse = watermark !== void 0 && watermark.mtime_ms === dirStat.mtimeMs && watermark.has_nested === 0;
|
|
1449
|
+
if (canReuse) {
|
|
1450
|
+
for (const row of files.activePathsByParentDir(projectDir)) {
|
|
1451
|
+
results.push({ filePath: row.absolute_path, account: row.account });
|
|
1452
|
+
}
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const found = await discoverJsonlFiles([{ projectsDir: projectDir, account }]);
|
|
1456
|
+
const hasNested = found.some((f) => dirnameOf(f.filePath) !== projectDir);
|
|
1457
|
+
scannedDirs.upsert(projectDir, projectsDir, dirStat.mtimeMs, hasNested);
|
|
1458
|
+
results.push(...found);
|
|
1459
|
+
}
|
|
1460
|
+
if (resolved.commitRoot) {
|
|
1461
|
+
scannedDirs.upsert(projectsDir, null, resolved.commitRoot.mtimeMs, false);
|
|
1462
|
+
const seen = new Set(resolved.entries);
|
|
1463
|
+
for (const known of scannedDirs.childrenOf(projectsDir)) {
|
|
1464
|
+
if (!seen.has(known.path)) scannedDirs.remove(known.path);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
log.debug(
|
|
1469
|
+
{ totalFiles: results.length, dirs: dirs.length },
|
|
1470
|
+
"dir-watermark: gated discovery complete"
|
|
1471
|
+
);
|
|
1472
|
+
return results;
|
|
1473
|
+
}
|
|
1474
|
+
async function resolveProjectDirs(projectsDir, scannedDirs) {
|
|
1475
|
+
let rootStat;
|
|
1476
|
+
try {
|
|
1477
|
+
rootStat = await stat3(projectsDir);
|
|
1478
|
+
} catch {
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
const rootWatermark = scannedDirs.get(projectsDir);
|
|
1482
|
+
if (rootWatermark !== void 0 && rootWatermark.mtime_ms === rootStat.mtimeMs) {
|
|
1483
|
+
return { entries: scannedDirs.childrenOf(projectsDir).map((row) => row.path) };
|
|
1484
|
+
}
|
|
1485
|
+
let entries;
|
|
1486
|
+
try {
|
|
1487
|
+
entries = (await readdir(projectsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => joinPath(projectsDir, e.name));
|
|
1488
|
+
} catch {
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
|
|
1492
|
+
}
|
|
1493
|
+
function dirnameOf(filePath) {
|
|
1494
|
+
const idx = filePath.lastIndexOf("/");
|
|
1495
|
+
return idx === -1 ? filePath : filePath.slice(0, idx);
|
|
1496
|
+
}
|
|
1497
|
+
function joinPath(dir, name) {
|
|
1498
|
+
return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1407
1501
|
// src/persistent/jsonl-tail-reader.ts
|
|
1408
1502
|
import { createReadStream as createReadStream4 } from "fs";
|
|
1409
1503
|
async function tailReduce(filePath, startOffset, startLine, state, tier) {
|
|
@@ -1610,6 +1704,14 @@ var ConversationFilesRepo = class {
|
|
|
1610
1704
|
const rows = this.db.prepare("SELECT absolute_path FROM conversation_files WHERE status != 'deleted'").all();
|
|
1611
1705
|
return rows.map((r) => r.absolute_path);
|
|
1612
1706
|
}
|
|
1707
|
+
// Active files whose immediate parent is exactly parentDir (no nested
|
|
1708
|
+
// subdirectories). Backs the dir-mtime gate's reuse path: a project dir with
|
|
1709
|
+
// an unchanged mtime and no nested files can skip the glob entirely.
|
|
1710
|
+
activePathsByParentDir(parentDir) {
|
|
1711
|
+
return this.db.prepare(
|
|
1712
|
+
"SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
|
|
1713
|
+
).all(parentDir);
|
|
1714
|
+
}
|
|
1613
1715
|
};
|
|
1614
1716
|
|
|
1615
1717
|
// src/persistent/repositories/conversations.repo.ts
|
|
@@ -1858,6 +1960,36 @@ function toMatchQuery(query) {
|
|
|
1858
1960
|
return terms.map((t) => `"${t}"*`).join(" AND ");
|
|
1859
1961
|
}
|
|
1860
1962
|
|
|
1963
|
+
// src/persistent/repositories/scanned-dirs.repo.ts
|
|
1964
|
+
var ScannedDirsRepo = class {
|
|
1965
|
+
constructor(db) {
|
|
1966
|
+
this.db = db;
|
|
1967
|
+
}
|
|
1968
|
+
db;
|
|
1969
|
+
get(path) {
|
|
1970
|
+
return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path);
|
|
1971
|
+
}
|
|
1972
|
+
// Known project subdirectories under a root, path ascending (stable order).
|
|
1973
|
+
childrenOf(parentRoot) {
|
|
1974
|
+
return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
|
|
1975
|
+
}
|
|
1976
|
+
upsert(path, parentRoot, mtimeMs, hasNested) {
|
|
1977
|
+
this.db.prepare(
|
|
1978
|
+
`INSERT INTO scanned_dirs (path, parent_root, mtime_ms, has_nested, updated_at)
|
|
1979
|
+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
1980
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
1981
|
+
parent_root = excluded.parent_root,
|
|
1982
|
+
mtime_ms = excluded.mtime_ms,
|
|
1983
|
+
has_nested = excluded.has_nested,
|
|
1984
|
+
updated_at = CURRENT_TIMESTAMP`
|
|
1985
|
+
).run(path, parentRoot, mtimeMs, hasNested ? 1 : 0);
|
|
1986
|
+
}
|
|
1987
|
+
// Drop a project subdirectory's watermark row (it vanished from disk).
|
|
1988
|
+
remove(path) {
|
|
1989
|
+
this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path);
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1861
1993
|
// src/persistent/sidecar.ts
|
|
1862
1994
|
import { readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
1863
1995
|
var SIDECAR_VERSION = 1;
|
|
@@ -1899,15 +2031,22 @@ var PersistentEngine = class {
|
|
|
1899
2031
|
conversations;
|
|
1900
2032
|
fts;
|
|
1901
2033
|
checkpoints;
|
|
2034
|
+
scannedDirs;
|
|
1902
2035
|
// When true, write a portable <file>.idx.json sidecar next to each indexed
|
|
1903
2036
|
// JSONL. Off by default.
|
|
1904
2037
|
sidecar;
|
|
2038
|
+
// Counts indexAll() passes so the dir-mtime gate's full-reconcile backstop
|
|
2039
|
+
// (FULL_RECONCILE_EVERY_N_SCANS) can fire periodically. In-memory only: a
|
|
2040
|
+
// restart just means the first few post-restart scans don't force an early
|
|
2041
|
+
// backstop pass, which is harmless (watermarks themselves persist in the DB).
|
|
2042
|
+
scanCount = 0;
|
|
1905
2043
|
constructor(dbPath, options = {}) {
|
|
1906
2044
|
this.db = openDatabase(dbPath);
|
|
1907
2045
|
this.files = new ConversationFilesRepo(this.db);
|
|
1908
2046
|
this.conversations = new ConversationsRepo(this.db);
|
|
1909
2047
|
this.fts = new FtsRepo(this.db);
|
|
1910
2048
|
this.checkpoints = new CheckpointsRepo(this.db);
|
|
2049
|
+
this.scannedDirs = new ScannedDirsRepo(this.db);
|
|
1911
2050
|
this.sidecar = options.sidecar ?? false;
|
|
1912
2051
|
}
|
|
1913
2052
|
close() {
|
|
@@ -1926,7 +2065,12 @@ var PersistentEngine = class {
|
|
|
1926
2065
|
projectsDir: getProjectsDir(p),
|
|
1927
2066
|
account: p.id
|
|
1928
2067
|
}));
|
|
1929
|
-
|
|
2068
|
+
this.scanCount++;
|
|
2069
|
+
const forceFullGlob = options.fullRescan === true || this.scanCount % FULL_RECONCILE_EVERY_N_SCANS === 0;
|
|
2070
|
+
const gated = await discoverJsonlFilesGated(configDirs, this.files, this.scannedDirs, {
|
|
2071
|
+
fullRescan: forceFullGlob
|
|
2072
|
+
});
|
|
2073
|
+
for (const f of gated) discovered.push(f);
|
|
1930
2074
|
}
|
|
1931
2075
|
const codex = new CodexCliProvider();
|
|
1932
2076
|
if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
|
|
@@ -1981,8 +2125,8 @@ var PersistentEngine = class {
|
|
|
1981
2125
|
const log = getLogger();
|
|
1982
2126
|
const tier = resolveTier(tierName, customTiers);
|
|
1983
2127
|
const existing = this.files.getByPath(filePath);
|
|
1984
|
-
const { change, stat:
|
|
1985
|
-
if (change === "vanished" || !
|
|
2128
|
+
const { change, stat: stat4 } = classify(filePath, existing);
|
|
2129
|
+
if (change === "vanished" || !stat4) {
|
|
1986
2130
|
this.markDeleted(filePath);
|
|
1987
2131
|
return null;
|
|
1988
2132
|
}
|
|
@@ -1990,7 +2134,7 @@ var PersistentEngine = class {
|
|
|
1990
2134
|
return this.conversations.getBySourcePath(filePath);
|
|
1991
2135
|
}
|
|
1992
2136
|
if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
|
|
1993
|
-
return this.indexFileWithProvider(provider, filePath, account, tier,
|
|
2137
|
+
return this.indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch);
|
|
1994
2138
|
}
|
|
1995
2139
|
const resume = change === "appended" && !force && existing?.reducer_state;
|
|
1996
2140
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
@@ -2009,15 +2153,15 @@ var PersistentEngine = class {
|
|
|
2009
2153
|
return null;
|
|
2010
2154
|
}
|
|
2011
2155
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2012
|
-
const fp =
|
|
2156
|
+
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2013
2157
|
const fileId = this.files.ensure(filePath, account);
|
|
2014
2158
|
const upsert = this.db.transaction(() => {
|
|
2015
2159
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2016
2160
|
this.fts.upsert(meta);
|
|
2017
2161
|
this.checkpoints.remove(filePath);
|
|
2018
2162
|
this.files.updateCursor(fileId, {
|
|
2019
|
-
sizeBytes:
|
|
2020
|
-
mtimeMs:
|
|
2163
|
+
sizeBytes: stat4.size,
|
|
2164
|
+
mtimeMs: stat4.mtimeMs,
|
|
2021
2165
|
// Advance only to the last fully-parsed line; a trailing partial line
|
|
2022
2166
|
// is left for the next pass.
|
|
2023
2167
|
offset: result.newOffset,
|
|
@@ -2034,8 +2178,8 @@ var PersistentEngine = class {
|
|
|
2034
2178
|
buildSidecar(
|
|
2035
2179
|
meta,
|
|
2036
2180
|
{
|
|
2037
|
-
sizeBytes:
|
|
2038
|
-
mtimeMs:
|
|
2181
|
+
sizeBytes: stat4.size,
|
|
2182
|
+
mtimeMs: stat4.mtimeMs,
|
|
2039
2183
|
offset: result.newOffset,
|
|
2040
2184
|
line: result.newLine
|
|
2041
2185
|
},
|
|
@@ -2054,7 +2198,7 @@ var PersistentEngine = class {
|
|
|
2054
2198
|
// the Threadbase path uses. The cursor records size/mtime (and offset = size)
|
|
2055
2199
|
// so the next pass classifies an unchanged file as "unchanged" and skips it;
|
|
2056
2200
|
// any change reparses from 0 again. No reducer_state is persisted.
|
|
2057
|
-
async indexFileWithProvider(provider, filePath, account, tier,
|
|
2201
|
+
async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
|
|
2058
2202
|
const log = getLogger();
|
|
2059
2203
|
const meta = await parseMetaWithProvider(provider, filePath, account, tier);
|
|
2060
2204
|
if (!meta) {
|
|
@@ -2064,18 +2208,18 @@ var PersistentEngine = class {
|
|
|
2064
2208
|
if (meta.gitBranch === null && meta.projectPath) {
|
|
2065
2209
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2066
2210
|
}
|
|
2067
|
-
const fp =
|
|
2211
|
+
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2068
2212
|
const fileId = this.files.ensure(filePath, account);
|
|
2069
2213
|
const upsert = this.db.transaction(() => {
|
|
2070
2214
|
this.conversations.upsert(fileId, meta, meta.messageCount);
|
|
2071
2215
|
this.fts.upsert(meta);
|
|
2072
2216
|
this.checkpoints.remove(filePath);
|
|
2073
2217
|
this.files.updateCursor(fileId, {
|
|
2074
|
-
sizeBytes:
|
|
2075
|
-
mtimeMs:
|
|
2218
|
+
sizeBytes: stat4.size,
|
|
2219
|
+
mtimeMs: stat4.mtimeMs,
|
|
2076
2220
|
// offset = size marks the file fully consumed (non-zero so the next pass
|
|
2077
2221
|
// can classify it "unchanged"); no resumable reducer state is kept.
|
|
2078
|
-
offset:
|
|
2222
|
+
offset: stat4.size,
|
|
2079
2223
|
line: 0,
|
|
2080
2224
|
reducerState: null,
|
|
2081
2225
|
fingerprint: fp,
|