hillclimb 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +300 -10
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2419,6 +2419,48 @@ async function readFileContent(file) {
2419
2419
  }
2420
2420
  }
2421
2421
 
2422
+ // src/middleware/large-file.ts
2423
+ import { constants as bufferConstants } from "buffer";
2424
+ var MAX_STRINGIFIABLE_BYTES = bufferConstants.MAX_STRING_LENGTH;
2425
+ var largeFileThreshold = MAX_STRINGIFIABLE_BYTES;
2426
+ function getLargeFileThreshold() {
2427
+ return largeFileThreshold;
2428
+ }
2429
+ var NEWLINE = 10;
2430
+ var OVERSIZED_LINE_PLACEHOLDER = '{"_hillclimb_omitted":"line exceeded max string length; dropped to allow redaction"}';
2431
+ function redactBufferByLine(buffer, redactLine, options = {}) {
2432
+ const maxLineBytes = options.maxLineBytes ?? MAX_STRINGIFIABLE_BYTES;
2433
+ const oversizedPlaceholder = options.oversizedPlaceholder ?? OVERSIZED_LINE_PLACEHOLDER;
2434
+ const pieces = [];
2435
+ const newlineBuf = Buffer.from([NEWLINE]);
2436
+ let totalCount = 0;
2437
+ let oversizedLines = 0;
2438
+ let start = 0;
2439
+ let first = true;
2440
+ for (; ; ) {
2441
+ const nl = buffer.indexOf(NEWLINE, start);
2442
+ const end = nl === -1 ? buffer.length : nl;
2443
+ const slice = buffer.subarray(start, end);
2444
+ if (!first) pieces.push(newlineBuf);
2445
+ first = false;
2446
+ if (slice.length > maxLineBytes) {
2447
+ oversizedLines++;
2448
+ pieces.push(Buffer.from(oversizedPlaceholder, "utf-8"));
2449
+ } else {
2450
+ const { value, count } = redactLine(slice.toString("utf-8"));
2451
+ totalCount += count;
2452
+ pieces.push(Buffer.from(value, "utf-8"));
2453
+ }
2454
+ if (nl === -1) break;
2455
+ start = nl + 1;
2456
+ }
2457
+ return {
2458
+ content: Buffer.concat(pieces),
2459
+ count: totalCount,
2460
+ oversizedLines
2461
+ };
2462
+ }
2463
+
2422
2464
  // src/middleware/patterns.json
2423
2465
  var patterns_default = {
2424
2466
  patterns: [
@@ -10658,6 +10700,19 @@ function processFiles(files) {
10658
10700
  }
10659
10701
 
10660
10702
  // src/middleware/pattern-redact.ts
10703
+ function redactPatternLine(line, patterns, memo, isJsonl) {
10704
+ if (isJsonl) {
10705
+ if (!line.trim()) return { value: line, count: 0 };
10706
+ try {
10707
+ const parsed = JSON.parse(line);
10708
+ const walked = walkAndRedactAll(parsed, patterns, memo);
10709
+ return { value: JSON.stringify(walked.value), count: walked.count };
10710
+ } catch {
10711
+ return redactString(line, patterns);
10712
+ }
10713
+ }
10714
+ return redactString(line, patterns);
10715
+ }
10661
10716
  var WORKER_COUNT = Math.min(
10662
10717
  os3.availableParallelism?.() ?? os3.cpus().length,
10663
10718
  4
@@ -10686,9 +10741,14 @@ var PatternRedactMiddleware = class {
10686
10741
  const tasks = [];
10687
10742
  const fileMap = /* @__PURE__ */ new Map();
10688
10743
  const passThrough = [];
10744
+ const largeResults = /* @__PURE__ */ new Map();
10689
10745
  for (let i = 0; i < group.files.length; i++) {
10690
10746
  const file = group.files[i];
10691
10747
  this.stats.filesScanned++;
10748
+ if (file.content && !file.isBinary && file.content.length > getLargeFileThreshold()) {
10749
+ largeResults.set(i, this.redactLargeFile(file));
10750
+ continue;
10751
+ }
10692
10752
  const read = await readFileContent(file);
10693
10753
  if (read.kind === "binary") {
10694
10754
  this.stats.binaryFiles++;
@@ -10718,6 +10778,11 @@ var PatternRedactMiddleware = class {
10718
10778
  }
10719
10779
  const newFiles = [];
10720
10780
  for (let i = 0; i < group.files.length; i++) {
10781
+ const large = largeResults.get(i);
10782
+ if (large) {
10783
+ newFiles.push(large);
10784
+ continue;
10785
+ }
10721
10786
  const pt = passThrough.find((p7) => p7.index === i);
10722
10787
  if (pt) {
10723
10788
  newFiles.push(pt.file);
@@ -10739,6 +10804,33 @@ var PatternRedactMiddleware = class {
10739
10804
  }
10740
10805
  return { ...group, files: newFiles };
10741
10806
  }
10807
+ // Stream-redact an oversized file straight from its buffer, one line at a time,
10808
+ // so we never build a string larger than a single line. Single-threaded (no
10809
+ // worker pool) — it trades parallelism for bounded memory on the one giant file.
10810
+ redactLargeFile(file) {
10811
+ const buffer = file.content;
10812
+ const isJsonl = file.absolutePath.endsWith(".jsonl");
10813
+ const patterns = compilePatterns();
10814
+ const memo = /* @__PURE__ */ new Map();
10815
+ const { content, count, oversizedLines } = redactBufferByLine(
10816
+ buffer,
10817
+ (line) => redactPatternLine(line, patterns, memo, isJsonl)
10818
+ );
10819
+ if (oversizedLines > 0) {
10820
+ appendLog(
10821
+ "warn",
10822
+ `pattern-redact: dropped ${oversizedLines} oversized line(s) in ${file.absolutePath} (exceeded max string length)`
10823
+ );
10824
+ }
10825
+ if (count > 0) {
10826
+ this.stats.filesRedacted++;
10827
+ this.stats.totalRedactions += count;
10828
+ }
10829
+ if (count > 0 || oversizedLines > 0) {
10830
+ return { ...file, content };
10831
+ }
10832
+ return file;
10833
+ }
10742
10834
  async processWithWorkers(tasks) {
10743
10835
  const workerPath = resolveWorkerPath();
10744
10836
  const sorted = [...tasks].sort(
@@ -10814,6 +10906,10 @@ var RedactMiddleware = class {
10814
10906
  for (const file of group.files) {
10815
10907
  this.stats.filesScanned++;
10816
10908
  regex.lastIndex = 0;
10909
+ if (file.content && !file.isBinary && file.content.length > getLargeFileThreshold()) {
10910
+ newFiles.push(this.redactLargeFile(file, regex));
10911
+ continue;
10912
+ }
10817
10913
  const read = await readFileContent(file);
10818
10914
  if (read.kind === "binary") {
10819
10915
  this.stats.binaryFiles++;
@@ -10849,6 +10945,47 @@ var RedactMiddleware = class {
10849
10945
  }
10850
10946
  return { ...group, files: newFiles };
10851
10947
  }
10948
+ // Stream-redact an oversized file straight from its buffer. Each line is
10949
+ // redacted with the same JSON-aware (or raw) logic as redactJsonl, so the
10950
+ // output matches the in-memory path exactly for files whose lines all fit in a
10951
+ // string — which holds for real transcripts (largest line is a few MiB).
10952
+ redactLargeFile(file, regex) {
10953
+ const buffer = file.content;
10954
+ const isJsonl = file.absolutePath.endsWith(".jsonl");
10955
+ const { content, count, oversizedLines } = redactBufferByLine(
10956
+ buffer,
10957
+ (line) => this.redactLine(line, regex, isJsonl)
10958
+ );
10959
+ if (oversizedLines > 0) {
10960
+ appendLog(
10961
+ "warn",
10962
+ `redact: dropped ${oversizedLines} oversized line(s) in ${file.absolutePath} (exceeded max string length)`
10963
+ );
10964
+ }
10965
+ if (count > 0) {
10966
+ this.stats.filesRedacted++;
10967
+ this.stats.totalRedactions += count;
10968
+ }
10969
+ if (count > 0 || oversizedLines > 0) {
10970
+ return { ...file, content };
10971
+ }
10972
+ return file;
10973
+ }
10974
+ redactLine(line, regex, isJsonl) {
10975
+ if (isJsonl) {
10976
+ if (!line.trim()) return { value: line, count: 0 };
10977
+ try {
10978
+ const parsed = JSON.parse(line);
10979
+ const walked = walkAndRedact(parsed, regex);
10980
+ return { value: JSON.stringify(walked.value), count: walked.count };
10981
+ } catch {
10982
+ const { result: result2, count: count2 } = this.redactString(line, regex);
10983
+ return { value: result2, count: count2 };
10984
+ }
10985
+ }
10986
+ const { result, count } = this.redactString(line, regex);
10987
+ return { value: result, count };
10988
+ }
10852
10989
  redactJsonl(content, regex) {
10853
10990
  let totalCount = 0;
10854
10991
  const lines = content.split("\n");
@@ -13351,10 +13488,10 @@ var NormalizeMiddleware = class {
13351
13488
  file.sourceName
13352
13489
  ))
13353
13490
  continue;
13354
- const content = file.content ? file.content.toString("utf-8") : null;
13355
- if (!content) continue;
13356
13491
  const sessionId = file.metadata?.sessionId ?? (file.sourceName === "codex" ? void 0 : path13.basename(file.absolutePath, ".jsonl"));
13357
13492
  try {
13493
+ const content = file.content ? file.content.toString("utf-8") : null;
13494
+ if (!content) continue;
13358
13495
  const trajectory = normalizeContent(
13359
13496
  file.sourceName,
13360
13497
  content,
@@ -13383,7 +13520,7 @@ var NormalizeMiddleware = class {
13383
13520
  } catch (err) {
13384
13521
  appendLog(
13385
13522
  "warn",
13386
- `normalize: failed for source=${file.sourceName} session=${sessionId ?? "<unknown>"} file=${file.absolutePath}: ${err instanceof Error ? err.message : String(err)}`
13523
+ `normalize: skipped ATIF for source=${file.sourceName} session=${sessionId ?? "<unknown>"} file=${file.absolutePath}: ${err instanceof Error ? err.message : String(err)}`
13387
13524
  );
13388
13525
  }
13389
13526
  }
@@ -14085,7 +14222,7 @@ import { execFileSync as execFileSync3 } from "child_process";
14085
14222
  import path18 from "path";
14086
14223
 
14087
14224
  // src/git-traces/git-ops.ts
14088
- import { execFileSync as execFileSync2 } from "child_process";
14225
+ import { execFileSync as execFileSync2, spawnSync } from "child_process";
14089
14226
  import fs12 from "fs";
14090
14227
  import os7 from "os";
14091
14228
  import path16 from "path";
@@ -14097,11 +14234,155 @@ var EXEC_OPTS = {
14097
14234
  };
14098
14235
  var MAX_ERROR_OUTPUT_CHARS = 2e3;
14099
14236
  var MAX_SNAPSHOT_FILE_BYTES = 10 * 1024 * 1024;
14100
- var EXCLUDED_SNAPSHOT_EXTENSIONS = /* @__PURE__ */ new Set([".pdf"]);
14237
+ var MAX_BINARY_SNAPSHOT_FILE_BYTES = 1024 * 1024;
14238
+ var BINARY_SNIFF_BYTES = 8e3;
14239
+ var EXCLUDED_SNAPSHOT_EXTENSIONS = /* @__PURE__ */ new Set([
14240
+ // Documents
14241
+ ".pdf",
14242
+ // Raster images
14243
+ ".png",
14244
+ ".jpg",
14245
+ ".jpeg",
14246
+ ".gif",
14247
+ ".bmp",
14248
+ ".tiff",
14249
+ ".tif",
14250
+ ".webp",
14251
+ ".ico",
14252
+ ".heic",
14253
+ ".heif",
14254
+ ".avif",
14255
+ // Video
14256
+ ".mp4",
14257
+ ".mov",
14258
+ ".avi",
14259
+ ".mkv",
14260
+ ".webm",
14261
+ ".m4v",
14262
+ ".mpg",
14263
+ ".mpeg",
14264
+ ".wmv",
14265
+ ".flv",
14266
+ // Audio
14267
+ ".mp3",
14268
+ ".wav",
14269
+ ".flac",
14270
+ ".ogg",
14271
+ ".m4a",
14272
+ ".aac",
14273
+ ".aiff",
14274
+ // Archives / compressed
14275
+ ".zip",
14276
+ ".tar",
14277
+ ".gz",
14278
+ ".tgz",
14279
+ ".bz2",
14280
+ ".tbz2",
14281
+ ".xz",
14282
+ ".7z",
14283
+ ".rar",
14284
+ ".zst",
14285
+ ".lz4",
14286
+ // Fonts
14287
+ ".woff",
14288
+ ".woff2",
14289
+ ".ttf",
14290
+ ".otf",
14291
+ ".eot",
14292
+ // Compiled / native artifacts
14293
+ ".so",
14294
+ ".dylib",
14295
+ ".dll",
14296
+ ".a",
14297
+ ".o",
14298
+ ".lib",
14299
+ ".class",
14300
+ ".jar",
14301
+ ".wasm",
14302
+ ".exe",
14303
+ ".pyc",
14304
+ ".pyd",
14305
+ ".node",
14306
+ // Databases
14307
+ ".sqlite",
14308
+ ".sqlite3",
14309
+ ".db",
14310
+ ".mdb",
14311
+ // Office / design binaries
14312
+ ".doc",
14313
+ ".docx",
14314
+ ".xls",
14315
+ ".xlsx",
14316
+ ".ppt",
14317
+ ".pptx",
14318
+ ".odt",
14319
+ ".ods",
14320
+ ".odp",
14321
+ ".psd",
14322
+ ".ai",
14323
+ ".sketch",
14324
+ ".fig",
14325
+ ".blend"
14326
+ ]);
14327
+ var EXCLUDED_SNAPSHOT_BASENAMES = /* @__PURE__ */ new Set([
14328
+ ".DS_Store",
14329
+ "Thumbs.db",
14330
+ "desktop.ini"
14331
+ ]);
14101
14332
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
14102
- function hasExcludedSnapshotExtension(filePath) {
14333
+ function isExcludedSnapshotPath(filePath) {
14334
+ if (EXCLUDED_SNAPSHOT_BASENAMES.has(path16.basename(filePath))) return true;
14103
14335
  return EXCLUDED_SNAPSHOT_EXTENSIONS.has(path16.extname(filePath).toLowerCase());
14104
14336
  }
14337
+ function isBinaryBuffer(buffer) {
14338
+ return buffer.includes(0);
14339
+ }
14340
+ function readTreeBlobHead(repoRoot, sha) {
14341
+ try {
14342
+ const { stdout } = spawnSync("git", ["cat-file", "blob", sha], {
14343
+ cwd: repoRoot,
14344
+ timeout: GIT_COMMAND_TIMEOUT_MS,
14345
+ maxBuffer: BINARY_SNIFF_BYTES
14346
+ });
14347
+ return stdout?.length ? stdout.subarray(0, BINARY_SNIFF_BYTES) : null;
14348
+ } catch {
14349
+ return null;
14350
+ }
14351
+ }
14352
+ function readWorkingFileHead(absPath) {
14353
+ let fd = null;
14354
+ try {
14355
+ fd = fs12.openSync(absPath, "r");
14356
+ const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
14357
+ const bytesRead = fs12.readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
14358
+ return buffer.subarray(0, bytesRead);
14359
+ } catch {
14360
+ return null;
14361
+ } finally {
14362
+ if (fd !== null) {
14363
+ try {
14364
+ fs12.closeSync(fd);
14365
+ } catch {
14366
+ }
14367
+ }
14368
+ }
14369
+ }
14370
+ function classifyOmission(filePath, sizeBytes, readHead) {
14371
+ if (isExcludedSnapshotPath(filePath)) return "excluded-extension";
14372
+ if (sizeBytes > MAX_SNAPSHOT_FILE_BYTES) return "file-over-limit";
14373
+ if (sizeBytes > MAX_BINARY_SNAPSHOT_FILE_BYTES) {
14374
+ const head = readHead();
14375
+ if (!head) {
14376
+ appendLog(
14377
+ "warn",
14378
+ `git-traces: binary sniff failed for ${filePath} (${sizeBytes} bytes); keeping it in the snapshot`
14379
+ );
14380
+ return null;
14381
+ }
14382
+ if (isBinaryBuffer(head)) return "binary-over-limit";
14383
+ }
14384
+ return null;
14385
+ }
14105
14386
  function quoteGitArg(arg) {
14106
14387
  if (/^[A-Za-z0-9_./:=@%+,-]+$/.test(arg)) return arg;
14107
14388
  return JSON.stringify(arg);
@@ -14205,7 +14486,7 @@ function recordOmittedSnapshotFile(omittedFiles, file, options = {}) {
14205
14486
  omittedFiles?.push(file);
14206
14487
  if (options.log === false) return;
14207
14488
  const action = file.tracked ? "omitting tracked" : "skipping untracked";
14208
- const reason = file.reason === "file-over-limit" ? `${file.sizeBytes} bytes > ${MAX_SNAPSHOT_FILE_BYTES} limit` : "excluded extension";
14489
+ const reason = file.reason === "file-over-limit" ? `${file.sizeBytes} bytes > ${MAX_SNAPSHOT_FILE_BYTES} limit` : file.reason === "binary-over-limit" ? `binary ${file.sizeBytes} bytes > ${MAX_BINARY_SNAPSHOT_FILE_BYTES} binary limit` : "excluded extension";
14209
14490
  appendLog("warn", `git-traces: ${action} ${file.path} (${reason})`);
14210
14491
  }
14211
14492
  function parseLsTreeLongZ(output) {
@@ -14228,7 +14509,11 @@ function listOmittedTreeFiles(repoRoot, treeSha, options = {}) {
14228
14509
  const output = gitBuffer(repoRoot, ["ls-tree", "-r", "-l", "-z", treeSha]);
14229
14510
  const omitted = [];
14230
14511
  for (const entry of parseLsTreeLongZ(output)) {
14231
- const reason = hasExcludedSnapshotExtension(entry.path) ? "excluded-extension" : entry.sizeBytes > MAX_SNAPSHOT_FILE_BYTES ? "file-over-limit" : null;
14512
+ const reason = classifyOmission(
14513
+ entry.path,
14514
+ entry.sizeBytes,
14515
+ () => readTreeBlobHead(repoRoot, entry.sha)
14516
+ );
14232
14517
  if (!reason) continue;
14233
14518
  const file = {
14234
14519
  path: entry.path,
@@ -14286,8 +14571,13 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
14286
14571
  for (const relPath of list.split("\0")) {
14287
14572
  if (!relPath) continue;
14288
14573
  try {
14289
- const stat = fs12.lstatSync(path16.join(repoRoot, relPath));
14290
- const reason = hasExcludedSnapshotExtension(relPath) ? "excluded-extension" : stat.size > MAX_SNAPSHOT_FILE_BYTES ? "file-over-limit" : null;
14574
+ const absPath = path16.join(repoRoot, relPath);
14575
+ const stat = fs12.lstatSync(absPath);
14576
+ const reason = classifyOmission(
14577
+ relPath,
14578
+ stat.size,
14579
+ () => readWorkingFileHead(absPath)
14580
+ );
14291
14581
  if (reason) {
14292
14582
  recordOmittedSnapshotFile(omittedFiles, {
14293
14583
  path: relPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",