token-goat 2.9.18 → 2.9.20
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/README.md +5 -1
- package/dist/token-goat-chunk-2CLWWRFR.mjs +26 -0
- package/dist/{token-goat-chunk-LTYWLKAG.mjs → token-goat-chunk-2Q3N5KUQ.mjs} +1283 -1012
- package/dist/{token-goat-chunk-6LGVCCMY.mjs → token-goat-chunk-3OGZOFLS.mjs} +26 -16
- package/dist/{token-goat-chunk-QML7ITHJ.mjs → token-goat-chunk-3VTJC7KU.mjs} +693 -438
- package/dist/{token-goat-chunk-QIDLJGXZ.mjs → token-goat-chunk-6LJNM4ZZ.mjs} +29 -5
- package/dist/{token-goat-chunk-CA6V36KW.mjs → token-goat-chunk-74ADIZZX.mjs} +68 -50
- package/dist/{token-goat-chunk-52IMXJ2L.mjs → token-goat-chunk-ASTBLE2R.mjs} +4 -4
- package/dist/token-goat-chunk-AU2K2QHR.mjs +20 -0
- package/dist/token-goat-chunk-BQIC3AVL.mjs +292 -0
- package/dist/token-goat-chunk-CBXLE54Q.mjs +30 -0
- package/dist/{token-goat-chunk-LMFO66YD.mjs → token-goat-chunk-DA43OE7L.mjs} +1 -1
- package/dist/{token-goat-chunk-ARNRFR6M.mjs → token-goat-chunk-DNAF26L4.mjs} +1 -1
- package/dist/{token-goat-chunk-BFEWOMA3.mjs → token-goat-chunk-DPHJU3P7.mjs} +1186 -4573
- package/dist/{token-goat-chunk-OWSJJY5R.mjs → token-goat-chunk-DW4VKU7D.mjs} +5 -5
- package/dist/token-goat-chunk-DYUBZZNP.mjs +198 -0
- package/dist/{token-goat-chunk-W3E65QOW.mjs → token-goat-chunk-ERGHDBC6.mjs} +310 -47
- package/dist/token-goat-chunk-EUZKG7ZO.mjs +304 -0
- package/dist/{token-goat-chunk-5MMJAJF3.mjs → token-goat-chunk-FWYZVS3E.mjs} +324 -31
- package/dist/token-goat-chunk-G6SYQQO7.mjs +41 -0
- package/dist/{token-goat-chunk-YQZE5NS6.mjs → token-goat-chunk-GIPMOGOI.mjs} +6 -6
- package/dist/{token-goat-chunk-BWCRC23L.mjs → token-goat-chunk-H4VC5TR6.mjs} +29 -20
- package/dist/{token-goat-chunk-V3ZWBEHM.mjs → token-goat-chunk-I5PQRHFE.mjs} +103 -24
- package/dist/token-goat-chunk-ITWYDOKP.mjs +341 -0
- package/dist/{token-goat-chunk-I67CPTDD.mjs → token-goat-chunk-K3ZNBN4U.mjs} +19 -14
- package/dist/{token-goat-chunk-HPOFR6QX.mjs → token-goat-chunk-K5XPIUVB.mjs} +222 -61
- package/dist/token-goat-chunk-KT75DZK4.mjs +1989 -0
- package/dist/{token-goat-chunk-H3FONFBO.mjs → token-goat-chunk-MNJWG2ZO.mjs} +3 -3
- package/dist/{token-goat-chunk-GRI6HWX3.mjs → token-goat-chunk-MUJV7BR3.mjs} +1 -1
- package/dist/{token-goat-chunk-ERTXEKB6.mjs → token-goat-chunk-NMTKNYGF.mjs} +2 -0
- package/dist/{token-goat-chunk-LA3O4JGD.mjs → token-goat-chunk-ODF7H7WS.mjs} +3 -3
- package/dist/{token-goat-chunk-K4DG6EEF.mjs → token-goat-chunk-ONMU7BON.mjs} +7 -8
- package/dist/{token-goat-chunk-4EXFN2AW.mjs → token-goat-chunk-OZRSREKR.mjs} +2 -2
- package/dist/{token-goat-chunk-EPXNOKIV.mjs → token-goat-chunk-P4XBHZXU.mjs} +2 -2
- package/dist/{token-goat-chunk-CTOYJ232.mjs → token-goat-chunk-PAVWB4L4.mjs} +173 -1840
- package/dist/{token-goat-chunk-UENEL6KX.mjs → token-goat-chunk-PTW22MRC.mjs} +2 -2
- package/dist/token-goat-chunk-SL454EN5.mjs +1816 -0
- package/dist/token-goat-chunk-SP5QR2HN.mjs +1379 -0
- package/dist/{token-goat-chunk-7VLWNQDQ.mjs → token-goat-chunk-T46UKRFM.mjs} +1 -1
- package/dist/{token-goat-chunk-L7VCX33I.mjs → token-goat-chunk-T63VHOL3.mjs} +40 -10
- package/dist/token-goat-chunk-VSJGPEBB.mjs +248 -0
- package/dist/{token-goat-chunk-HIZXQVNU.mjs → token-goat-chunk-VYXGHGUY.mjs} +4 -4
- package/dist/token-goat-chunk-W6QYCWAT.mjs +128 -0
- package/dist/{token-goat-chunk-WFX2V3OF.mjs → token-goat-chunk-Z4ALQADF.mjs} +579 -422
- package/dist/token-goat-chunk-ZHFM4DHJ.mjs +324 -0
- package/dist/token-goat-hook.mjs +18 -12
- package/dist/token-goat.core.mjs +29 -20
- package/docs/C4_RUNTIME_ARCHITECTURE.md +1 -1
- package/docs/cli.md +15 -13
- package/docs/install.md +1 -1
- package/docs/security.md +2 -2
- package/package.json +3 -2
- package/dist/token-goat-chunk-2KTZ6J7T.mjs +0 -173
- package/dist/token-goat-chunk-34OUJ2IE.mjs +0 -35
- package/dist/token-goat-chunk-RHHFEXKF.mjs +0 -93
|
@@ -2,14 +2,17 @@ import { createRequire as __cjsRequire } from 'node:module';
|
|
|
2
2
|
const require = __cjsRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
getDb
|
|
5
|
-
} from "./token-goat-chunk-
|
|
5
|
+
} from "./token-goat-chunk-T63VHOL3.mjs";
|
|
6
6
|
import {
|
|
7
7
|
getHarnessName,
|
|
8
8
|
loadConfig
|
|
9
|
-
} from "./token-goat-chunk-
|
|
9
|
+
} from "./token-goat-chunk-74ADIZZX.mjs";
|
|
10
10
|
import {
|
|
11
11
|
registerReset
|
|
12
12
|
} from "./token-goat-chunk-EEIDFMEM.mjs";
|
|
13
|
+
import {
|
|
14
|
+
Database
|
|
15
|
+
} from "./token-goat-chunk-OSUFN2FV.mjs";
|
|
13
16
|
import {
|
|
14
17
|
VERSION,
|
|
15
18
|
atomicWriteText,
|
|
@@ -17,12 +20,13 @@ import {
|
|
|
17
20
|
dataDir,
|
|
18
21
|
dataDirForHome,
|
|
19
22
|
ensureDirSync,
|
|
23
|
+
extractErrorMessage,
|
|
20
24
|
sanitizeIdForFilename,
|
|
21
25
|
tokenGoatHome
|
|
22
|
-
} from "./token-goat-chunk-
|
|
26
|
+
} from "./token-goat-chunk-6LJNM4ZZ.mjs";
|
|
23
27
|
import {
|
|
24
28
|
displaySafeText
|
|
25
|
-
} from "./token-goat-chunk-
|
|
29
|
+
} from "./token-goat-chunk-NMTKNYGF.mjs";
|
|
26
30
|
import {
|
|
27
31
|
growsExponentially,
|
|
28
32
|
hasNestedQuantifier
|
|
@@ -50,12 +54,6 @@ function stripAnsiEscapes(s) {
|
|
|
50
54
|
}
|
|
51
55
|
return s.replace(_ANSI_ESCAPE_RE, "");
|
|
52
56
|
}
|
|
53
|
-
function stripAnsi(s) {
|
|
54
|
-
if (!s.includes("\x1B")) {
|
|
55
|
-
return s;
|
|
56
|
-
}
|
|
57
|
-
return stripAnsiEscapes(s).replace(_PUA_RE, "");
|
|
58
|
-
}
|
|
59
57
|
function fmtBytes(n) {
|
|
60
58
|
let value = n;
|
|
61
59
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
@@ -71,7 +69,7 @@ function fg(r, g, b) {
|
|
|
71
69
|
return `${_E}[38;2;${r};${g};${b}m`;
|
|
72
70
|
}
|
|
73
71
|
function vlen(s) {
|
|
74
|
-
return
|
|
72
|
+
return stripAnsiEscapes(s).replace(_PUA_RE, "").length;
|
|
75
73
|
}
|
|
76
74
|
function padR(s, w) {
|
|
77
75
|
return s + " ".repeat(Math.max(0, w - vlen(s)));
|
|
@@ -108,8 +106,44 @@ var C = {
|
|
|
108
106
|
|
|
109
107
|
// src/stats.ts
|
|
110
108
|
init_define_import_meta_env();
|
|
109
|
+
import * as fs from "node:fs";
|
|
111
110
|
import * as path from "node:path";
|
|
112
111
|
|
|
112
|
+
// src/token_estimate.ts
|
|
113
|
+
init_define_import_meta_env();
|
|
114
|
+
var BYTES_PER_TOKEN = {
|
|
115
|
+
text: 4,
|
|
116
|
+
dense: 1.45
|
|
117
|
+
};
|
|
118
|
+
var GUARD_MARGIN = 0.75;
|
|
119
|
+
function creditDivisor(cls = "text") {
|
|
120
|
+
return BYTES_PER_TOKEN[cls];
|
|
121
|
+
}
|
|
122
|
+
function guardDivisor(cls = "text") {
|
|
123
|
+
return BYTES_PER_TOKEN[cls] * GUARD_MARGIN;
|
|
124
|
+
}
|
|
125
|
+
var DENSE_ALPHABET = /^[A-Za-z0-9+/=_-]$/;
|
|
126
|
+
var MIN_DENSE_SAMPLE = 200;
|
|
127
|
+
var MIN_DENSE_DISTINCT = 16;
|
|
128
|
+
var CLASSIFY_SAMPLE_CHARS = 4096;
|
|
129
|
+
function classifyContent(text) {
|
|
130
|
+
const sample = text.slice(0, CLASSIFY_SAMPLE_CHARS);
|
|
131
|
+
if (sample.length < MIN_DENSE_SAMPLE) return "text";
|
|
132
|
+
let whitespace = 0;
|
|
133
|
+
let dense = 0;
|
|
134
|
+
const distinct = /* @__PURE__ */ new Set();
|
|
135
|
+
for (const ch of sample) {
|
|
136
|
+
if (ch === " " || ch === "\n" || ch === " " || ch === "\r") whitespace++;
|
|
137
|
+
else if (DENSE_ALPHABET.test(ch)) {
|
|
138
|
+
dense++;
|
|
139
|
+
distinct.add(ch);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (whitespace / sample.length >= 0.05) return "text";
|
|
143
|
+
if (distinct.size < MIN_DENSE_DISTINCT) return "text";
|
|
144
|
+
return dense / sample.length >= 0.95 ? "dense" : "text";
|
|
145
|
+
}
|
|
146
|
+
|
|
113
147
|
// src/render/stats_renderer.ts
|
|
114
148
|
init_define_import_meta_env();
|
|
115
149
|
var _STATS_MESSAGES_FALLBACK = {
|
|
@@ -772,7 +806,7 @@ function _renderByProjectSection(stats) {
|
|
|
772
806
|
nameColor: C.TEXT_PRIMARY
|
|
773
807
|
})
|
|
774
808
|
);
|
|
775
|
-
lines.push(`${_M} ${fg(...C.TEXT_DIM)}\u2514\u2500 ${p.hash} ${displaySafeText(
|
|
809
|
+
lines.push(`${_M} ${fg(...C.TEXT_DIM)}\u2514\u2500 ${p.hash} ${displaySafeText(stripAnsiEscapes(p.path))}${RESET}`);
|
|
776
810
|
}
|
|
777
811
|
return lines;
|
|
778
812
|
}
|
|
@@ -866,8 +900,8 @@ var SOURCE_CONTENT = "content";
|
|
|
866
900
|
var SOURCE_OTHER = "other";
|
|
867
901
|
var _BYTES_MODE_ONLY_KINDS = /* @__PURE__ */ new Set(["webfetch_image", "gdrive_image"]);
|
|
868
902
|
var COUNT_ONLY_KINDS = /* @__PURE__ */ new Set(["secret_redacted"]);
|
|
869
|
-
function savedTokensFromBytes(bytes) {
|
|
870
|
-
return Math.round(Math.max(0, bytes) /
|
|
903
|
+
function savedTokensFromBytes(bytes, cls = "text") {
|
|
904
|
+
return Math.round(Math.max(0, bytes) / creditDivisor(cls));
|
|
871
905
|
}
|
|
872
906
|
var KIND_TO_SOURCE = {
|
|
873
907
|
image_shrink: SOURCE_IMAGE,
|
|
@@ -954,11 +988,7 @@ var KIND_TO_SOURCE = {
|
|
|
954
988
|
compact_doc: SOURCE_READ,
|
|
955
989
|
note_read: SOURCE_READ,
|
|
956
990
|
note_list: SOURCE_READ,
|
|
957
|
-
// note-add is a write (like insert-section/replace, which record no stat at all -- neither
|
|
958
|
-
// has a "full source it replaces" savings concept). It still gets an event-only entry here
|
|
959
|
-
// (no bytesSaved/tokensSaved argument, same as skill_load) purely so `token-goat note-add`
|
|
960
|
-
// usage is visible in `token-goat stats --full` at all -- SOURCE_OTHER, not SOURCE_READ,
|
|
961
|
-
// since it is not a token-savings substitute for a read.
|
|
991
|
+
// note-add is a write (like insert-section/replace, which record no stat at all -- neither has a "full source it replaces" savings concept). It still gets an event-only entry here (no bytesSaved/tokensSaved argument, same as skill_load) purely so `token-goat note-add` usage is visible in `token-goat stats --full` at all -- SOURCE_OTHER, not SOURCE_READ, since it is not a token-savings substitute for a read.
|
|
962
992
|
note_write: SOURCE_OTHER,
|
|
963
993
|
web_fetch: SOURCE_WEB,
|
|
964
994
|
injection_detected: SOURCE_WEB,
|
|
@@ -973,6 +1003,8 @@ var KIND_TO_SOURCE = {
|
|
|
973
1003
|
dirty_queue_append_failed: SOURCE_OTHER,
|
|
974
1004
|
worker_healthcheck_failed: SOURCE_OTHER,
|
|
975
1005
|
known_root_record_failed: SOURCE_OTHER,
|
|
1006
|
+
// Same fail-soft shape, from hooks_session_start.ts's reconcileNote: a thrown reconcile sweep must not block session start, but it also must not vanish silently, so the catch that swallows it records why instead of returning null with nothing recorded.
|
|
1007
|
+
reconcile_note_failed: SOURCE_OTHER,
|
|
976
1008
|
// Measurement of what a compaction produced (hooks_compact.ts postCompactHandler): summary size and how many manifest paths survived into it. SOURCE_OTHER and always recorded at (0, 0) -- the summary was written whether or not token-goat was watching, so there is no counterfactual in which those bytes were saved. Filing it anywhere with a savings total would credit token-goat for the whole summary, which is the accounting mistake this registry exists to prevent.
|
|
977
1009
|
compact_summary: SOURCE_OTHER,
|
|
978
1010
|
// Envelope compaction of an oversized subagent report (hooks_agent_spawn.ts). SOURCE_CONTENT, not SOURCE_HINT: the handler's sibling session_hint entry is advisory (it only appends a recall pointer and genuinely saves nothing), whereas this kind records a real rewrite with real bytes removed, so filing it under the advisory bucket would understate the compaction and repeat the zero-savings desync this registry keeps getting bitten by.
|
|
@@ -986,12 +1018,7 @@ var KIND_TO_SOURCE = {
|
|
|
986
1018
|
plan_echo_collapse: SOURCE_CONTENT,
|
|
987
1019
|
// Lossless re-layout of Grep content-mode output (hooks_grep.ts foldGrepContentHandler). SOURCE_CONTENT, not SOURCE_HINT, for the same reason as agent_report_compact above: its sibling grep_dedup_hint is advisory and saves nothing directly, whereas this is a real rewrite with real bytes removed. Filing it under the advisory bucket would silently add non-hint savings to hint_stats.ts's savedBytes (which reads by_source[SOURCE_HINT] wholesale) and overstate the hint ledger's net benefit.
|
|
988
1020
|
"grep:fold": SOURCE_CONTENT,
|
|
989
|
-
// Withholding of already-served stretches from a completed Read (hooks_read.ts
|
|
990
|
-
// elideAlreadyServedLines). SOURCE_CONTENT, not SOURCE_HINT, for the same reason as
|
|
991
|
-
// grep:fold above: its siblings read_count_deny and read_served_deny are decisions about
|
|
992
|
-
// whether a read happens at all, whereas this is a rewrite of a result that did happen,
|
|
993
|
-
// with real bytes removed from it. Filing it under the advisory bucket would add non-hint
|
|
994
|
-
// savings to hint_stats.ts's savedBytes, which reads by_source[SOURCE_HINT] wholesale.
|
|
1021
|
+
// Withholding of already-served stretches from a completed Read (hooks_read.ts elideAlreadyServedLines). SOURCE_CONTENT, not SOURCE_HINT, for the same reason as grep:fold above: its siblings read_count_deny and read_served_deny are decisions about whether a read happens at all, whereas this is a rewrite of a result that did happen, with real bytes removed from it. Filing it under the advisory bucket would add non-hint savings to hint_stats.ts's savedBytes, which reads by_source[SOURCE_HINT] wholesale.
|
|
995
1022
|
"read:served_elide": SOURCE_CONTENT,
|
|
996
1023
|
// Same bucket and same reasoning as read:served_elide directly above: a rewrite of a Read that did happen, with real bytes removed, not an advisory about whether to read at all.
|
|
997
1024
|
"read:body_fold": SOURCE_CONTENT,
|
|
@@ -1011,7 +1038,9 @@ var KIND_PREFIX_TO_SOURCE = [
|
|
|
1011
1038
|
["skill_body:", SOURCE_SKILL],
|
|
1012
1039
|
["skill_compact:", SOURCE_SKILL],
|
|
1013
1040
|
["bashoutput:", SOURCE_BASH],
|
|
1014
|
-
["taskoutput:", SOURCE_CONTENT]
|
|
1041
|
+
["taskoutput:", SOURCE_CONTENT],
|
|
1042
|
+
// Hook wall-clock timing (relay.ts's relayInProcess), one row per invocation, always 0 bytes/0 tokens: it measures token-goat's own overhead, not a saving. See hook_latency.ts's hookLatencyBreakdown() for the dedicated read path and pruneHookStats() below for its own (shorter) retention.
|
|
1043
|
+
["hook:", SOURCE_OTHER]
|
|
1015
1044
|
];
|
|
1016
1045
|
var COMMAND_KINDS = {
|
|
1017
1046
|
symbol: /* @__PURE__ */ new Set(["symbol_lookup"]),
|
|
@@ -1132,7 +1161,8 @@ CREATE TABLE IF NOT EXISTS stats (
|
|
|
1132
1161
|
detail TEXT,
|
|
1133
1162
|
harness TEXT,
|
|
1134
1163
|
traceparent TEXT,
|
|
1135
|
-
tg_version TEXT
|
|
1164
|
+
tg_version TEXT,
|
|
1165
|
+
duration_ms INTEGER
|
|
1136
1166
|
);
|
|
1137
1167
|
CREATE INDEX IF NOT EXISTS idx_stats_ts ON stats(ts);
|
|
1138
1168
|
CREATE INDEX IF NOT EXISTS idx_stats_kind ON stats(kind);
|
|
@@ -1190,6 +1220,29 @@ function migrateGlobalSchema(db) {
|
|
|
1190
1220
|
} catch (err) {
|
|
1191
1221
|
if (!(err instanceof Error) || !/duplicate column/i.test(err.message)) throw err;
|
|
1192
1222
|
}
|
|
1223
|
+
try {
|
|
1224
|
+
db.exec("ALTER TABLE stats ADD COLUMN duration_ms INTEGER");
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
if (!(err instanceof Error) || !/duplicate column/i.test(err.message)) throw err;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
var TEST_ISOLATION_LEAK_WINDOW_START_TS = 1780406895;
|
|
1230
|
+
var TEST_ISOLATION_LEAK_WINDOW_END_TS = 1782119331;
|
|
1231
|
+
function pruneTestIsolationLeakRows(db) {
|
|
1232
|
+
try {
|
|
1233
|
+
db.prepare(
|
|
1234
|
+
`DELETE FROM stats WHERE tg_version IS NULL AND ts BETWEEN ? AND ? AND (detail LIKE '%pytest-of-%' OR detail LIKE '%/fake/%')`
|
|
1235
|
+
).run(TEST_ISOLATION_LEAK_WINDOW_START_TS, TEST_ISOLATION_LEAK_WINDOW_END_TS);
|
|
1236
|
+
} catch {
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
function dropRetiredPythonTables(db) {
|
|
1240
|
+
try {
|
|
1241
|
+
db.prepare("DROP TABLE IF EXISTS grep_patterns").run();
|
|
1242
|
+
db.prepare("DROP TABLE IF EXISTS miss_patterns").run();
|
|
1243
|
+
db.prepare("DROP TABLE IF EXISTS wal_bloat").run();
|
|
1244
|
+
} catch {
|
|
1245
|
+
}
|
|
1193
1246
|
}
|
|
1194
1247
|
var _harnessColumnByDb = /* @__PURE__ */ new WeakMap();
|
|
1195
1248
|
function statsHasHarnessColumn(db) {
|
|
@@ -1236,6 +1289,21 @@ function statsHasVersionColumn(db) {
|
|
|
1236
1289
|
_versionColumnByDb.set(db, present);
|
|
1237
1290
|
return present;
|
|
1238
1291
|
}
|
|
1292
|
+
var _durationColumnByDb = /* @__PURE__ */ new WeakMap();
|
|
1293
|
+
function statsHasDurationColumn(db) {
|
|
1294
|
+
const cached = _durationColumnByDb.get(db);
|
|
1295
|
+
if (cached !== void 0) return cached;
|
|
1296
|
+
let present;
|
|
1297
|
+
try {
|
|
1298
|
+
present = db.prepare("PRAGMA table_info(stats)").all().some(
|
|
1299
|
+
(c) => c.name === "duration_ms"
|
|
1300
|
+
);
|
|
1301
|
+
} catch {
|
|
1302
|
+
present = false;
|
|
1303
|
+
}
|
|
1304
|
+
_durationColumnByDb.set(db, present);
|
|
1305
|
+
return present;
|
|
1306
|
+
}
|
|
1239
1307
|
function getGlobalDb(homeDir) {
|
|
1240
1308
|
const basePath = homeDir ? dataDirForHome(homeDir) : dataDir();
|
|
1241
1309
|
const dbPath = path.join(basePath, "global.db");
|
|
@@ -1248,6 +1316,7 @@ function getGlobalDb(homeDir) {
|
|
|
1248
1316
|
return db;
|
|
1249
1317
|
}
|
|
1250
1318
|
var STATS_RETENTION_DAYS = 180;
|
|
1319
|
+
var UNMAPPED_TOOL_RETENTION_DAYS = 30;
|
|
1251
1320
|
var STATS_ROLLUP_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
1252
1321
|
function rollupAndPruneStats(db, retentionDays = STATS_RETENTION_DAYS) {
|
|
1253
1322
|
try {
|
|
@@ -1269,11 +1338,31 @@ function rollupAndPruneStats(db, retentionDays = STATS_RETENTION_DAYS) {
|
|
|
1269
1338
|
tokens_saved = tokens_saved + excluded.tokens_saved`
|
|
1270
1339
|
).run(cutoff);
|
|
1271
1340
|
db.prepare(`DELETE FROM stats WHERE ts < ?`).run(cutoff);
|
|
1341
|
+
try {
|
|
1342
|
+
const unmappedCutoff = Math.floor(Date.now() / 1e3) - UNMAPPED_TOOL_RETENTION_DAYS * 86400;
|
|
1343
|
+
db.prepare(`DELETE FROM unmapped_tools WHERE last_seen < ?`).run(unmappedCutoff);
|
|
1344
|
+
} catch {
|
|
1345
|
+
}
|
|
1272
1346
|
});
|
|
1273
1347
|
run(cutoffTs);
|
|
1274
1348
|
} catch {
|
|
1275
1349
|
}
|
|
1276
1350
|
}
|
|
1351
|
+
function pruneHintEmissions(db, retentionDays = STATS_RETENTION_DAYS) {
|
|
1352
|
+
try {
|
|
1353
|
+
const cutoffMs = Date.now() - retentionDays * 86400 * 1e3;
|
|
1354
|
+
db.prepare(`DELETE FROM hint_emissions WHERE emitted_at < ?`).run(cutoffMs);
|
|
1355
|
+
} catch {
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
var HOOK_STATS_RETENTION_DAYS = 7;
|
|
1359
|
+
function pruneHookStats(db, retentionDays = HOOK_STATS_RETENTION_DAYS) {
|
|
1360
|
+
try {
|
|
1361
|
+
const cutoffTs = Math.floor(Date.now() / 1e3) - retentionDays * 86400;
|
|
1362
|
+
db.prepare(`DELETE FROM stats WHERE kind LIKE 'hook:%' AND ts < ?`).run(cutoffTs);
|
|
1363
|
+
} catch {
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1277
1366
|
function maybeRunStatsMaintenance(db) {
|
|
1278
1367
|
try {
|
|
1279
1368
|
const now = Date.now();
|
|
@@ -1284,7 +1373,11 @@ function maybeRunStatsMaintenance(db) {
|
|
|
1284
1373
|
`INSERT INTO stats_maintenance (id, last_rollup_ts) VALUES (1, ?)
|
|
1285
1374
|
ON CONFLICT(id) DO UPDATE SET last_rollup_ts = excluded.last_rollup_ts`
|
|
1286
1375
|
).run(now);
|
|
1376
|
+
pruneHookStats(db);
|
|
1287
1377
|
rollupAndPruneStats(db);
|
|
1378
|
+
pruneHintEmissions(db);
|
|
1379
|
+
pruneTestIsolationLeakRows(db);
|
|
1380
|
+
dropRetiredPythonTables(db);
|
|
1288
1381
|
} catch {
|
|
1289
1382
|
}
|
|
1290
1383
|
}
|
|
@@ -1303,9 +1396,33 @@ function noStatsMessage(windowDays, homeDir) {
|
|
|
1303
1396
|
if (total === 0) return "No stats recorded yet.";
|
|
1304
1397
|
return `No stats in the last ${countNoun(windowDays, "day")} (${total} recorded outside this window; use --window-days 0 for all time).`;
|
|
1305
1398
|
}
|
|
1306
|
-
|
|
1399
|
+
var STATS_WRITE_FAILURE_LOG_MIN_INTERVAL_MS = 60 * 1e3;
|
|
1400
|
+
function statsWriteFailureMarkerPath(dir) {
|
|
1401
|
+
return path.join(dir, "stats-write-failed.marker");
|
|
1402
|
+
}
|
|
1403
|
+
function statsWriteFailureLogPath(dir) {
|
|
1404
|
+
return path.join(dir, "stats-write-failed.log");
|
|
1405
|
+
}
|
|
1406
|
+
function recordStatWriteFailure(kind, err, dir = dataDir()) {
|
|
1307
1407
|
try {
|
|
1308
|
-
const
|
|
1408
|
+
const markerPath = statsWriteFailureMarkerPath(dir);
|
|
1409
|
+
try {
|
|
1410
|
+
if (Date.now() - fs.statSync(markerPath).mtimeMs < STATS_WRITE_FAILURE_LOG_MIN_INTERVAL_MS) return;
|
|
1411
|
+
} catch {
|
|
1412
|
+
}
|
|
1413
|
+
ensureDirSync(dir);
|
|
1414
|
+
fs.writeFileSync(markerPath, "");
|
|
1415
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} recordStat write failed for kind=${kind}: ${extractErrorMessage(err)}`;
|
|
1416
|
+
fs.appendFileSync(statsWriteFailureLogPath(dir), displaySafeText(line.replace(/[\n\r]+$/, "")) + "\n");
|
|
1417
|
+
} catch {
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
var STATS_WRITE_BUSY_TIMEOUT_MS = 200;
|
|
1421
|
+
function recordStat(kind, bytesSaved = 0, tokensSaved = 0, _testDb, detail, traceparent, durationMs) {
|
|
1422
|
+
let db;
|
|
1423
|
+
try {
|
|
1424
|
+
if (!_testDb) getGlobalDb();
|
|
1425
|
+
db = _testDb ?? new Database(path.join(dataDir(), "global.db"), { timeout: STATS_WRITE_BUSY_TIMEOUT_MS });
|
|
1309
1426
|
const ts = Math.floor(Date.now() / 1e3);
|
|
1310
1427
|
const tp = traceparent ?? process.env["TRACEPARENT"] ?? process.env["traceparent"] ?? null;
|
|
1311
1428
|
const cols = ["ts", "kind", "bytes_saved", "tokens_saved", "detail"];
|
|
@@ -1322,11 +1439,23 @@ function recordStat(kind, bytesSaved = 0, tokensSaved = 0, _testDb, detail, trac
|
|
|
1322
1439
|
cols.push("tg_version");
|
|
1323
1440
|
vals.push(VERSION);
|
|
1324
1441
|
}
|
|
1442
|
+
if (durationMs !== void 0 && statsHasDurationColumn(db)) {
|
|
1443
|
+
cols.push("duration_ms");
|
|
1444
|
+
vals.push(Math.round(durationMs));
|
|
1445
|
+
}
|
|
1325
1446
|
db.prepare(
|
|
1326
1447
|
`INSERT INTO stats (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`
|
|
1327
1448
|
).run(...vals);
|
|
1328
1449
|
maybeRunStatsMaintenance(db);
|
|
1329
|
-
} catch {
|
|
1450
|
+
} catch (e) {
|
|
1451
|
+
recordStatWriteFailure(kind, e);
|
|
1452
|
+
} finally {
|
|
1453
|
+
if (db && !_testDb) {
|
|
1454
|
+
try {
|
|
1455
|
+
db.close();
|
|
1456
|
+
} catch {
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1330
1459
|
}
|
|
1331
1460
|
}
|
|
1332
1461
|
var MAX_TOOL_NAME_CHARS = 200;
|
|
@@ -1356,6 +1485,18 @@ function readUnmappedTools(dbPath, homeDir) {
|
|
|
1356
1485
|
return [];
|
|
1357
1486
|
}
|
|
1358
1487
|
}
|
|
1488
|
+
function pruneStalePatternCoveredUnmappedTools(db, patterns) {
|
|
1489
|
+
if (patterns.length === 0) return;
|
|
1490
|
+
const regexes = patterns.map((p) => new RegExp(p));
|
|
1491
|
+
try {
|
|
1492
|
+
const rows = db.prepare("SELECT DISTINCT tool_name FROM unmapped_tools").all();
|
|
1493
|
+
const stale = rows.filter((r) => regexes.some((re) => re.test(r.tool_name)));
|
|
1494
|
+
if (stale.length === 0) return;
|
|
1495
|
+
const del = db.prepare("DELETE FROM unmapped_tools WHERE tool_name = ?");
|
|
1496
|
+
for (const r of stale) del.run(r.tool_name);
|
|
1497
|
+
} catch {
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1359
1500
|
function summarize(windowDays = 30, testDb, homeDir) {
|
|
1360
1501
|
const t0 = Date.now();
|
|
1361
1502
|
const sinceTs = windowDays > 0 ? Math.floor((Date.now() - windowDays * 24 * 60 * 60 * 1e3) / 1e3) : null;
|
|
@@ -1496,18 +1637,11 @@ function _totalsLines(summary) {
|
|
|
1496
1637
|
`Total events: ${summary.total_events}`,
|
|
1497
1638
|
`Bytes saved: ${fmtBytes(summary.total_bytes_saved)}`,
|
|
1498
1639
|
`Tokens saved: ${summary.total_tokens_saved}`,
|
|
1499
|
-
// Disclosure, not a correction: `total_tokens_saved` above sums rows written under whichever
|
|
1500
|
-
// pricing formula was live when each was recorded, and `tg_version` cannot be read back into
|
|
1501
|
-
// "which formula" for a row from before this disclosure existed (PRICING_VERSION_UNRECORDED is
|
|
1502
|
-
// the overwhelming majority of all-time rows). Excluding those rows from the headline would
|
|
1503
|
-
// discard nearly the whole figure rather than fix it, so the honest move is to keep the sum and
|
|
1504
|
-
// say plainly that it spans more than one era, not to quietly present a mixed total as single-formula.
|
|
1640
|
+
// Disclosure, not a correction: `total_tokens_saved` above sums rows written under whichever pricing formula was live when each was recorded, and `tg_version` cannot be read back into "which formula" for a row from before this disclosure existed (PRICING_VERSION_UNRECORDED is the overwhelming majority of all-time rows). Excluding those rows from the headline would discard nearly the whole figure rather than fix it, so the honest move is to keep the sum and say plainly that it spans more than one era, not to quietly present a mixed total as single-formula.
|
|
1505
1641
|
...hasMixedPricingEras(summary) ? [
|
|
1506
1642
|
`Pricing note: totals mix ${countNoun(Object.keys(summary.by_pricing_version).length, "tg_version era")} (${countNoun(summary.by_pricing_version[PRICING_VERSION_UNRECORDED]?.events ?? 0, "row")} unrecorded); see 'token-goat stats --json' -> by_pricing_version for the breakdown`
|
|
1507
1643
|
] : [],
|
|
1508
|
-
// Printed on its own line, below the token total and never inside it, because it counts
|
|
1509
|
-
// placeholders rather than tokens. Omitted entirely when nothing was redacted, so the line is
|
|
1510
|
-
// information rather than a permanent zero. See COUNT_ONLY_KINDS.
|
|
1644
|
+
// Printed on its own line, below the token total and never inside it, because it counts placeholders rather than tokens. Omitted entirely when nothing was redacted, so the line is information rather than a permanent zero. See COUNT_ONLY_KINDS.
|
|
1511
1645
|
...summary.counts["secret_redacted"] ? [`Secrets hidden: ${summary.counts["secret_redacted"]} (a count, not tokens)`] : [],
|
|
1512
1646
|
`Window: ${summary.window_days} days`
|
|
1513
1647
|
];
|
|
@@ -1515,7 +1649,8 @@ function _totalsLines(summary) {
|
|
|
1515
1649
|
function _useRichStats() {
|
|
1516
1650
|
if (process.env["NO_COLOR"]) return false;
|
|
1517
1651
|
if (process.stdout.isTTY === true) return true;
|
|
1518
|
-
|
|
1652
|
+
const forceColor = process.env["FORCE_COLOR"];
|
|
1653
|
+
return forceColor !== void 0 && forceColor !== "0";
|
|
1519
1654
|
}
|
|
1520
1655
|
function _renderShortTotals(summary) {
|
|
1521
1656
|
const lines = [
|
|
@@ -1678,9 +1813,7 @@ var SECRET_PATTERNS = [
|
|
|
1678
1813
|
// Redacts only the token itself, not the "Authorization: Bearer " prefix -- the lookbehind anchors on the header name and scheme so the surrounding request-log line stays readable, matching how AWS_ACCESS_KEY_ID=... above keeps its own prefix intact. The optional quotes on either side of the colon are what let this see a header carried in JSON rather than in raw wire format. Without them the lookbehind demanded the colon sit directly against the header name and the scheme directly against the space, so a body like {"Authorization": "Bearer <token>"} -- the shape any logged fetch or MCP result arrives in -- matched nothing and the token was cached verbatim. `token` is the second scheme spelling in wide use -- it is what curl and gh examples pass for GitHub and many other APIs -- and an opaque value behind it carries exactly the same authority as one behind `Bearer`. The trailing gap is `{1,8}` rather than a single space for the same reason every other gap in this lookbehind already is: a hand-aligned or reformatted header ("Authorization: Bearer <token>") is ordinary, and demanding exactly one space there made this the one position in the pattern that a second space defeated.
|
|
1679
1814
|
["auth_bearer_token", /(?<=Authorization["']?[ \t]{0,8}:[ \t]{0,8}["']?[ \t]{0,8}(?:Bearer|token)[ \t]{1,8})[A-Za-z0-9\-._~+/]{10,}=*/gi],
|
|
1680
1815
|
["auth_basic_token", /(?<=Authorization["']?[ \t]{0,8}:[ \t]{0,8}["']?[ \t]{0,8}Basic[ \t])[A-Za-z0-9+/]{6,}=*/gi],
|
|
1681
|
-
// JWTs have no distinctive prefix of their own, but the base64url encoding of the smallest realistic header ('{"alg":' or similar) always starts with "eyJ", so that's the practical anchor here -- each of the three dot-separated segments requires a minimum length to avoid matching a short, coincidentally dotted token.
|
|
1682
|
-
//
|
|
1683
|
-
// The trailing `={0,2}` on each segment is what makes a padded token match. Base64url as the JWT spec defines it drops the `=` padding, but producers that reach for a plain base64 encoder emit it anyway, and a `=` in the header or payload segment used to defeat the match outright: not a partial redaction, but none at all, so the entire token was printed. `=` cannot appear anywhere except the end of a segment, since it is not one of the characters the segment body allows, so accepting it here cannot widen the match onto anything else.
|
|
1816
|
+
// JWTs have no distinctive prefix of their own, but the base64url encoding of the smallest realistic header ('{"alg":' or similar) always starts with "eyJ", so that's the practical anchor here -- each of the three dot-separated segments requires a minimum length to avoid matching a short, coincidentally dotted token. The trailing `={0,2}` on each segment is what makes a padded token match. Base64url as the JWT spec defines it drops the `=` padding, but producers that reach for a plain base64 encoder emit it anyway, and a `=` in the header or payload segment used to defeat the match outright: not a partial redaction, but none at all, so the entire token was printed. `=` cannot appear anywhere except the end of a segment, since it is not one of the characters the segment body allows, so accepting it here cannot widen the match onto anything else.
|
|
1684
1817
|
["jwt", /eyJ[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]{10,}={0,2}/g],
|
|
1685
1818
|
["npm_token", /npm_[A-Za-z0-9]{36}/g],
|
|
1686
1819
|
// rk_live_ (restricted keys) share the sk_live_/sk_test_ secret-key shape and risk level, so one pattern covers all three rather than adding a near-duplicate entry.
|
|
@@ -1692,9 +1825,7 @@ var SECRET_PATTERNS = [
|
|
|
1692
1825
|
["url_credentials", /(?<=:\/\/[^\s:@/]{1,64}:)[^\s:@/]{1,256}(?=@)/g],
|
|
1693
1826
|
// Azure storage account connection strings (`DefaultEndpointsProtocol=https;AccountName=...; AccountKey=<base64>==;EndpointSuffix=core.windows.net`) carry a full read/write key to the account in the AccountKey field, and none of the generic patterns above catch it: generic_secret_assignment's keyword list (password|passwd|secret|api[_-]?key| access[_-]?token|refresh[_-]?token|id[_-]?token) has nothing that matches "AccountKey", and an unanchored base64-shape pattern was deliberately rejected -- this module's header already warns that a bare high-entropy-blob heuristic false-fires on ordinary code, JSON and log output, and an 88-char base64 value is exactly the shape a hash, a compiled asset digest or a generated id can also take. Anchoring on the literal `AccountKey=` field name instead keeps the match specific to this one connection-string field. The value class is base64 proper (letters, digits, `+`, `/`, trailing `=` padding) and none of those characters include `;`, so the match terminates on its own at the `;` that starts the next `Name=` field -- unlike generic_secret_assignment's separator characters (`& ; # , :`), which double as ordinary credential characters and need a lookahead to tell the two roles apart, `;` is never valid base64 and needs no such lookahead here. The lookbehind keeps `AccountKey=` itself in the output, matching auth_bearer_token and presigned_signature above, so `;EndpointSuffix=core.windows.net` after it stays fully readable too. `SharedAccessKey` is the same credential one Azure service over: Service Bus, Event Hubs and Relay spell it that way (`Endpoint=sb://ns.servicebus.windows.net/;SharedAccessKeyName=Root; SharedAccessKey=<base64>`) and it carries the same authority over that namespace that AccountKey does over a storage account. `SharedAccessKeyName` is a plain identifier rather than a secret, and never matches: the separator in the lookbehind sits directly against the key name, so the `Name` in between stops it dead. The separator is spelled the way auth_bearer_token above spells its own, for the same reasons that comment records having learned the hard way. Optional quotes on either side of it, so the JSON and YAML forms a logged MCP result or api response actually arrives in are matched rather than stopped dead at the opening quote. A bounded gap rather than exactly one space, so a hand-aligned or reformatted `AccountKey = ...` in an appsettings file is not the single variant that defeats the whole pattern. Case-insensitive for the same reason presigned_signature is. The leading word boundary is what keeps the widened name from reaching into the middle of a longer identifier such as `myaccountkey=`.
|
|
1694
1827
|
["azure_storage_key", /(?<=\b(?:AccountKey|SharedAccessKey)["']?[ \t]{0,8}[:=][ \t]{0,8}["']?)[A-Za-z0-9+/]{40,}=*/gi],
|
|
1695
|
-
// Generic key=value assignments in .env-file and connection-string/query-string shape. The lookbehind again redacts only the value, and the value's character class deliberately excludes whitespace, '&', ';', '#', quote characters, and '[' ']' ':' -- that exclusion is what stops this from swallowing the rest of the line (a trailing comment or the next key=value pair) or the remainder of a query string past the matched parameter, which is exactly the kind of over-eager match this module's own design note above warns broad heuristics produce. The '[' ']' ':' exclusion also matters because this pattern runs last: an earlier pattern's own "OPENAI_API_KEY=[REDACTED:openai_project_key]" replacement text contains "API_KEY=" too, and without excluding those characters this pattern would re-match and double-redact its own placeholder. The length has a lower bound only (no upper bound): capping it at 64 used to leave the tail of any longer secret unredacted in plain text, which is worse than no redaction because it looks handled. A single negated-class quantifier like this cannot backtrack catastrophically -- there is no nested or overlapping quantifier for the engine to explore multiple ways of matching, so removing the upper bound does not introduce a ReDoS risk. Quotes are permitted around the separator, but never inside the value class. A quoted value is the ordinary way secrets are written -- .env files, JSON, YAML, TOML all quote by default -- and the lookbehind used to stop dead at the opening quote, so `API_KEY="..."` passed through in full while the bare `API_KEY=...` was caught. The closing quote of the key name blocked it from the other side too, which is what kept every JSON body unredacted. Keeping quotes out of the value class is still what stops the match running past the closing quote. The keyword may be a prefix of a longer key name rather than the whole of it, so the trailing identifier class below is load-bearing: without it the lookbehind required the keyword to sit immediately before the separator, and AWS_SECRET_ACCESS_KEY=, SECRET_KEY=, and DB_PASSWORD_HASH= all passed through in full. That class matches identifier characters only, so prose that merely mentions a keyword still never reaches a separator and stays unredacted. api[_-]?key covers the apikey and api-key spellings too. `& ; # , :` play two incompatible roles. They separate one field from the next (a query string, a cookie header, an inline env list), and they are also perfectly ordinary credential characters. Rejecting them outright got the first role right and the second badly wrong: the match stopped at the first one and left everything after it in plain text, so `password=corr&horse&battery` redacted four characters and printed the rest, and `DB_PASSWORD=Aa1:xyz` matched nothing at all because the run before the `:` was under the four-character floor. A tail left sitting in the open is the outcome this module's header calls worse than no redaction, because it reads as handled.
|
|
1696
|
-
//
|
|
1697
|
-
// So the separator role is decided by what follows rather than assumed: one of these characters ends the value only when the next thing along is another `name=` / `name:` pair, which is what an actual field separator is always followed by. `,OTHER=public` and `; other=1` still end it; the `&` in the middle of a passphrase does not. Whitespace, quotes and brackets are unchanged -- they end a value unconditionally, which is also what keeps this pattern from re-matching the `[REDACTED:...]` placeholder it just wrote.
|
|
1828
|
+
// Generic key=value assignments in .env-file and connection-string/query-string shape. The lookbehind again redacts only the value, and the value's character class deliberately excludes whitespace, '&', ';', '#', quote characters, and '[' ']' ':' -- that exclusion is what stops this from swallowing the rest of the line (a trailing comment or the next key=value pair) or the remainder of a query string past the matched parameter, which is exactly the kind of over-eager match this module's own design note above warns broad heuristics produce. The '[' ']' ':' exclusion also matters because this pattern runs last: an earlier pattern's own "OPENAI_API_KEY=[REDACTED:openai_project_key]" replacement text contains "API_KEY=" too, and without excluding those characters this pattern would re-match and double-redact its own placeholder. The length has a lower bound only (no upper bound): capping it at 64 used to leave the tail of any longer secret unredacted in plain text, which is worse than no redaction because it looks handled. A single negated-class quantifier like this cannot backtrack catastrophically -- there is no nested or overlapping quantifier for the engine to explore multiple ways of matching, so removing the upper bound does not introduce a ReDoS risk. Quotes are permitted around the separator, but never inside the value class. A quoted value is the ordinary way secrets are written -- .env files, JSON, YAML, TOML all quote by default -- and the lookbehind used to stop dead at the opening quote, so `API_KEY="..."` passed through in full while the bare `API_KEY=...` was caught. The closing quote of the key name blocked it from the other side too, which is what kept every JSON body unredacted. Keeping quotes out of the value class is still what stops the match running past the closing quote. The keyword may be a prefix of a longer key name rather than the whole of it, so the trailing identifier class below is load-bearing: without it the lookbehind required the keyword to sit immediately before the separator, and AWS_SECRET_ACCESS_KEY=, SECRET_KEY=, and DB_PASSWORD_HASH= all passed through in full. That class matches identifier characters only, so prose that merely mentions a keyword still never reaches a separator and stays unredacted. api[_-]?key covers the apikey and api-key spellings too. `& ; # , :` play two incompatible roles. They separate one field from the next (a query string, a cookie header, an inline env list), and they are also perfectly ordinary credential characters. Rejecting them outright got the first role right and the second badly wrong: the match stopped at the first one and left everything after it in plain text, so `password=corr&horse&battery` redacted four characters and printed the rest, and `DB_PASSWORD=Aa1:xyz` matched nothing at all because the run before the `:` was under the four-character floor. A tail left sitting in the open is the outcome this module's header calls worse than no redaction, because it reads as handled. So the separator role is decided by what follows rather than assumed: one of these characters ends the value only when the next thing along is another `name=` / `name:` pair, which is what an actual field separator is always followed by. `,OTHER=public` and `; other=1` still end it; the `&` in the middle of a passphrase does not. Whitespace, quotes and brackets are unchanged -- they end a value unconditionally, which is also what keeps this pattern from re-matching the `[REDACTED:...]` placeholder it just wrote.
|
|
1698
1829
|
["generic_secret_assignment", /(?<=(?:password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)[a-z0-9_-]{0,64}["']?[ \t]{0,8}[:=][ \t]{0,8}["']?)(?:\\[^\n]|[^\s\\&;#,:'"[\]{}]|[&;#,:](?![ \t]*[A-Za-z_][A-Za-z0-9_.-]*[ \t]*[:=])){4,}/gi]
|
|
1699
1830
|
];
|
|
1700
1831
|
function countRedactionPlaceholders(text) {
|
|
@@ -1777,6 +1908,28 @@ function characterClasses(s) {
|
|
|
1777
1908
|
function looksLikeCredential(s) {
|
|
1778
1909
|
return characterClasses(s) >= 3 && entropyBitsPerChar(s) >= 3.5;
|
|
1779
1910
|
}
|
|
1911
|
+
var RECALL_TUNED_KIND = "generic_secret_assignment";
|
|
1912
|
+
function matchesAnywhere(pattern, text) {
|
|
1913
|
+
pattern.lastIndex = 0;
|
|
1914
|
+
const hit = pattern.test(text);
|
|
1915
|
+
pattern.lastIndex = 0;
|
|
1916
|
+
return hit;
|
|
1917
|
+
}
|
|
1918
|
+
function hasPreciseSecret(text, config = loadConfig()) {
|
|
1919
|
+
for (const [kind, pattern] of SECRET_PATTERNS) {
|
|
1920
|
+
if (kind === RECALL_TUNED_KIND) continue;
|
|
1921
|
+
if (matchesAnywhere(pattern, text)) return true;
|
|
1922
|
+
}
|
|
1923
|
+
for (const custom of compileCustomPatterns(config.redaction.custom_patterns).patterns) {
|
|
1924
|
+
if (matchesAnywhere(custom, text)) return true;
|
|
1925
|
+
}
|
|
1926
|
+
if (config.redaction.strict) {
|
|
1927
|
+
for (const candidate of text.match(STRICT_CANDIDATE) ?? []) {
|
|
1928
|
+
if (looksLikeCredential(candidate)) return true;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
return false;
|
|
1932
|
+
}
|
|
1780
1933
|
function redactSecrets(text, config = loadConfig()) {
|
|
1781
1934
|
let count = 0;
|
|
1782
1935
|
let out = text;
|
|
@@ -1804,7 +1957,7 @@ function redactSecrets(text, config = loadConfig()) {
|
|
|
1804
1957
|
|
|
1805
1958
|
// src/disk_cache.ts
|
|
1806
1959
|
init_define_import_meta_env();
|
|
1807
|
-
import * as
|
|
1960
|
+
import * as fs2 from "node:fs";
|
|
1808
1961
|
import * as path2 from "node:path";
|
|
1809
1962
|
var DEFAULT_MAX_COUNT = 200;
|
|
1810
1963
|
var DEFAULT_MAX_AGE_MS = 24 * 3600 * 1e3;
|
|
@@ -1831,7 +1984,7 @@ function isBlobStale(subdir, id) {
|
|
|
1831
1984
|
const p = blobPath(subdir, id);
|
|
1832
1985
|
if (p === null) return false;
|
|
1833
1986
|
try {
|
|
1834
|
-
const stat =
|
|
1987
|
+
const stat = fs2.statSync(p);
|
|
1835
1988
|
return Date.now() - stat.mtimeMs > DEFAULT_MAX_AGE_MS;
|
|
1836
1989
|
} catch {
|
|
1837
1990
|
return false;
|
|
@@ -1873,7 +2026,7 @@ function storeBlob(subdir, id, value, opts = {}) {
|
|
|
1873
2026
|
if (Number.isFinite(maxBytesPerItem) && Buffer.byteLength(json, "utf-8") > maxBytesPerItem) return false;
|
|
1874
2027
|
try {
|
|
1875
2028
|
const dir = path2.dirname(p);
|
|
1876
|
-
if (!
|
|
2029
|
+
if (!fs2.existsSync(dir)) ensureDirSync(dir);
|
|
1877
2030
|
atomicWriteText(p, json);
|
|
1878
2031
|
} catch {
|
|
1879
2032
|
return false;
|
|
@@ -1891,8 +2044,8 @@ function loadBlob(subdir, id) {
|
|
|
1891
2044
|
const p = blobPath(subdir, id);
|
|
1892
2045
|
if (!p) return null;
|
|
1893
2046
|
try {
|
|
1894
|
-
if (!
|
|
1895
|
-
return JSON.parse(
|
|
2047
|
+
if (!fs2.existsSync(p)) return null;
|
|
2048
|
+
return JSON.parse(fs2.readFileSync(p, "utf8"));
|
|
1896
2049
|
} catch {
|
|
1897
2050
|
return null;
|
|
1898
2051
|
}
|
|
@@ -1901,13 +2054,13 @@ function listBlobs(subdir) {
|
|
|
1901
2054
|
const dir = blobDir(subdir);
|
|
1902
2055
|
const out = [];
|
|
1903
2056
|
try {
|
|
1904
|
-
if (!
|
|
1905
|
-
for (const file of
|
|
2057
|
+
if (!fs2.existsSync(dir)) return out;
|
|
2058
|
+
for (const file of fs2.readdirSync(dir)) {
|
|
1906
2059
|
if (!file.endsWith(".json")) continue;
|
|
1907
2060
|
const id = file.slice(0, -5);
|
|
1908
2061
|
let mtime = 0;
|
|
1909
2062
|
try {
|
|
1910
|
-
mtime =
|
|
2063
|
+
mtime = fs2.statSync(path2.join(dir, file)).mtimeMs;
|
|
1911
2064
|
} catch {
|
|
1912
2065
|
}
|
|
1913
2066
|
const value = loadBlob(subdir, id);
|
|
@@ -1924,15 +2077,15 @@ function pruneBlobs(subdir, maxCount = DEFAULT_MAX_COUNT, maxAgeMs = DEFAULT_MAX
|
|
|
1924
2077
|
function pruneBlobDir(dir, maxCount = DEFAULT_MAX_COUNT, maxAgeMs = DEFAULT_MAX_AGE_MS, maxBytes = Number.POSITIVE_INFINITY, protectedPath) {
|
|
1925
2078
|
let removed = 0;
|
|
1926
2079
|
try {
|
|
1927
|
-
if (!
|
|
2080
|
+
if (!fs2.existsSync(dir)) return 0;
|
|
1928
2081
|
const cutoff = Date.now() - maxAgeMs;
|
|
1929
2082
|
let kept = [];
|
|
1930
2083
|
let protectedEntry;
|
|
1931
|
-
for (const file of
|
|
2084
|
+
for (const file of fs2.readdirSync(dir)) {
|
|
1932
2085
|
const full = path2.join(dir, file);
|
|
1933
2086
|
let stat;
|
|
1934
2087
|
try {
|
|
1935
|
-
stat =
|
|
2088
|
+
stat = fs2.statSync(full);
|
|
1936
2089
|
} catch {
|
|
1937
2090
|
continue;
|
|
1938
2091
|
}
|
|
@@ -1943,7 +2096,7 @@ function pruneBlobDir(dir, maxCount = DEFAULT_MAX_COUNT, maxAgeMs = DEFAULT_MAX_
|
|
|
1943
2096
|
}
|
|
1944
2097
|
if (stat.mtimeMs < cutoff) {
|
|
1945
2098
|
try {
|
|
1946
|
-
|
|
2099
|
+
fs2.unlinkSync(full);
|
|
1947
2100
|
removed++;
|
|
1948
2101
|
} catch {
|
|
1949
2102
|
continue;
|
|
@@ -1959,7 +2112,7 @@ function pruneBlobDir(dir, maxCount = DEFAULT_MAX_COUNT, maxAgeMs = DEFAULT_MAX_
|
|
|
1959
2112
|
kept = kept.slice(kept.length - countBudget);
|
|
1960
2113
|
for (const [full] of excess) {
|
|
1961
2114
|
try {
|
|
1962
|
-
|
|
2115
|
+
fs2.unlinkSync(full);
|
|
1963
2116
|
removed++;
|
|
1964
2117
|
} catch {
|
|
1965
2118
|
continue;
|
|
@@ -1974,7 +2127,7 @@ function pruneBlobDir(dir, maxCount = DEFAULT_MAX_COUNT, maxAgeMs = DEFAULT_MAX_
|
|
|
1974
2127
|
if (!oldest) break;
|
|
1975
2128
|
const [full, , size] = oldest;
|
|
1976
2129
|
try {
|
|
1977
|
-
|
|
2130
|
+
fs2.unlinkSync(full);
|
|
1978
2131
|
removed++;
|
|
1979
2132
|
total -= size;
|
|
1980
2133
|
} catch {
|
|
@@ -2017,24 +2170,32 @@ function sweepCacheRoots(extraRoots = []) {
|
|
|
2017
2170
|
}
|
|
2018
2171
|
|
|
2019
2172
|
export {
|
|
2173
|
+
BYTES_PER_TOKEN,
|
|
2174
|
+
guardDivisor,
|
|
2175
|
+
classifyContent,
|
|
2020
2176
|
colorStdout,
|
|
2021
2177
|
RESET,
|
|
2022
2178
|
stripAnsiEscapes,
|
|
2023
|
-
stripAnsi,
|
|
2024
2179
|
fg,
|
|
2025
2180
|
C,
|
|
2026
2181
|
SOURCE_HINT,
|
|
2027
2182
|
savedTokensFromBytes,
|
|
2028
2183
|
formatLocalTimestamp,
|
|
2184
|
+
statsHasHarnessColumn,
|
|
2185
|
+
statsHasDurationColumn,
|
|
2186
|
+
getGlobalDb,
|
|
2187
|
+
HOOK_STATS_RETENTION_DAYS,
|
|
2029
2188
|
recordStat,
|
|
2030
2189
|
recordUnmappedTool,
|
|
2031
2190
|
readUnmappedTools,
|
|
2191
|
+
pruneStalePatternCoveredUnmappedTools,
|
|
2032
2192
|
summarize,
|
|
2033
2193
|
_useRichStats,
|
|
2034
2194
|
renderShortStats,
|
|
2035
2195
|
renderStats2 as renderStats,
|
|
2036
2196
|
countRedactionPlaceholders,
|
|
2037
2197
|
compileCustomPatterns,
|
|
2198
|
+
hasPreciseSecret,
|
|
2038
2199
|
redactSecrets,
|
|
2039
2200
|
DEFAULT_MAX_COUNT,
|
|
2040
2201
|
DEFAULT_MAX_AGE_MS,
|