context-compress 2026.3.3 → 2026.3.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.
Files changed (47) hide show
  1. package/README.md +14 -4
  2. package/dist/cli/doctor.d.ts.map +1 -1
  3. package/dist/cli/doctor.js +22 -1
  4. package/dist/cli/doctor.js.map +1 -1
  5. package/dist/config.d.ts +7 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +148 -4
  8. package/dist/config.js.map +1 -1
  9. package/dist/executor.d.ts +11 -0
  10. package/dist/executor.d.ts.map +1 -1
  11. package/dist/executor.js +104 -3
  12. package/dist/executor.js.map +1 -1
  13. package/dist/hooks/pretooluse.js +12 -6
  14. package/dist/hooks/pretooluse.js.map +1 -1
  15. package/dist/logger.d.ts +0 -2
  16. package/dist/logger.d.ts.map +1 -1
  17. package/dist/logger.js +0 -6
  18. package/dist/logger.js.map +1 -1
  19. package/dist/network.d.ts +14 -0
  20. package/dist/network.d.ts.map +1 -0
  21. package/dist/network.js +103 -0
  22. package/dist/network.js.map +1 -0
  23. package/dist/runtime/languages/go.js +3 -3
  24. package/dist/runtime/languages/go.js.map +1 -1
  25. package/dist/runtime/languages/javascript.js +1 -1
  26. package/dist/runtime/languages/javascript.js.map +1 -1
  27. package/dist/runtime/languages/r.js +1 -1
  28. package/dist/runtime/languages/rust.js +2 -2
  29. package/dist/runtime/languages/rust.js.map +1 -1
  30. package/dist/runtime/languages/typescript.js +1 -1
  31. package/dist/runtime/languages/typescript.js.map +1 -1
  32. package/dist/server.bundle.mjs +757 -243
  33. package/dist/server.bundle.mjs.map +4 -4
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +262 -74
  36. package/dist/server.js.map +1 -1
  37. package/dist/stats.d.ts.map +1 -1
  38. package/dist/stats.js +43 -7
  39. package/dist/stats.js.map +1 -1
  40. package/dist/store.d.ts +20 -1
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js +78 -76
  43. package/dist/store.js.map +1 -1
  44. package/dist/types.d.ts +0 -19
  45. package/dist/types.d.ts.map +1 -1
  46. package/hooks/pretooluse.mjs +10 -5
  47. package/package.json +6 -4
@@ -6800,85 +6800,8 @@ var require_dist = __commonJS({
6800
6800
 
6801
6801
  // src/config.ts
6802
6802
  import { readFileSync } from "node:fs";
6803
+ import { homedir } from "node:os";
6803
6804
  import { join } from "node:path";
6804
- var DEFAULTS = {
6805
- passthroughEnvVars: [],
6806
- debug: false,
6807
- blockCurl: true,
6808
- blockWebFetch: true,
6809
- nudgeOnRead: true,
6810
- nudgeOnGrep: true,
6811
- intentSearchThreshold: 5e3,
6812
- maxOutputBytes: 102400,
6813
- hardCapBytes: 100 * 1024 * 1024,
6814
- searchMaxBytes: 40960,
6815
- batchMaxBytes: 81920,
6816
- searchLimit: 3,
6817
- searchWindowMs: 6e4,
6818
- searchReduceAfter: 3,
6819
- searchBlockAfter: 8
6820
- };
6821
- function loadFileConfig(projectDir2) {
6822
- const paths = [
6823
- projectDir2 && join(projectDir2, ".context-compress.json"),
6824
- join(process.env.HOME ?? "~", ".context-compress.json")
6825
- ].filter(Boolean);
6826
- for (const p of paths) {
6827
- try {
6828
- const raw = readFileSync(p, "utf-8");
6829
- return JSON.parse(raw);
6830
- } catch {
6831
- }
6832
- }
6833
- return {};
6834
- }
6835
- function loadEnvConfig() {
6836
- const partial2 = {};
6837
- if (process.env.CONTEXT_COMPRESS_DEBUG === "1") {
6838
- partial2.debug = true;
6839
- }
6840
- if (process.env.CONTEXT_COMPRESS_PASSTHROUGH_ENV) {
6841
- partial2.passthroughEnvVars = process.env.CONTEXT_COMPRESS_PASSTHROUGH_ENV.split(",").map((s) => s.trim()).filter(Boolean);
6842
- }
6843
- if (process.env.CONTEXT_COMPRESS_BLOCK_CURL !== void 0) {
6844
- partial2.blockCurl = process.env.CONTEXT_COMPRESS_BLOCK_CURL !== "0";
6845
- }
6846
- if (process.env.CONTEXT_COMPRESS_BLOCK_WEBFETCH !== void 0) {
6847
- partial2.blockWebFetch = process.env.CONTEXT_COMPRESS_BLOCK_WEBFETCH !== "0";
6848
- }
6849
- if (process.env.CONTEXT_COMPRESS_NUDGE_READ !== void 0) {
6850
- partial2.nudgeOnRead = process.env.CONTEXT_COMPRESS_NUDGE_READ !== "0";
6851
- }
6852
- if (process.env.CONTEXT_COMPRESS_NUDGE_GREP !== void 0) {
6853
- partial2.nudgeOnGrep = process.env.CONTEXT_COMPRESS_NUDGE_GREP !== "0";
6854
- }
6855
- return partial2;
6856
- }
6857
- var _config = null;
6858
- function loadConfig(projectDir2) {
6859
- if (_config) return _config;
6860
- const fileConfig = loadFileConfig(projectDir2);
6861
- const envConfig = loadEnvConfig();
6862
- _config = { ...DEFAULTS, ...fileConfig, ...envConfig };
6863
- return _config;
6864
- }
6865
- function getConfig() {
6866
- if (!_config) return loadConfig();
6867
- return _config;
6868
- }
6869
-
6870
- // src/logger.ts
6871
- function debug(...args) {
6872
- if (getConfig().debug) {
6873
- process.stderr.write(`[context-compress] ${args.map(String).join(" ")}
6874
- `);
6875
- }
6876
- }
6877
-
6878
- // src/server.ts
6879
- import { readFileSync as readFileSync2 } from "node:fs";
6880
- import { dirname, join as join4, resolve } from "node:path";
6881
- import { fileURLToPath } from "node:url";
6882
6805
 
6883
6806
  // node_modules/zod/v3/external.js
6884
6807
  var external_exports = {};
@@ -10921,6 +10844,227 @@ var coerce = {
10921
10844
  };
10922
10845
  var NEVER = INVALID;
10923
10846
 
10847
+ // src/config.ts
10848
+ var DEFAULTS = {
10849
+ passthroughEnvVars: [],
10850
+ debug: false,
10851
+ blockCurl: true,
10852
+ blockWebFetch: true,
10853
+ nudgeOnRead: true,
10854
+ nudgeOnGrep: true,
10855
+ intentSearchThreshold: 5e3,
10856
+ maxOutputBytes: 102400,
10857
+ hardCapBytes: 100 * 1024 * 1024,
10858
+ searchMaxBytes: 40960,
10859
+ batchMaxBytes: 81920,
10860
+ searchLimit: 3,
10861
+ searchWindowMs: 6e4,
10862
+ searchReduceAfter: 3,
10863
+ searchBlockAfter: 8,
10864
+ compressionLevel: "normal",
10865
+ persistDb: false,
10866
+ dbDir: null
10867
+ };
10868
+ var LEVEL_OVERRIDES = {
10869
+ normal: {},
10870
+ compact: {
10871
+ maxOutputBytes: 51200,
10872
+ searchMaxBytes: 20480,
10873
+ batchMaxBytes: 40960,
10874
+ searchLimit: 2,
10875
+ intentSearchThreshold: 3e3
10876
+ },
10877
+ ultra: {
10878
+ maxOutputBytes: 25600,
10879
+ searchMaxBytes: 10240,
10880
+ batchMaxBytes: 20480,
10881
+ searchLimit: 1,
10882
+ intentSearchThreshold: 2e3
10883
+ }
10884
+ };
10885
+ var ConfigSchema = external_exports.object({
10886
+ passthroughEnvVars: external_exports.array(external_exports.string()).optional(),
10887
+ debug: external_exports.boolean().optional(),
10888
+ blockCurl: external_exports.boolean().optional(),
10889
+ blockWebFetch: external_exports.boolean().optional(),
10890
+ nudgeOnRead: external_exports.boolean().optional(),
10891
+ nudgeOnGrep: external_exports.boolean().optional(),
10892
+ intentSearchThreshold: external_exports.number().int().positive().optional(),
10893
+ maxOutputBytes: external_exports.number().int().positive().optional(),
10894
+ hardCapBytes: external_exports.number().int().positive().optional(),
10895
+ searchMaxBytes: external_exports.number().int().positive().optional(),
10896
+ batchMaxBytes: external_exports.number().int().positive().optional(),
10897
+ searchLimit: external_exports.number().int().positive().optional(),
10898
+ searchWindowMs: external_exports.number().int().positive().optional(),
10899
+ searchReduceAfter: external_exports.number().int().nonnegative().optional(),
10900
+ searchBlockAfter: external_exports.number().int().positive().optional(),
10901
+ compressionLevel: external_exports.enum(["normal", "compact", "ultra"]).optional(),
10902
+ persistDb: external_exports.boolean().optional(),
10903
+ dbDir: external_exports.string().nullable().optional()
10904
+ });
10905
+ function parseIntEnv(key) {
10906
+ const val = process.env[key];
10907
+ if (val === void 0) return void 0;
10908
+ const n = Number.parseInt(val, 10);
10909
+ return Number.isNaN(n) ? void 0 : n;
10910
+ }
10911
+ function loadFileConfig(projectDir2) {
10912
+ const paths = [
10913
+ projectDir2 && join(projectDir2, ".context-compress.json"),
10914
+ join(homedir(), ".context-compress.json")
10915
+ ].filter(Boolean);
10916
+ for (const p of paths) {
10917
+ try {
10918
+ const raw = readFileSync(p, "utf-8");
10919
+ const parsed = JSON.parse(raw);
10920
+ const result = ConfigSchema.safeParse(parsed);
10921
+ if (result.success) {
10922
+ return result.data;
10923
+ }
10924
+ return {};
10925
+ } catch {
10926
+ }
10927
+ }
10928
+ return {};
10929
+ }
10930
+ function loadEnvConfig() {
10931
+ const partial2 = {};
10932
+ if (process.env.CONTEXT_COMPRESS_DEBUG === "1") {
10933
+ partial2.debug = true;
10934
+ }
10935
+ if (process.env.CONTEXT_COMPRESS_PASSTHROUGH_ENV) {
10936
+ partial2.passthroughEnvVars = process.env.CONTEXT_COMPRESS_PASSTHROUGH_ENV.split(",").map((s) => s.trim()).filter(Boolean);
10937
+ }
10938
+ if (process.env.CONTEXT_COMPRESS_BLOCK_CURL !== void 0) {
10939
+ partial2.blockCurl = process.env.CONTEXT_COMPRESS_BLOCK_CURL !== "0";
10940
+ }
10941
+ if (process.env.CONTEXT_COMPRESS_BLOCK_WEBFETCH !== void 0) {
10942
+ partial2.blockWebFetch = process.env.CONTEXT_COMPRESS_BLOCK_WEBFETCH !== "0";
10943
+ }
10944
+ if (process.env.CONTEXT_COMPRESS_NUDGE_READ !== void 0) {
10945
+ partial2.nudgeOnRead = process.env.CONTEXT_COMPRESS_NUDGE_READ !== "0";
10946
+ }
10947
+ if (process.env.CONTEXT_COMPRESS_NUDGE_GREP !== void 0) {
10948
+ partial2.nudgeOnGrep = process.env.CONTEXT_COMPRESS_NUDGE_GREP !== "0";
10949
+ }
10950
+ const maxOutput = parseIntEnv("CONTEXT_COMPRESS_MAX_OUTPUT_BYTES");
10951
+ if (maxOutput !== void 0) partial2.maxOutputBytes = maxOutput;
10952
+ const hardCap = parseIntEnv("CONTEXT_COMPRESS_HARD_CAP_BYTES");
10953
+ if (hardCap !== void 0) partial2.hardCapBytes = hardCap;
10954
+ const searchMax = parseIntEnv("CONTEXT_COMPRESS_SEARCH_MAX_BYTES");
10955
+ if (searchMax !== void 0) partial2.searchMaxBytes = searchMax;
10956
+ const batchMax = parseIntEnv("CONTEXT_COMPRESS_BATCH_MAX_BYTES");
10957
+ if (batchMax !== void 0) partial2.batchMaxBytes = batchMax;
10958
+ const searchLimit = parseIntEnv("CONTEXT_COMPRESS_SEARCH_LIMIT");
10959
+ if (searchLimit !== void 0) partial2.searchLimit = searchLimit;
10960
+ const searchWindow = parseIntEnv("CONTEXT_COMPRESS_SEARCH_WINDOW_MS");
10961
+ if (searchWindow !== void 0) partial2.searchWindowMs = searchWindow;
10962
+ const searchReduce = parseIntEnv("CONTEXT_COMPRESS_SEARCH_REDUCE_AFTER");
10963
+ if (searchReduce !== void 0) partial2.searchReduceAfter = searchReduce;
10964
+ const searchBlock = parseIntEnv("CONTEXT_COMPRESS_SEARCH_BLOCK_AFTER");
10965
+ if (searchBlock !== void 0) partial2.searchBlockAfter = searchBlock;
10966
+ const intentThreshold = parseIntEnv("CONTEXT_COMPRESS_INTENT_SEARCH_THRESHOLD");
10967
+ if (intentThreshold !== void 0) partial2.intentSearchThreshold = intentThreshold;
10968
+ const level = process.env.CONTEXT_COMPRESS_LEVEL;
10969
+ if (level === "normal" || level === "compact" || level === "ultra") {
10970
+ partial2.compressionLevel = level;
10971
+ }
10972
+ if (process.env.CONTEXT_COMPRESS_PERSIST_DB === "1") {
10973
+ partial2.persistDb = true;
10974
+ }
10975
+ if (process.env.CONTEXT_COMPRESS_DB_DIR) {
10976
+ partial2.dbDir = process.env.CONTEXT_COMPRESS_DB_DIR;
10977
+ }
10978
+ return partial2;
10979
+ }
10980
+ var _config = null;
10981
+ function loadConfig(projectDir2) {
10982
+ if (_config) return _config;
10983
+ const fileConfig = loadFileConfig(projectDir2);
10984
+ const envConfig = loadEnvConfig();
10985
+ const merged = { ...DEFAULTS, ...fileConfig, ...envConfig };
10986
+ const levelOverrides = LEVEL_OVERRIDES[merged.compressionLevel];
10987
+ for (const [key, value] of Object.entries(levelOverrides)) {
10988
+ const k = key;
10989
+ if (!(k in fileConfig) && !(k in envConfig)) {
10990
+ merged[k] = value;
10991
+ }
10992
+ }
10993
+ if (merged.maxOutputBytes < 1024) {
10994
+ console.error(
10995
+ `[context-compress] Config: maxOutputBytes clamped from ${merged.maxOutputBytes} to 1024`
10996
+ );
10997
+ merged.maxOutputBytes = 1024;
10998
+ }
10999
+ if (merged.hardCapBytes < merged.maxOutputBytes) {
11000
+ console.error(
11001
+ `[context-compress] Config: hardCapBytes clamped from ${merged.hardCapBytes} to ${merged.maxOutputBytes}`
11002
+ );
11003
+ merged.hardCapBytes = merged.maxOutputBytes;
11004
+ }
11005
+ if (merged.intentSearchThreshold < 0) {
11006
+ console.error(
11007
+ `[context-compress] Config: intentSearchThreshold clamped from ${merged.intentSearchThreshold} to 0`
11008
+ );
11009
+ merged.intentSearchThreshold = 0;
11010
+ }
11011
+ if (merged.searchLimit < 1) {
11012
+ console.error(`[context-compress] Config: searchLimit clamped from ${merged.searchLimit} to 1`);
11013
+ merged.searchLimit = 1;
11014
+ }
11015
+ if (merged.searchWindowMs < 1e3) {
11016
+ console.error(
11017
+ `[context-compress] Config: searchWindowMs clamped from ${merged.searchWindowMs} to 1000`
11018
+ );
11019
+ merged.searchWindowMs = 1e3;
11020
+ }
11021
+ if (merged.searchReduceAfter < 1) {
11022
+ console.error(
11023
+ `[context-compress] Config: searchReduceAfter clamped from ${merged.searchReduceAfter} to 1`
11024
+ );
11025
+ merged.searchReduceAfter = 1;
11026
+ }
11027
+ if (merged.searchBlockAfter < merged.searchReduceAfter + 1) {
11028
+ const minVal = merged.searchReduceAfter + 1;
11029
+ console.error(
11030
+ `[context-compress] Config: searchBlockAfter clamped from ${merged.searchBlockAfter} to ${minVal}`
11031
+ );
11032
+ merged.searchBlockAfter = minVal;
11033
+ }
11034
+ if (merged.searchMaxBytes < 1024) {
11035
+ console.error(
11036
+ `[context-compress] Config: searchMaxBytes clamped from ${merged.searchMaxBytes} to 1024`
11037
+ );
11038
+ merged.searchMaxBytes = 1024;
11039
+ }
11040
+ if (merged.batchMaxBytes < 1024) {
11041
+ console.error(
11042
+ `[context-compress] Config: batchMaxBytes clamped from ${merged.batchMaxBytes} to 1024`
11043
+ );
11044
+ merged.batchMaxBytes = 1024;
11045
+ }
11046
+ if (merged.dbDir) merged.persistDb = true;
11047
+ _config = merged;
11048
+ return _config;
11049
+ }
11050
+ function getConfig() {
11051
+ if (!_config) return loadConfig();
11052
+ return _config;
11053
+ }
11054
+
11055
+ // src/logger.ts
11056
+ function debug(...args) {
11057
+ if (getConfig().debug) {
11058
+ process.stderr.write(`[context-compress] ${args.map(String).join(" ")}
11059
+ `);
11060
+ }
11061
+ }
11062
+
11063
+ // src/server.ts
11064
+ import { readFileSync as readFileSync2, realpathSync, statSync } from "node:fs";
11065
+ import { dirname, join as join4, resolve } from "node:path";
11066
+ import { fileURLToPath } from "node:url";
11067
+
10924
11068
  // node_modules/zod/v4/core/core.js
10925
11069
  var NEVER2 = Object.freeze({
10926
11070
  status: "aborted"
@@ -21073,7 +21217,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
21073
21217
  import { tmpdir } from "node:os";
21074
21218
  import { join as join2 } from "node:path";
21075
21219
  var DEFAULT_TIMEOUT = 3e4;
21076
- var DEFAULT_HARD_CAP = 100 * 1024 * 1024;
21077
21220
  var SAFE_ENV_KEYS = [
21078
21221
  "PATH",
21079
21222
  "HOME",
@@ -21126,6 +21269,86 @@ function killProcessTree(pid) {
21126
21269
  }
21127
21270
  }
21128
21271
  }
21272
+ function deduplicateLines(output) {
21273
+ const lines = output.split("\n");
21274
+ if (lines.length < 3) return output;
21275
+ const result = [];
21276
+ let prevLine = lines[0];
21277
+ let count = 1;
21278
+ for (let i = 1; i < lines.length; i++) {
21279
+ if (lines[i] === prevLine && prevLine.trim().length > 0) {
21280
+ count++;
21281
+ } else {
21282
+ if (count > 2) {
21283
+ result.push(prevLine);
21284
+ result.push(` ... (\xD7${count} identical lines)`);
21285
+ } else {
21286
+ for (let j = 0; j < count; j++) result.push(prevLine);
21287
+ }
21288
+ prevLine = lines[i];
21289
+ count = 1;
21290
+ }
21291
+ }
21292
+ if (count > 2) {
21293
+ result.push(prevLine);
21294
+ result.push(` ... (\xD7${count} identical lines)`);
21295
+ } else {
21296
+ for (let j = 0; j < count; j++) result.push(prevLine);
21297
+ }
21298
+ return result.join("\n");
21299
+ }
21300
+ function groupErrorLines(output) {
21301
+ const lines = output.split("\n");
21302
+ if (lines.length < 5) return output;
21303
+ const ERROR_RE = /^(.*?(?:error|warning|Error|Warning|ERR|WARN)[:\s])\s*(.+?)(?:\s+(?:at|in|on)\s+(?:line\s+)?(\d+))?$/i;
21304
+ const errorGroups = /* @__PURE__ */ new Map();
21305
+ const resultLines = [];
21306
+ let groupedCount = 0;
21307
+ for (const line of lines) {
21308
+ const match = line.match(ERROR_RE);
21309
+ if (match) {
21310
+ const prefix = match[1].trim();
21311
+ const msg = match[2].trim();
21312
+ const key = `${prefix}|${msg}`;
21313
+ const existing = errorGroups.get(key);
21314
+ if (existing) {
21315
+ existing.count++;
21316
+ if (match[3]) existing.locations.push(match[3]);
21317
+ groupedCount++;
21318
+ continue;
21319
+ }
21320
+ errorGroups.set(key, {
21321
+ message: `${prefix} ${msg}`,
21322
+ locations: match[3] ? [match[3]] : [],
21323
+ count: 1
21324
+ });
21325
+ groupedCount++;
21326
+ continue;
21327
+ }
21328
+ resultLines.push(line);
21329
+ }
21330
+ if (groupedCount < 4 || errorGroups.size === groupedCount) return output;
21331
+ const grouped = [];
21332
+ for (const [, group] of errorGroups) {
21333
+ if (group.count === 1) {
21334
+ grouped.push(
21335
+ group.message + (group.locations.length ? ` at line ${group.locations[0]}` : "")
21336
+ );
21337
+ } else {
21338
+ let line = `${group.message} (\xD7${group.count})`;
21339
+ if (group.locations.length > 0) {
21340
+ line += ` [lines: ${group.locations.join(", ")}]`;
21341
+ }
21342
+ grouped.push(line);
21343
+ }
21344
+ }
21345
+ if (grouped.length > 0) {
21346
+ resultLines.push("");
21347
+ resultLines.push(`\u2500\u2500 Grouped errors/warnings (${groupedCount} \u2192 ${errorGroups.size}) \u2500\u2500`);
21348
+ resultLines.push(...grouped);
21349
+ }
21350
+ return resultLines.join("\n");
21351
+ }
21129
21352
  function smartTruncate(output, maxBytes) {
21130
21353
  if (Buffer.byteLength(output) <= maxBytes) return output;
21131
21354
  const lines = output.split("\n");
@@ -21231,7 +21454,7 @@ var SubprocessExecutor = class {
21231
21454
  plugin.needsShell
21232
21455
  );
21233
21456
  } finally {
21234
- setTimeout(() => this.cleanupTempDir(tmpDir), 100);
21457
+ setTimeout(() => this.cleanupTempDir(tmpDir), 100).unref();
21235
21458
  }
21236
21459
  }
21237
21460
  /**
@@ -21317,6 +21540,8 @@ var SubprocessExecutor = class {
21317
21540
  stdout += `
21318
21541
  [output capped at ${formatBytes(hardCap)} \u2014 process killed]`;
21319
21542
  }
21543
+ stdout = deduplicateLines(stdout);
21544
+ stdout = groupErrorLines(stdout);
21320
21545
  const truncated = Buffer.byteLength(stdout) > maxOutput;
21321
21546
  if (truncated) {
21322
21547
  stdout = smartTruncate(stdout, maxOutput);
@@ -21354,6 +21579,65 @@ async function __cm_main(){${code}}
21354
21579
  __cm_main().then(()=>{${epilogue}}).catch(e=>{console.error(e);${epilogue}process.exit(1)});`;
21355
21580
  }
21356
21581
 
21582
+ // src/network.ts
21583
+ import dns from "node:dns";
21584
+ function isPrivateHost(hostname2) {
21585
+ const h = hostname2.startsWith("[") && hostname2.endsWith("]") ? hostname2.slice(1, -1) : hostname2;
21586
+ const lower = h.toLowerCase();
21587
+ if (lower === "localhost" || lower === "0.0.0.0") return true;
21588
+ if (/^0\./.test(h)) return true;
21589
+ if (/^127\./.test(h)) return true;
21590
+ if (/^10\./.test(h)) return true;
21591
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
21592
+ if (/^192\.168\./.test(h)) return true;
21593
+ if (/^169\.254\./.test(h)) return true;
21594
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true;
21595
+ if (lower === "::1") return true;
21596
+ if (lower === "::" || lower === "0:0:0:0:0:0:0:0") return true;
21597
+ const mappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
21598
+ if (mappedMatch) return isPrivateHost(mappedMatch[1]);
21599
+ if (/^fe[89ab]/i.test(h)) return true;
21600
+ if (/^f[cd]/i.test(h)) return true;
21601
+ return false;
21602
+ }
21603
+ async function resolveAndValidate(url) {
21604
+ const parsed = new URL(url);
21605
+ const hostname2 = parsed.hostname;
21606
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname2) || hostname2.includes(":")) {
21607
+ if (isPrivateHost(hostname2)) {
21608
+ throw new Error(`Blocked: resolved IP ${hostname2} is a private/internal address`);
21609
+ }
21610
+ return { url, resolvedIp: null };
21611
+ }
21612
+ let resolvedIp = null;
21613
+ let v4Error = false;
21614
+ let v6Error = false;
21615
+ try {
21616
+ const { address } = await dns.promises.lookup(hostname2, { family: 4 });
21617
+ if (isPrivateHost(address)) {
21618
+ throw new Error(`Blocked: ${hostname2} resolved to private IP ${address}`);
21619
+ }
21620
+ resolvedIp = address;
21621
+ } catch (err) {
21622
+ if (err instanceof Error && err.message.startsWith("Blocked:")) throw err;
21623
+ v4Error = true;
21624
+ }
21625
+ try {
21626
+ const { address } = await dns.promises.lookup(hostname2, { family: 6 });
21627
+ if (isPrivateHost(address)) {
21628
+ throw new Error(`Blocked: ${hostname2} resolved to private IPv6 ${address}`);
21629
+ }
21630
+ if (!resolvedIp) resolvedIp = address;
21631
+ } catch (err) {
21632
+ if (err instanceof Error && err.message.startsWith("Blocked:")) throw err;
21633
+ v6Error = true;
21634
+ }
21635
+ if (v4Error && v6Error) {
21636
+ throw new Error(`DNS resolution failed for ${hostname2}: unable to verify host safety`);
21637
+ }
21638
+ return { url, resolvedIp };
21639
+ }
21640
+
21357
21641
  // src/runtime/index.ts
21358
21642
  import { exec } from "node:child_process";
21359
21643
  import { promisify } from "node:util";
@@ -21385,8 +21669,8 @@ var goPlugin = {
21385
21669
  return [runtime, "run", filePath];
21386
21670
  },
21387
21671
  preprocessCode(code) {
21388
- if (!code.includes("package ")) {
21389
- const hasImport = code.includes("import ");
21672
+ if (!/^package\s/m.test(code)) {
21673
+ const hasImport = /^import\s/m.test(code);
21390
21674
  if (hasImport) {
21391
21675
  return `package main
21392
21676
 
@@ -21405,7 +21689,7 @@ _ = fmt.Sprintf("")
21405
21689
  },
21406
21690
  wrapWithFileContent(code, filePath) {
21407
21691
  const escaped = JSON.stringify(filePath);
21408
- const hasPackage = code.includes("package ");
21692
+ const hasPackage = /^package\s/m.test(code);
21409
21693
  if (hasPackage) {
21410
21694
  return code.replace(
21411
21695
  /(package\s+\w+\n)/,
@@ -21446,8 +21730,9 @@ var javascriptPlugin = {
21446
21730
  },
21447
21731
  wrapWithFileContent(code, filePath) {
21448
21732
  const escaped = JSON.stringify(filePath);
21449
- return `const FILE_CONTENT_PATH = ${escaped};
21450
- const FILE_CONTENT = require("fs").readFileSync(FILE_CONTENT_PATH, "utf-8");
21733
+ return `const {readFileSync: __cm_readFileSync} = await import("node:fs");
21734
+ const FILE_CONTENT_PATH = ${escaped};
21735
+ const FILE_CONTENT = __cm_readFileSync(FILE_CONTENT_PATH, "utf-8");
21451
21736
  ${code}`;
21452
21737
  }
21453
21738
  };
@@ -21519,7 +21804,7 @@ ${code}`;
21519
21804
  // src/runtime/languages/r.ts
21520
21805
  var rPlugin = {
21521
21806
  language: "r",
21522
- runtimeCandidates: ["Rscript", "r"],
21807
+ runtimeCandidates: ["Rscript", "R"],
21523
21808
  fileExtension: ".R",
21524
21809
  buildCommand(runtime, filePath) {
21525
21810
  return [runtime, filePath];
@@ -21562,7 +21847,7 @@ var rustPlugin = {
21562
21847
  return [runtime, srcPath, "-o", binPath];
21563
21848
  },
21564
21849
  preprocessCode(code) {
21565
- if (!code.includes("fn main")) {
21850
+ if (!/^fn\s+main\s*\(/m.test(code)) {
21566
21851
  return `fn main() {
21567
21852
  ${code}
21568
21853
  }`;
@@ -21575,7 +21860,7 @@ ${code}
21575
21860
  let file_content_path = ${escaped};
21576
21861
  let file_content = fs::read_to_string(file_content_path).unwrap();
21577
21862
  `;
21578
- if (code.includes("fn main")) {
21863
+ if (/^fn\s+main\s*\(/m.test(code)) {
21579
21864
  return code.replace(/fn main\s*\(\s*\)\s*\{/, `fn main() {
21580
21865
  ${preamble}`);
21581
21866
  }
@@ -21611,8 +21896,9 @@ var typescriptPlugin = {
21611
21896
  },
21612
21897
  wrapWithFileContent(code, filePath) {
21613
21898
  const escaped = JSON.stringify(filePath);
21614
- return `const FILE_CONTENT_PATH = ${escaped};
21615
- const FILE_CONTENT = require("fs").readFileSync(FILE_CONTENT_PATH, "utf-8");
21899
+ return `const {readFileSync: __cm_readFileSync} = await import("node:fs");
21900
+ const FILE_CONTENT_PATH = ${escaped};
21901
+ const FILE_CONTENT = __cm_readFileSync(FILE_CONTENT_PATH, "utf-8");
21616
21902
  ${code}`;
21617
21903
  },
21618
21904
  // tsx and ts-node may be .cmd shims on Windows
@@ -21668,6 +21954,17 @@ function hasBun(runtimes) {
21668
21954
  }
21669
21955
 
21670
21956
  // src/stats.ts
21957
+ var BAR_WIDTH = 20;
21958
+ function asciiBar(ratio, width = BAR_WIDTH) {
21959
+ const filled = Math.round(ratio * width);
21960
+ const empty = width - filled;
21961
+ return `[${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}] ${(ratio * 100).toFixed(0)}%`;
21962
+ }
21963
+ function tokenCost(tokens) {
21964
+ const sonnetCost = tokens / 1e6 * 3;
21965
+ if (sonnetCost < 0.01) return "<$0.01";
21966
+ return `~$${sonnetCost.toFixed(2)} (Sonnet)`;
21967
+ }
21671
21968
  var SessionTracker = class {
21672
21969
  stats = {
21673
21970
  calls: {},
@@ -21698,9 +21995,14 @@ var SessionTracker = class {
21698
21995
  const totalReturned = Object.values(snap.bytesReturned).reduce((a, b) => a + b, 0);
21699
21996
  const keptOut = snap.bytesIndexed + snap.bytesSandboxed;
21700
21997
  const totalProcessed = keptOut + totalReturned;
21701
- const savingsRatio = totalReturned > 0 ? totalProcessed / totalReturned : 1;
21998
+ const savingsRatio = totalReturned > 0 ? totalProcessed / totalReturned : keptOut > 0 ? Number.POSITIVE_INFINITY : 1;
21702
21999
  const reductionPct = totalProcessed > 0 ? ((1 - totalReturned / totalProcessed) * 100).toFixed(1) : "0.0";
21703
- const estTokens = Math.round(totalReturned / 4);
22000
+ const estTokensLo = Math.round(totalReturned / 5);
22001
+ const estTokensHi = Math.round(totalReturned / 3);
22002
+ const estTokensAvoidedLo = Math.round(keptOut / 5);
22003
+ const estTokensAvoidedHi = Math.round(keptOut / 3);
22004
+ const estTokensMid = Math.round(totalReturned / 4);
22005
+ const estTokensAvoidedMid = Math.round(keptOut / 4);
21704
22006
  const lines = [];
21705
22007
  lines.push("## Session Statistics\n");
21706
22008
  lines.push("| Metric | Value |");
@@ -21710,18 +22012,33 @@ var SessionTracker = class {
21710
22012
  lines.push(`| Total data processed | ${formatBytes(totalProcessed)} |`);
21711
22013
  lines.push(`| Kept in sandbox | ${formatBytes(keptOut)} |`);
21712
22014
  lines.push(`| Context consumed | ${formatBytes(totalReturned)} |`);
21713
- lines.push(`| Est. tokens | ~${estTokens.toLocaleString()} |`);
21714
22015
  lines.push(
21715
- `| **Savings ratio** | **${savingsRatio.toFixed(1)}x** (${reductionPct}% reduction) |`
22016
+ `| Est. tokens used | ~${estTokensLo.toLocaleString()}-${estTokensHi.toLocaleString()} tokens (${tokenCost(estTokensMid)}) |`
22017
+ );
22018
+ lines.push(
22019
+ `| Est. tokens saved | ~${estTokensAvoidedLo.toLocaleString()}-${estTokensAvoidedHi.toLocaleString()} tokens (${tokenCost(estTokensAvoidedMid)}) |`
21716
22020
  );
22021
+ const savingsLabel = Number.isFinite(savingsRatio) ? `${savingsRatio.toFixed(1)}x` : "\u221E";
22022
+ lines.push(`| **Savings ratio** | **${savingsLabel}** (${reductionPct}% reduction) |`);
22023
+ if (totalProcessed > 0) {
22024
+ const savingsBar = asciiBar(keptOut / totalProcessed);
22025
+ lines.push(`
22026
+ **Context savings:** ${savingsBar}`);
22027
+ lines.push(
22028
+ ` Sandbox: ${formatBytes(keptOut)} kept out | Context: ${formatBytes(totalReturned)} entered`
22029
+ );
22030
+ }
21717
22031
  if (totalCalls > 0) {
21718
22032
  lines.push("\n## Per-Tool Breakdown\n");
21719
- lines.push("| Tool | Calls | Context bytes | Est. tokens |");
21720
- lines.push("|------|-------|--------------|-------------|");
22033
+ const maxBytes = Math.max(...Object.values(snap.bytesReturned));
21721
22034
  for (const [name, calls] of Object.entries(snap.calls)) {
21722
22035
  const bytes = snap.bytesReturned[name] ?? 0;
22036
+ const tokLo = Math.round(bytes / 5);
22037
+ const tokHi = Math.round(bytes / 3);
22038
+ const barRatio = maxBytes > 0 ? bytes / maxBytes : 0;
22039
+ const bar = "\u2588".repeat(Math.max(1, Math.round(barRatio * 15)));
21723
22040
  lines.push(
21724
- `| ${name} | ${calls} | ${formatBytes(bytes)} | ~${Math.round(bytes / 4).toLocaleString()} |`
22041
+ ` ${name.padEnd(16)} ${String(calls).padStart(3)} calls ${bar} ${formatBytes(bytes)} (~${tokLo.toLocaleString()}-${tokHi.toLocaleString()} tok)`
21725
22042
  );
21726
22043
  }
21727
22044
  }
@@ -21734,7 +22051,7 @@ Context-compress kept ${formatBytes(keptOut)} out of context (${reductionPct}% s
21734
22051
  };
21735
22052
 
21736
22053
  // src/store.ts
21737
- import { readdirSync, unlinkSync } from "node:fs";
22054
+ import { mkdirSync as mkdirSync2, readdirSync, unlinkSync } from "node:fs";
21738
22055
  import { tmpdir as tmpdir2 } from "node:os";
21739
22056
  import { join as join3 } from "node:path";
21740
22057
  import Database from "better-sqlite3";
@@ -21825,20 +22142,15 @@ var STOPWORDS = /* @__PURE__ */ new Set([
21825
22142
  "how",
21826
22143
  "its",
21827
22144
  "may",
21828
- "new",
21829
22145
  "now",
21830
22146
  "old",
21831
22147
  "see",
21832
22148
  "way",
21833
22149
  "who",
21834
22150
  "did",
21835
- "get",
21836
- "got",
21837
- "let",
21838
22151
  "say",
21839
22152
  "she",
21840
22153
  "too",
21841
- "use",
21842
22154
  "will",
21843
22155
  "with",
21844
22156
  "this",
@@ -21852,7 +22164,6 @@ var STOPWORDS = /* @__PURE__ */ new Set([
21852
22164
  "them",
21853
22165
  "than",
21854
22166
  "each",
21855
- "make",
21856
22167
  "like",
21857
22168
  "just",
21858
22169
  "over",
@@ -21892,21 +22203,7 @@ var STOPWORDS = /* @__PURE__ */ new Set([
21892
22203
  "where",
21893
22204
  "here",
21894
22205
  "were",
21895
- "much",
21896
- "update",
21897
- "updates",
21898
- "updated",
21899
- "deps",
21900
- "dev",
21901
- "tests",
21902
- "test",
21903
- "add",
21904
- "added",
21905
- "fix",
21906
- "fixed",
21907
- "run",
21908
- "running",
21909
- "using"
22206
+ "much"
21910
22207
  ]);
21911
22208
  var HEADING_RE = /^(#{1,4})\s+(.+)$/;
21912
22209
  var SEPARATOR_RE = /^[-_*]{3,}\s*$/;
@@ -21917,7 +22214,7 @@ var WORD_SPLIT_RE = /[^\p{L}\p{N}_-]+/u;
21917
22214
  function sanitizeQuery(raw) {
21918
22215
  const q = raw.replace(FTS_SPECIAL_RE, " ").replace(FTS_OPERATORS_RE, " ").trim();
21919
22216
  const words = q.split(/\s+/).filter((w) => w.length >= 2).map((w) => `"${w}"`);
21920
- return words.length > 0 ? words.join(" OR ") : '""';
22217
+ return words.length > 0 ? words.join(" OR ") : "";
21921
22218
  }
21922
22219
  function levenshtein(a, b) {
21923
22220
  if (a.length === 0) return b.length;
@@ -21937,8 +22234,23 @@ function levenshtein(a, b) {
21937
22234
  var ContentStore = class {
21938
22235
  db;
21939
22236
  hasTrigramTable = false;
21940
- constructor(dbPath) {
21941
- const path = dbPath ?? join3(tmpdir2(), `context-compress-${process.pid}.db`);
22237
+ // Cached prepared statements (initialized in initSchema, always available after constructor)
22238
+ insertSourceStmt;
22239
+ insertChunkStmt;
22240
+ vocabCountStmt;
22241
+ vocabInsertStmt;
22242
+ constructor(options) {
22243
+ let path;
22244
+ if (typeof options === "string") {
22245
+ path = options;
22246
+ } else if (options?.persistDb || options?.dbDir) {
22247
+ const dir = options.dbDir ?? join3(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), ".context-compress");
22248
+ mkdirSync2(dir, { recursive: true });
22249
+ path = join3(dir, "store.db");
22250
+ debug("Using persistent DB at", path);
22251
+ } else {
22252
+ path = (typeof options === "object" ? options?.dbPath : void 0) ?? join3(tmpdir2(), `context-compress-${process.pid}.db`);
22253
+ }
21942
22254
  this.db = new Database(path);
21943
22255
  this.db.pragma("journal_mode = WAL");
21944
22256
  this.db.pragma("synchronous = NORMAL");
@@ -21966,6 +22278,14 @@ var ContentStore = class {
21966
22278
  word TEXT PRIMARY KEY
21967
22279
  );
21968
22280
  `);
22281
+ this.insertSourceStmt = this.db.prepare(
22282
+ "INSERT INTO sources (label, chunk_count, code_chunk_count) VALUES (?, ?, ?)"
22283
+ );
22284
+ this.insertChunkStmt = this.db.prepare(
22285
+ "INSERT INTO chunks (title, content, source_id, content_type) VALUES (?, ?, ?, ?)"
22286
+ );
22287
+ this.vocabCountStmt = this.db.prepare("SELECT COUNT(*) as cnt FROM vocabulary");
22288
+ this.vocabInsertStmt = this.db.prepare("INSERT OR IGNORE INTO vocabulary (word) VALUES (?)");
21969
22289
  }
21970
22290
  /** Lazily create trigram table only when porter search returns 0 results */
21971
22291
  ensureTrigramTable() {
@@ -21991,12 +22311,8 @@ var ContentStore = class {
21991
22311
  index(content, label) {
21992
22312
  const isMarkdown = HEADING_RE.test(content) || content.includes("```") || content.includes("---");
21993
22313
  const chunks = isMarkdown ? chunkMarkdown(content) : chunkPlainText(content);
21994
- const insertSource = this.db.prepare(
21995
- "INSERT INTO sources (label, chunk_count, code_chunk_count) VALUES (?, ?, ?)"
21996
- );
21997
- const insertChunk = this.db.prepare(
21998
- "INSERT INTO chunks (title, content, source_id, content_type) VALUES (?, ?, ?, ?)"
21999
- );
22314
+ const insertSource = this.insertSourceStmt;
22315
+ const insertChunk = this.insertChunkStmt;
22000
22316
  const insertTrigram = this.hasTrigramTable ? this.db.prepare(
22001
22317
  "INSERT INTO chunks_trigram (title, content, source_id, content_type) VALUES (?, ?, ?, ?)"
22002
22318
  ) : null;
@@ -22028,6 +22344,9 @@ var ContentStore = class {
22028
22344
  search(query, options) {
22029
22345
  const limit = options?.limit ?? 3;
22030
22346
  const sanitized = sanitizeQuery(query);
22347
+ if (!sanitized) {
22348
+ return { query, results: [] };
22349
+ }
22031
22350
  let hits = this.porterSearch(sanitized, options?.source, limit);
22032
22351
  if (hits.length > 0) {
22033
22352
  return { query, results: hits };
@@ -22040,29 +22359,31 @@ var ContentStore = class {
22040
22359
  const corrected = this.fuzzyCorrect(query);
22041
22360
  if (corrected && corrected !== query) {
22042
22361
  const correctedSanitized = sanitizeQuery(corrected);
22043
- hits = this.porterSearch(correctedSanitized, options?.source, limit);
22044
- if (hits.length > 0) {
22045
- return { query, results: hits, corrected };
22362
+ if (correctedSanitized) {
22363
+ hits = this.porterSearch(correctedSanitized, options?.source, limit);
22364
+ if (hits.length > 0) {
22365
+ return { query, results: hits, corrected };
22366
+ }
22046
22367
  }
22047
22368
  }
22048
22369
  return { query, results: [] };
22049
22370
  }
22050
- porterSearch(sanitized, source, limit) {
22371
+ ftsSearch(table, sanitized, source, limit) {
22051
22372
  const sourceFilter = source ? "AND sources.label LIKE '%' || ? || '%'" : "";
22052
22373
  const params = [sanitized];
22053
22374
  if (source) params.push(source);
22054
22375
  params.push(limit);
22055
22376
  const sql = `
22056
22377
  SELECT
22057
- chunks.title,
22058
- chunks.content,
22059
- chunks.content_type,
22378
+ ${table}.title,
22379
+ ${table}.content,
22380
+ ${table}.content_type,
22060
22381
  sources.label,
22061
- bm25(chunks, 2.0, 1.0) AS rank,
22062
- highlight(chunks, 1, char(2), char(3)) AS highlighted
22063
- FROM chunks
22064
- JOIN sources ON sources.id = chunks.source_id
22065
- WHERE chunks MATCH ? ${sourceFilter}
22382
+ bm25(${table}, 2.0, 1.0) AS rank,
22383
+ highlight(${table}, 1, char(2), char(3)) AS highlighted
22384
+ FROM ${table}
22385
+ JOIN sources ON sources.id = ${table}.source_id
22386
+ WHERE ${table} MATCH ? ${sourceFilter}
22066
22387
  ORDER BY rank
22067
22388
  LIMIT ?
22068
22389
  `;
@@ -22075,41 +22396,15 @@ var ContentStore = class {
22075
22396
  score: Math.abs(row.rank)
22076
22397
  }));
22077
22398
  } catch (e) {
22078
- debug("Porter search error:", e);
22399
+ debug(`FTS search error (${table}):`, e);
22079
22400
  return [];
22080
22401
  }
22081
22402
  }
22403
+ porterSearch(sanitized, source, limit) {
22404
+ return this.ftsSearch("chunks", sanitized, source, limit);
22405
+ }
22082
22406
  trigramSearch(sanitized, source, limit) {
22083
- const sourceFilter = source ? "AND sources.label LIKE '%' || ? || '%'" : "";
22084
- const params = [sanitized];
22085
- if (source) params.push(source);
22086
- params.push(limit);
22087
- const sql = `
22088
- SELECT
22089
- chunks_trigram.title,
22090
- chunks_trigram.content,
22091
- chunks_trigram.content_type,
22092
- sources.label,
22093
- bm25(chunks_trigram, 2.0, 1.0) AS rank,
22094
- highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
22095
- FROM chunks_trigram
22096
- JOIN sources ON sources.id = chunks_trigram.source_id
22097
- WHERE chunks_trigram MATCH ? ${sourceFilter}
22098
- ORDER BY rank
22099
- LIMIT ?
22100
- `;
22101
- try {
22102
- const rows = this.db.prepare(sql).all(...params);
22103
- return rows.map((row) => ({
22104
- title: row.title,
22105
- snippet: extractSnippet(row.highlighted),
22106
- source: row.label,
22107
- score: Math.abs(row.rank)
22108
- }));
22109
- } catch (e) {
22110
- debug("Trigram search error:", e);
22111
- return [];
22112
- }
22407
+ return this.ftsSearch("chunks_trigram", sanitized, source, limit);
22113
22408
  }
22114
22409
  /**
22115
22410
  * Fuzzy correction using vocabulary + Levenshtein distance.
@@ -22123,7 +22418,7 @@ var ContentStore = class {
22123
22418
  const maxDist = word.length <= 4 ? 1 : word.length <= 12 ? 2 : 3;
22124
22419
  const minLen = word.length - maxDist;
22125
22420
  const maxLen = word.length + maxDist;
22126
- const candidates = this.db.prepare("SELECT word FROM vocabulary WHERE length(word) BETWEEN ? AND ?").all(minLen, maxLen);
22421
+ const candidates = this.db.prepare("SELECT word FROM vocabulary WHERE length(word) BETWEEN ? AND ? LIMIT 500").all(minLen, maxLen);
22127
22422
  let bestWord = word;
22128
22423
  let bestDist = maxDist + 1;
22129
22424
  for (const { word: candidate } of candidates) {
@@ -22142,11 +22437,11 @@ var ContentStore = class {
22142
22437
  * Update vocabulary table from content (bounded to MAX_VOCABULARY).
22143
22438
  */
22144
22439
  updateVocabulary(content) {
22145
- const currentCount = this.db.prepare("SELECT COUNT(*) as cnt FROM vocabulary").get().cnt;
22440
+ const currentCount = this.vocabCountStmt.get().cnt;
22146
22441
  if (currentCount >= MAX_VOCABULARY) return;
22147
22442
  const words = content.split(WORD_SPLIT_RE).filter((w) => w.length >= 3 && !STOPWORDS.has(w.toLowerCase()));
22148
22443
  const unique = new Set(words.map((w) => w.toLowerCase()));
22149
- const insert = this.db.prepare("INSERT OR IGNORE INTO vocabulary (word) VALUES (?)");
22444
+ const insert = this.vocabInsertStmt;
22150
22445
  let added = 0;
22151
22446
  for (const word of unique) {
22152
22447
  if (currentCount + added >= MAX_VOCABULARY) break;
@@ -22163,7 +22458,7 @@ var ContentStore = class {
22163
22458
  ).get(...sourceId ? [sourceId] : []).cnt;
22164
22459
  if (totalChunks === 0) return [];
22165
22460
  const filter = sourceId ? " WHERE source_id = ?" : "";
22166
- const stmt = this.db.prepare(`SELECT content FROM chunks${filter}`);
22461
+ const stmt = this.db.prepare(`SELECT content FROM chunks${filter} LIMIT 500`);
22167
22462
  const rows = sourceId ? stmt.all(sourceId) : stmt.all();
22168
22463
  const docFreq = /* @__PURE__ */ new Map();
22169
22464
  for (const row of rows) {
@@ -22188,6 +22483,21 @@ var ContentStore = class {
22188
22483
  scored.sort((a, b) => b.score - a.score);
22189
22484
  return scored.slice(0, 40).map((s) => s.word);
22190
22485
  }
22486
+ /**
22487
+ * List all indexed sources with metadata.
22488
+ */
22489
+ listSources() {
22490
+ const rows = this.db.prepare(
22491
+ "SELECT id, label, chunk_count, code_chunk_count, indexed_at FROM sources ORDER BY indexed_at DESC"
22492
+ ).all();
22493
+ return rows.map((row) => ({
22494
+ id: row.id,
22495
+ label: row.label,
22496
+ chunkCount: row.chunk_count,
22497
+ codeChunks: row.code_chunk_count,
22498
+ indexedAt: row.indexed_at
22499
+ }));
22500
+ }
22191
22501
  /**
22192
22502
  * Get store statistics.
22193
22503
  */
@@ -22251,6 +22561,10 @@ function chunkMarkdown(content) {
22251
22561
  }
22252
22562
  currentLines.push(line);
22253
22563
  }
22564
+ if (inFence) {
22565
+ debug("Warning: unclosed code fence detected during markdown chunking");
22566
+ hasCode = true;
22567
+ }
22254
22568
  flush();
22255
22569
  return chunks;
22256
22570
  }
@@ -22264,7 +22578,7 @@ function chunkPlainText(content, linesPerChunk = 20, overlap = 2) {
22264
22578
  return {
22265
22579
  title: trimmed.split("\n")[0].slice(0, 80),
22266
22580
  content: trimmed,
22267
- hasCode: FENCE_RE.test(trimmed)
22581
+ hasCode: /`{3,}/.test(trimmed)
22268
22582
  };
22269
22583
  }).filter(Boolean);
22270
22584
  }
@@ -22313,8 +22627,8 @@ function cleanupStaleDbs() {
22313
22627
  return cleaned;
22314
22628
  }
22315
22629
 
22316
- // src/server.ts
22317
- var LANGUAGES = [
22630
+ // src/types.ts
22631
+ var ALL_LANGUAGES = [
22318
22632
  "javascript",
22319
22633
  "typescript",
22320
22634
  "python",
@@ -22327,10 +22641,19 @@ var LANGUAGES = [
22327
22641
  "r",
22328
22642
  "elixir"
22329
22643
  ];
22644
+
22645
+ // src/server.ts
22646
+ var LANGUAGE_ENUM = ALL_LANGUAGES;
22330
22647
  var projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
22331
22648
  function isWithinProject(absPath) {
22332
- const normalized = resolve(absPath);
22333
- return normalized === projectDir || normalized.startsWith(`${projectDir}/`);
22649
+ try {
22650
+ const normalized = realpathSync(resolve(absPath));
22651
+ const realProjectDir = realpathSync(projectDir);
22652
+ return normalized === realProjectDir || normalized.startsWith(`${realProjectDir}/`);
22653
+ } catch {
22654
+ const normalized = resolve(absPath);
22655
+ return normalized === projectDir || normalized.startsWith(`${projectDir}/`);
22656
+ }
22334
22657
  }
22335
22658
  function getVersion() {
22336
22659
  try {
@@ -22342,6 +22665,57 @@ function getVersion() {
22342
22665
  return "1.0.0";
22343
22666
  }
22344
22667
  }
22668
+ function compactLabel(normal, level) {
22669
+ if (level === "ultra") {
22670
+ return normal.replace(/\*\*/g, "").replace(/Use search\(queries: \[\.\.\.]\) to retrieve.*$/gm, "\u2192 search() for more").replace(/Searchable terms: .+$/gm, "");
22671
+ }
22672
+ if (level === "compact") {
22673
+ return normal.replace(
22674
+ /Use search\(queries: \[\.\.\.]\) to retrieve full content of any section\./,
22675
+ "\u2192 search() for details"
22676
+ );
22677
+ }
22678
+ return normal;
22679
+ }
22680
+ async function limitConcurrency(tasks, limit) {
22681
+ const results = new Array(tasks.length);
22682
+ let nextIndex = 0;
22683
+ async function runNext() {
22684
+ while (nextIndex < tasks.length) {
22685
+ const index = nextIndex++;
22686
+ try {
22687
+ const value = await tasks[index]();
22688
+ results[index] = { status: "fulfilled", value };
22689
+ } catch (reason) {
22690
+ results[index] = { status: "rejected", reason };
22691
+ }
22692
+ }
22693
+ }
22694
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
22695
+ await Promise.all(workers);
22696
+ return results;
22697
+ }
22698
+ function detectInjectionPatterns(content) {
22699
+ const warnings = [];
22700
+ const patterns = [
22701
+ { re: /ignore\s+(all\s+)?previous\s+instructions/i, label: "instruction override" },
22702
+ { re: /you\s+are\s+now\s+/i, label: "role reassignment" },
22703
+ {
22704
+ re: /(?:^|\n)\s*system\s*:\s*(?:you are|you're|as an? )/im,
22705
+ label: "system prompt injection"
22706
+ },
22707
+ { re: /\[INST\]|\[\/INST\]|<\|im_start\|>|<\|im_end\|>/i, label: "chat template injection" },
22708
+ { re: /\n\n(?:Human|Assistant):/m, label: "chat delimiter injection" },
22709
+ { re: /reveal\s+(your|the)\s+(system|secret|confidential)/i, label: "data exfiltration" },
22710
+ { re: /act\s+as\s+(if\s+you\s+are|a)\s+/i, label: "role manipulation" }
22711
+ ];
22712
+ for (const { re, label } of patterns) {
22713
+ if (re.test(content)) {
22714
+ warnings.push(label);
22715
+ }
22716
+ }
22717
+ return warnings;
22718
+ }
22345
22719
  async function createServer(config3) {
22346
22720
  const version2 = getVersion();
22347
22721
  debug("Version:", version2);
@@ -22350,8 +22724,48 @@ async function createServer(config3) {
22350
22724
  const bunDetected = hasBun(runtimes);
22351
22725
  debug("Runtimes detected:", runtimes.size);
22352
22726
  const executor = new SubprocessExecutor(runtimes, config3);
22353
- const store = new ContentStore();
22727
+ let store;
22728
+ let dbFallback = false;
22729
+ try {
22730
+ store = new ContentStore({ persistDb: config3.persistDb, dbDir: config3.dbDir });
22731
+ } catch (e) {
22732
+ debug("Failed to create DB, falling back to in-memory:", e);
22733
+ store = new ContentStore(":memory:");
22734
+ dbFallback = true;
22735
+ }
22354
22736
  const tracker = new SessionTracker();
22737
+ function applyIntentFilter(output, intent, sourceLabel) {
22738
+ if (Buffer.byteLength(output) <= config3.intentSearchThreshold) return output;
22739
+ const indexed = store.index(output, sourceLabel);
22740
+ tracker.trackIndexed(Buffer.byteLength(output));
22741
+ const searchResults = store.search(intent, { limit: 3 });
22742
+ const terms = store.getDistinctiveTerms(indexed.sourceId);
22743
+ let filtered = `Indexed ${indexed.totalChunks} sections from ${sourceLabel}.
22744
+ `;
22745
+ filtered += `${searchResults.results.length} sections matched "${intent}":
22746
+
22747
+ `;
22748
+ for (const hit of searchResults.results) {
22749
+ filtered += ` - **${hit.title}**: ${hit.snippet.slice(0, 200)}
22750
+ `;
22751
+ }
22752
+ if (terms.length > 0 && config3.compressionLevel !== "ultra") {
22753
+ filtered += `
22754
+ Searchable terms: ${terms.join(", ")}
22755
+ `;
22756
+ }
22757
+ filtered += "\nUse search(queries: [...]) to retrieve full content of any section.";
22758
+ return compactLabel(filtered, config3.compressionLevel);
22759
+ }
22760
+ const shutdown = () => {
22761
+ try {
22762
+ store.close();
22763
+ } catch {
22764
+ }
22765
+ };
22766
+ process.on("SIGINT", shutdown);
22767
+ process.on("SIGTERM", shutdown);
22768
+ process.on("beforeExit", shutdown);
22355
22769
  const searchCalls = [];
22356
22770
  const server2 = new McpServer({
22357
22771
  name: "context-compress",
@@ -22359,11 +22773,11 @@ async function createServer(config3) {
22359
22773
  });
22360
22774
  server2.tool(
22361
22775
  "execute",
22362
- `Execute code in a sandboxed subprocess. Only stdout enters context \u2014 raw data stays in the subprocess. Use instead of bash/cat when output would exceed 20 lines. ${bunDetected ? "(Bun detected \u2014 JS/TS runs 3-5x faster) " : ""}Available: ${LANGUAGES.join(", ")}.
22776
+ `Execute code in a sandboxed subprocess. Only stdout enters context \u2014 raw data stays in the subprocess. Use instead of bash/cat when output would exceed ~5KB. ${bunDetected ? "(Bun detected \u2014 JS/TS runs 3-5x faster) " : ""}Available: ${ALL_LANGUAGES.join(", ")}.
22363
22777
 
22364
22778
  PREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, pytest), git queries (git log, git diff), data processing, and ANY CLI command that may produce large output. Bash should only be used for file mutations, git writes, and navigation.`,
22365
22779
  {
22366
- language: external_exports.enum(LANGUAGES).describe("Runtime language"),
22780
+ language: external_exports.enum(LANGUAGE_ENUM).describe("Runtime language"),
22367
22781
  code: external_exports.string().describe(
22368
22782
  "Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), or IO.puts (Elixir) to output a summary to context."
22369
22783
  ),
@@ -22384,27 +22798,8 @@ PREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, py
22384
22798
  STDERR:
22385
22799
  ${result.stderr}`;
22386
22800
  }
22387
- if (intent && Buffer.byteLength(output) > config3.intentSearchThreshold) {
22388
- const indexed = store.index(output, `execute:${language}`);
22389
- tracker.trackIndexed(Buffer.byteLength(output));
22390
- const searchResults = store.search(intent, { limit: 3 });
22391
- const terms = store.getDistinctiveTerms(indexed.sourceId);
22392
- let filtered = `Indexed ${indexed.totalChunks} sections from execute output.
22393
- `;
22394
- filtered += `${searchResults.results.length} sections matched "${intent}":
22395
-
22396
- `;
22397
- for (const hit of searchResults.results) {
22398
- filtered += ` - **${hit.title}**: ${hit.snippet.slice(0, 200)}
22399
- `;
22400
- }
22401
- if (terms.length > 0) {
22402
- filtered += `
22403
- Searchable terms: ${terms.join(", ")}
22404
- `;
22405
- }
22406
- filtered += "\nUse search(queries: [...]) to retrieve full content of any section.";
22407
- output = filtered;
22801
+ if (intent) {
22802
+ output = applyIntentFilter(output, intent, `execute:${language}`);
22408
22803
  }
22409
22804
  const responseBytes = Buffer.byteLength(output);
22410
22805
  tracker.trackCall("execute", responseBytes);
@@ -22416,7 +22811,7 @@ Searchable terms: ${terms.join(", ")}
22416
22811
  "Read a file and process it without loading contents into context. The file is read into a FILE_CONTENT variable inside the sandbox. Only your printed summary enters context.\n\nPREFER THIS OVER Read/cat for: log files, data files (CSV, JSON, XML), large source files for analysis, and any file where you need to extract specific information rather than read the entire content.",
22417
22812
  {
22418
22813
  path: external_exports.string().describe("Absolute file path or relative to project root"),
22419
- language: external_exports.enum(LANGUAGES).describe("Runtime language"),
22814
+ language: external_exports.enum(LANGUAGE_ENUM).describe("Runtime language"),
22420
22815
  code: external_exports.string().describe(
22421
22816
  "Code to process FILE_CONTENT. Print summary via console.log/print/echo/IO.puts."
22422
22817
  ),
@@ -22448,27 +22843,8 @@ Searchable terms: ${terms.join(", ")}
22448
22843
  STDERR:
22449
22844
  ${result.stderr}`;
22450
22845
  }
22451
- if (intent && Buffer.byteLength(output) > config3.intentSearchThreshold) {
22452
- const indexed = store.index(output, `file:${filePath}`);
22453
- tracker.trackIndexed(Buffer.byteLength(output));
22454
- const searchResults = store.search(intent, { limit: 3 });
22455
- const terms = store.getDistinctiveTerms(indexed.sourceId);
22456
- let filtered = `Indexed ${indexed.totalChunks} sections from "${filePath}" into knowledge base.
22457
- `;
22458
- filtered += `${searchResults.results.length} sections matched "${intent}":
22459
-
22460
- `;
22461
- for (const hit of searchResults.results) {
22462
- filtered += ` - **${hit.title}**: ${hit.snippet.slice(0, 200)}
22463
- `;
22464
- }
22465
- if (terms.length > 0) {
22466
- filtered += `
22467
- Searchable terms: ${terms.join(", ")}
22468
- `;
22469
- }
22470
- filtered += "\nUse search(queries: [...]) to retrieve full content of any section.";
22471
- output = filtered;
22846
+ if (intent) {
22847
+ output = applyIntentFilter(output, intent, `file:${filePath}`);
22472
22848
  }
22473
22849
  const responseBytes = Buffer.byteLength(output);
22474
22850
  tracker.trackCall("execute_file", responseBytes);
@@ -22498,9 +22874,38 @@ Searchable terms: ${terms.join(", ")}
22498
22874
  ]
22499
22875
  };
22500
22876
  }
22501
- text = readFileSync2(absPath, "utf-8");
22502
- label = source ?? filePath;
22877
+ try {
22878
+ const fileStat = statSync(absPath);
22879
+ if (fileStat.size > 50 * 1024 * 1024) {
22880
+ return {
22881
+ content: [
22882
+ {
22883
+ type: "text",
22884
+ text: `Error: file "${filePath}" is too large (${(fileStat.size / 1024 / 1024).toFixed(1)}MB). Max 50MB.`
22885
+ }
22886
+ ]
22887
+ };
22888
+ }
22889
+ text = readFileSync2(absPath, "utf-8");
22890
+ label = source ?? filePath;
22891
+ } catch (e) {
22892
+ const msg = e instanceof Error ? e.message : String(e);
22893
+ return {
22894
+ content: [{ type: "text", text: `Error reading "${filePath}": ${msg}` }]
22895
+ };
22896
+ }
22503
22897
  } else if (content) {
22898
+ const contentBytes = Buffer.byteLength(content);
22899
+ if (contentBytes > 50 * 1024 * 1024) {
22900
+ return {
22901
+ content: [
22902
+ {
22903
+ type: "text",
22904
+ text: `Error: content too large (${(contentBytes / 1024 / 1024).toFixed(1)}MB). Max 50MB.`
22905
+ }
22906
+ ]
22907
+ };
22908
+ }
22504
22909
  text = content;
22505
22910
  } else {
22506
22911
  return {
@@ -22587,8 +22992,7 @@ ${hit.snippet}
22587
22992
  content: [{ type: "text", text: "Error: only http/https URLs are allowed" }]
22588
22993
  };
22589
22994
  }
22590
- const hostname2 = parsed.hostname;
22591
- if (hostname2 === "localhost" || hostname2 === "127.0.0.1" || hostname2 === "::1" || hostname2 === "0.0.0.0" || hostname2.startsWith("10.") || hostname2.startsWith("172.16.") || hostname2.startsWith("192.168.") || hostname2.startsWith("169.254.")) {
22995
+ if (isPrivateHost(parsed.hostname)) {
22592
22996
  return {
22593
22997
  content: [
22594
22998
  { type: "text", text: "Error: internal/private URLs are not allowed" }
@@ -22600,8 +23004,22 @@ ${hit.snippet}
22600
23004
  content: [{ type: "text", text: `Error: invalid URL "${url}"` }]
22601
23005
  };
22602
23006
  }
23007
+ let resolvedIp = null;
23008
+ try {
23009
+ const validated = await resolveAndValidate(url);
23010
+ resolvedIp = validated.resolvedIp;
23011
+ } catch (err) {
23012
+ return {
23013
+ content: [
23014
+ {
23015
+ type: "text",
23016
+ text: `Error: ${err instanceof Error ? err.message : "DNS validation failed"}`
23017
+ }
23018
+ ]
23019
+ };
23020
+ }
22603
23021
  const label = source ?? url;
22604
- const fetchCode = buildFetchCode(url);
23022
+ const fetchCode = buildFetchCode(url, resolvedIp);
22605
23023
  const result = await executor.execute({
22606
23024
  language: "javascript",
22607
23025
  code: fetchCode,
@@ -22614,6 +23032,7 @@ ${hit.snippet}
22614
23032
  }
22615
23033
  const markdown = result.stdout;
22616
23034
  tracker.trackSandboxed(result.networkBytes ?? 0);
23035
+ const injectionWarnings = detectInjectionPatterns(markdown);
22617
23036
  const indexed = store.index(markdown, label);
22618
23037
  tracker.trackIndexed(Buffer.byteLength(markdown));
22619
23038
  const preview = markdown.slice(0, 3072);
@@ -22630,6 +23049,11 @@ ${preview}`;
22630
23049
  Searchable terms: ${terms.join(", ")}`;
22631
23050
  }
22632
23051
  output += "\n\nUse search(queries: [...]) to retrieve full content of any section.";
23052
+ if (injectionWarnings.length > 0) {
23053
+ output += `
23054
+
23055
+ \u26A0 Content safety notice: detected patterns (${injectionWarnings.join(", ")}). Review indexed content before relying on it.`;
23056
+ }
22633
23057
  tracker.trackCall("fetch_and_index", Buffer.byteLength(output));
22634
23058
  return { content: [{ type: "text", text: output }] };
22635
23059
  }
@@ -22650,15 +23074,16 @@ Searchable terms: ${terms.join(", ")}`;
22650
23074
  timeout: external_exports.number().default(6e4).describe("Max execution time in ms (default: 60s)")
22651
23075
  },
22652
23076
  async ({ commands, queries, timeout }) => {
22653
- const commandResults = await Promise.allSettled(
22654
- commands.map(async (cmd) => {
23077
+ const commandResults = await limitConcurrency(
23078
+ commands.map((cmd) => async () => {
22655
23079
  const result = await executor.execute({
22656
23080
  language: "shell",
22657
23081
  code: cmd.command,
22658
23082
  timeout
22659
23083
  });
22660
23084
  return { label: cmd.label, result };
22661
- })
23085
+ }),
23086
+ 4
22662
23087
  );
22663
23088
  let combined = "";
22664
23089
  const inventory = [];
@@ -22729,7 +23154,7 @@ Searchable terms: ${terms.join(", ")}`;
22729
23154
  );
22730
23155
  server2.tool(
22731
23156
  "stats",
22732
- "Returns context consumption statistics for the current session. Shows total bytes returned to context, breakdown by tool, call counts, estimated token usage, and context savings ratio.",
23157
+ "Returns context consumption statistics for the current session. Shows total bytes returned to context, breakdown by tool, call counts, estimated token usage, context savings ratio, and visual charts.",
22733
23158
  {},
22734
23159
  async () => {
22735
23160
  const report = tracker.formatReport();
@@ -22737,6 +23162,82 @@ Searchable terms: ${terms.join(", ")}`;
22737
23162
  return { content: [{ type: "text", text: report }] };
22738
23163
  }
22739
23164
  );
23165
+ server2.tool(
23166
+ "discover",
23167
+ "Shows what's in the knowledge base and suggests optimization opportunities. Lists all indexed sources, chunk counts, searchable terms, and recommends next actions. Use this to understand what data is available for search.",
23168
+ {},
23169
+ async () => {
23170
+ const storeStats = store.getStats();
23171
+ const snap = tracker.getSnapshot();
23172
+ const lines = [];
23173
+ lines.push("## Knowledge Base Discovery\n");
23174
+ if (storeStats.totalSources === 0) {
23175
+ lines.push("No content indexed yet. Use these tools to build the knowledge base:\n");
23176
+ lines.push("- `batch_execute` \u2014 run commands and auto-index output");
23177
+ lines.push("- `execute` with `intent` \u2014 auto-indexes large output");
23178
+ lines.push("- `index` \u2014 index documentation or files");
23179
+ lines.push("- `fetch_and_index` \u2014 fetch and index web pages");
23180
+ } else {
23181
+ lines.push("| Metric | Value |");
23182
+ lines.push("|--------|-------|");
23183
+ lines.push(`| Indexed sources | ${storeStats.totalSources} |`);
23184
+ lines.push(`| Total chunks | ${storeStats.totalChunks} |`);
23185
+ lines.push(`| Vocabulary size | ${storeStats.vocabularySize} |`);
23186
+ lines.push(
23187
+ `| Trigram index | ${storeStats.hasTrigramTable ? "active" : "lazy (not yet needed)"} |`
23188
+ );
23189
+ const sources = store.listSources();
23190
+ if (sources.length > 0) {
23191
+ lines.push("\n### Indexed Sources\n");
23192
+ for (const src of sources) {
23193
+ lines.push(
23194
+ `- **${src.label}** \u2014 ${src.chunkCount} chunks${src.codeChunks > 0 ? ` (${src.codeChunks} with code)` : ""}`
23195
+ );
23196
+ }
23197
+ }
23198
+ const terms = store.getDistinctiveTerms();
23199
+ if (terms.length > 0) {
23200
+ lines.push("\n### Top Searchable Terms\n");
23201
+ lines.push(terms.slice(0, 20).join(", "));
23202
+ }
23203
+ }
23204
+ lines.push("\n### Optimization Suggestions\n");
23205
+ const totalCalls = Object.values(snap.calls).reduce((a, b) => a + b, 0);
23206
+ if (totalCalls === 0) {
23207
+ lines.push("- Start by using `batch_execute` to run multiple commands at once");
23208
+ } else {
23209
+ const searchCalls2 = snap.calls.search ?? 0;
23210
+ const executeCalls = snap.calls.execute ?? 0;
23211
+ const batchCalls = snap.calls.batch_execute ?? 0;
23212
+ if (executeCalls > 3 && batchCalls === 0) {
23213
+ lines.push(
23214
+ "- **Use batch_execute** \u2014 you've made multiple execute calls that could be batched into one"
23215
+ );
23216
+ }
23217
+ if (searchCalls2 > 5) {
23218
+ lines.push("- **Batch your searches** \u2014 pass multiple queries in a single search() call");
23219
+ }
23220
+ if (storeStats.totalChunks > 50) {
23221
+ lines.push(
23222
+ "- **Use source filtering** \u2014 scope searches with `source` parameter for faster, targeted results"
23223
+ );
23224
+ }
23225
+ if (storeStats.totalSources === 0 && totalCalls > 2) {
23226
+ lines.push(
23227
+ "- **Index more content** \u2014 use `intent` parameter in execute calls to auto-index large output"
23228
+ );
23229
+ }
23230
+ }
23231
+ if (dbFallback) {
23232
+ lines.push(
23233
+ "\n\u26A0 **Warning:** Persistent DB creation failed \u2014 using in-memory storage. Indexed data will not survive restarts."
23234
+ );
23235
+ }
23236
+ const output = lines.join("\n");
23237
+ tracker.trackCall("discover", Buffer.byteLength(output));
23238
+ return { content: [{ type: "text", text: output }] };
23239
+ }
23240
+ );
22740
23241
  return {
22741
23242
  async start() {
22742
23243
  const transport = new StdioServerTransport();
@@ -22745,11 +23246,21 @@ Searchable terms: ${terms.join(", ")}`;
22745
23246
  }
22746
23247
  };
22747
23248
  }
22748
- function buildFetchCode(url) {
22749
- const escaped = JSON.stringify(url);
22750
- return `
22751
- const url = ${escaped};
22752
- const resp = await fetch(url);
23249
+ function buildFetchCode(url, resolvedIp) {
23250
+ let fetchSetup;
23251
+ if (resolvedIp) {
23252
+ const pinnedUrl = new URL(url);
23253
+ const originalHost = pinnedUrl.host;
23254
+ pinnedUrl.hostname = resolvedIp;
23255
+ fetchSetup = `
23256
+ const url = ${JSON.stringify(pinnedUrl.toString())};
23257
+ const resp = await fetch(url, { headers: { 'Host': ${JSON.stringify(originalHost)} }, redirect: 'error' });`;
23258
+ } else {
23259
+ fetchSetup = `
23260
+ const url = ${JSON.stringify(url)};
23261
+ const resp = await fetch(url, { redirect: 'error' });`;
23262
+ }
23263
+ return `${fetchSetup}
22753
23264
  if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
22754
23265
  const html = await resp.text();
22755
23266
 
@@ -22785,12 +23296,15 @@ md = md.replace(/<br\\s*\\/?>/gi, "\\n");
22785
23296
  md = md.replace(/<[^>]+>/g, "");
22786
23297
 
22787
23298
  // Decode entities
22788
- md = md.replace(/&amp;/g, "&")
22789
- .replace(/&lt;/g, "<")
23299
+ md = md.replace(/&lt;/g, "<")
22790
23300
  .replace(/&gt;/g, ">")
22791
23301
  .replace(/&quot;/g, '"')
22792
23302
  .replace(/&#39;/g, "'")
22793
- .replace(/&nbsp;/g, " ");
23303
+ .replace(/&apos;/g, "'")
23304
+ .replace(/&nbsp;/g, " ")
23305
+ .replace(/&#(\\d+);/g, (_, n) => { const c = parseInt(n, 10); return c > 0 && c <= 0x10FFFF ? String.fromCodePoint(c) : ''; })
23306
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => { const c = parseInt(h, 16); return c > 0 && c <= 0x10FFFF ? String.fromCodePoint(c) : ''; })
23307
+ .replace(/&amp;/g, "&");
22794
23308
 
22795
23309
  // Clean whitespace
22796
23310
  md = md.replace(/\\n{3,}/g, "\\n\\n").trim();