opencode-harness-kit 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // src/index.ts
2
2
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
3
- import { join as join8 } from "node:path";
3
+ import { join as join9 } from "node:path";
4
4
 
5
5
  // src/guard.ts
6
6
  import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
7
- import { join as join3, resolve as resolve2 } from "node:path";
7
+ import { join as join4, resolve as resolve2 } from "node:path";
8
8
 
9
9
  // src/rules.js
10
10
  import { homedir, tmpdir } from "node:os";
11
- import { resolve as resolvePath, sep } from "node:path";
11
+ import { resolve as resolvePath, sep, dirname, basename, join } from "node:path";
12
+ import { realpathSync } from "node:fs";
12
13
 
13
14
  // src/denylist.js
14
15
  var BASH_DENYLIST = [
@@ -104,13 +105,31 @@ function expandHome(p) {
104
105
  if (p.startsWith("~/")) return resolvePath(homedir(), p.slice(2));
105
106
  return p;
106
107
  }
108
+ function realpathOfNearestExisting(p) {
109
+ const missing = [];
110
+ let current = p;
111
+ for (; ; ) {
112
+ try {
113
+ const real = realpathSync(current);
114
+ return missing.length ? join(real, ...missing) : real;
115
+ } catch {
116
+ }
117
+ const parent = dirname(current);
118
+ if (parent === current) return current;
119
+ missing.unshift(basename(current));
120
+ current = parent;
121
+ }
122
+ }
107
123
  function insideRoot(target, root) {
108
124
  if (!root) return true;
109
125
  const absTarget = resolvePath(root, expandHome(target));
110
126
  const absRoot = resolvePath(root);
111
127
  if (absTarget === absRoot) return true;
112
- const a = CASE_INSENSITIVE_FS ? absTarget.toLowerCase() : absTarget;
113
- const b = CASE_INSENSITIVE_FS ? absRoot.toLowerCase() : absRoot;
128
+ const realTarget = realpathOfNearestExisting(absTarget);
129
+ const realRoot = realpathOfNearestExisting(absRoot);
130
+ if (realTarget === realRoot) return true;
131
+ const a = CASE_INSENSITIVE_FS ? realTarget.toLowerCase() : realTarget;
132
+ const b = CASE_INSENSITIVE_FS ? realRoot.toLowerCase() : realRoot;
114
133
  return a.startsWith(b.endsWith(sep) ? b : b + sep);
115
134
  }
116
135
  var HEREDOC = /^<<(-)?(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)"|([A-Za-z_][A-Za-z0-9_]*))/;
@@ -258,21 +277,84 @@ function tokenize(segment) {
258
277
  return tokens;
259
278
  }
260
279
  var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
261
- var GIT_WRAPPERS = /* @__PURE__ */ new Set([
262
- "env",
263
- "sudo",
264
- "doas",
265
- "nohup",
266
- "time",
267
- "command",
268
- "exec",
269
- "xargs",
270
- "npx"
271
- ]);
280
+ var WRAPPERS = {
281
+ env: /* @__PURE__ */ new Set([
282
+ "-u",
283
+ "--unset",
284
+ "-C",
285
+ "--chdir",
286
+ "-S",
287
+ "--split-string",
288
+ "--argv0",
289
+ "--block-signal",
290
+ "--default-signal",
291
+ "--ignore-signal"
292
+ ]),
293
+ sudo: /* @__PURE__ */ new Set([
294
+ "-u",
295
+ "--user",
296
+ "-g",
297
+ "--group",
298
+ "-h",
299
+ "--host",
300
+ "-p",
301
+ "--prompt",
302
+ "-C",
303
+ "--close-from",
304
+ "-T",
305
+ "--command-timeout",
306
+ "-R",
307
+ "--chroot",
308
+ "-D",
309
+ "--chdir",
310
+ "--other-user"
311
+ ]),
312
+ doas: /* @__PURE__ */ new Set(["-u", "--user"]),
313
+ nohup: /* @__PURE__ */ new Set(),
314
+ time: /* @__PURE__ */ new Set(["-f", "--file", "-o", "--format", "-p", "--portability"]),
315
+ command: /* @__PURE__ */ new Set(["-p", "--path", "-v", "--verbose"]),
316
+ exec: /* @__PURE__ */ new Set(["-a", "--argv0", "-c", "--command", "-l", "--login"]),
317
+ xargs: /* @__PURE__ */ new Set([
318
+ "-a",
319
+ "--arg-file",
320
+ "-d",
321
+ "--delimiter",
322
+ "-E",
323
+ "--eof",
324
+ "-I",
325
+ "--replace",
326
+ "-L",
327
+ "--max-lines",
328
+ "-n",
329
+ "--max-args",
330
+ "-P",
331
+ "--max-procs",
332
+ "-s",
333
+ "--max-chars",
334
+ "-t",
335
+ "--verbose",
336
+ "-x",
337
+ "--exit-on-error"
338
+ ]),
339
+ npx: /* @__PURE__ */ new Set(["-p", "--package", "-c", "--call", "--registry"]),
340
+ // The wrappers that run a command and are not in the list above are the
341
+ // ones that were missing from it, and every one of them was a way around
342
+ // the rules.
343
+ timeout: /* @__PURE__ */ new Set(["-k", "--kill-after", "-s", "--signal"]),
344
+ nice: /* @__PURE__ */ new Set(["-n", "--adjustment"]),
345
+ ionice: /* @__PURE__ */ new Set(["-c", "--class", "-n", "--classdata", "-p", "--pid", "-t"]),
346
+ stdbuf: /* @__PURE__ */ new Set(["-i", "--input", "-o", "--output", "-e", "--error"]),
347
+ setsid: /* @__PURE__ */ new Set(),
348
+ chroot: /* @__PURE__ */ new Set(["-u", "--users", "-g", "--groups", "--userspec", "--groupspec"]),
349
+ taskset: /* @__PURE__ */ new Set(["-c", "--cpu-list", "-p", "--pid"]),
350
+ busybox: /* @__PURE__ */ new Set()
351
+ };
352
+ var WRAPPER_TABLE = WRAPPERS;
272
353
  var SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
273
354
  var SCRIPT_RUNNERS = /^python[0-9.]*$/i;
274
355
  var DOWNLOADERS = /\b(curl|wget)\b/i;
275
356
  var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
357
+ var DURATION = /^\d+(\.\d+)?[smhd]?$/;
276
358
  var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
277
359
  "-C",
278
360
  "-c",
@@ -322,6 +404,17 @@ function baseName(word) {
322
404
  const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"));
323
405
  return slash === -1 ? word : word.slice(slash + 1);
324
406
  }
407
+ function skipWrapperArgs(tokens, i, name) {
408
+ const valueFlags = WRAPPER_TABLE[name] ?? WRAPPER_VALUE_FLAGS;
409
+ while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
410
+ const flag = tokens[i];
411
+ i++;
412
+ if (valueFlags.has(flag) && !flag.includes("=") && i < tokens.length) i++;
413
+ }
414
+ if (name === "timeout" && i < tokens.length && DURATION.test(tokens[i])) i++;
415
+ if (name === "chroot" && i < tokens.length && !tokens[i].startsWith("-")) i++;
416
+ return i;
417
+ }
325
418
  function skipPrefixes(tokens) {
326
419
  let i = 0;
327
420
  for (; ; ) {
@@ -331,13 +424,9 @@ function skipPrefixes(tokens) {
331
424
  i++;
332
425
  continue;
333
426
  }
334
- if (GIT_WRAPPERS.has(baseName(word))) {
335
- i++;
336
- while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
337
- const flag = tokens[i];
338
- i++;
339
- if (WRAPPER_VALUE_FLAGS.has(flag) && i < tokens.length) i++;
340
- }
427
+ const name = baseName(word);
428
+ if (name in WRAPPER_TABLE) {
429
+ i = skipWrapperArgs(tokens, i + 1, name);
341
430
  continue;
342
431
  }
343
432
  return i;
@@ -375,6 +464,10 @@ function gitSubcommand(segment) {
375
464
  i++;
376
465
  while (i < tokens.length) {
377
466
  const flag = tokens[i];
467
+ if (flag.length > 2 && !flag.startsWith("--") && (flag.startsWith("-C") || flag.startsWith("-c"))) {
468
+ i++;
469
+ continue;
470
+ }
378
471
  if (GIT_VALUE_FLAGS.has(flag)) {
379
472
  i += 2;
380
473
  continue;
@@ -400,9 +493,39 @@ function shellCommandString(tokens, from) {
400
493
  }
401
494
  return null;
402
495
  }
496
+ function envSplitPayload(tokens) {
497
+ for (let i = 0; i < tokens.length; i++) {
498
+ const word = tokens[i];
499
+ if (word === void 0) return null;
500
+ if (ENV_ASSIGN.test(word)) continue;
501
+ const name = baseName(word);
502
+ if (name === "env") {
503
+ for (let j = i + 1; j < tokens.length; j++) {
504
+ const token = tokens[j];
505
+ if (token === "-S" || token === "--split-string") {
506
+ return tokens[j + 1] ?? null;
507
+ }
508
+ if (token.startsWith("--split-string=")) {
509
+ return token.slice("--split-string=".length) || null;
510
+ }
511
+ if (token.startsWith("-S") && token.length > 2) {
512
+ return token.slice(2) || null;
513
+ }
514
+ if (ENV_ASSIGN.test(token)) continue;
515
+ if (!token.startsWith("-")) break;
516
+ }
517
+ return null;
518
+ }
519
+ if (!(name in WRAPPER_TABLE)) return null;
520
+ i = skipWrapperArgs(tokens, i + 1, name) - 1;
521
+ }
522
+ return null;
523
+ }
403
524
  function shellPayload(segment) {
404
525
  const tokens = tokenize(segment);
405
526
  if (tokens.length < 2) return null;
527
+ const split2 = envSplitPayload(tokens);
528
+ if (split2) return split2;
406
529
  const i = skipPrefixes(tokens);
407
530
  if (i >= tokens.length) return null;
408
531
  if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
@@ -488,6 +611,57 @@ function opensPermissions(text) {
488
611
  return /^0*777$/.test(mode);
489
612
  }
490
613
  var ASK_GATED = [/^\s*git\s+push\b/];
614
+ function downloadTarget(segment) {
615
+ const tokens = tokenize(segment);
616
+ const i = skipPrefixes(tokens);
617
+ if (i >= tokens.length) return null;
618
+ const name = baseName(tokens[i]).toLowerCase();
619
+ if (name !== "curl" && name !== "wget") return null;
620
+ for (let j = i + 1; j < tokens.length; j++) {
621
+ const token = tokens[j];
622
+ if (token === "--") break;
623
+ if (token === "-o" || token === "--output" || token === "-O" || token === "--output-document") {
624
+ const value = tokens[j + 1];
625
+ return value && !value.startsWith("-") ? value : null;
626
+ }
627
+ if (token.startsWith("--output=") || token.startsWith("--output-document=")) {
628
+ return token.slice(token.indexOf("=") + 1) || null;
629
+ }
630
+ if (name === "wget" && token.startsWith("-O") && token.length > 2) {
631
+ return token.slice(2) || null;
632
+ }
633
+ }
634
+ return null;
635
+ }
636
+ function sameFile(a, b) {
637
+ if (a === b) return true;
638
+ if (!a.includes("/") || !b.includes("/")) return baseName(a) === baseName(b);
639
+ return false;
640
+ }
641
+ function runsFile(segment, file) {
642
+ const tokens = tokenize(segment);
643
+ const i = skipPrefixes(tokens);
644
+ if (i >= tokens.length) return false;
645
+ if (sameFile(tokens[i], file)) return true;
646
+ const name = baseName(tokens[i]);
647
+ const runner = SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name) || name === "source" || name === ".";
648
+ if (!runner) return false;
649
+ for (let j = i + 1; j < tokens.length; j++) {
650
+ if (sameFile(tokens[j], file)) return true;
651
+ }
652
+ return false;
653
+ }
654
+ function downloadsAndRuns(command) {
655
+ const parts = segments(command);
656
+ for (let i = 0; i < parts.length; i++) {
657
+ const file = downloadTarget(parts[i]);
658
+ if (!file) continue;
659
+ for (let j = i + 1; j < parts.length; j++) {
660
+ if (runsFile(parts[j], file)) return true;
661
+ }
662
+ }
663
+ return false;
664
+ }
491
665
  var BASH_RULES = [
492
666
  {
493
667
  name: "secret exfiltration via shell",
@@ -519,6 +693,16 @@ var BASH_RULES = [
519
693
  check: (text) => pipesScriptIntoShell(text),
520
694
  why: "downloads and executes a script in one step"
521
695
  },
696
+ {
697
+ name: "download and run",
698
+ scope: "command",
699
+ // The pipe rule above is the same download in ONE step. This is the
700
+ // same download in TWO: fetch it to a file, then run the file. The
701
+ // rules only ever looked at a pipe, so the two-step form was ordinary
702
+ // work - and it runs exactly the same downloaded script.
703
+ check: (text) => downloadsAndRuns(text),
704
+ why: "downloads a script to a file and then runs it"
705
+ },
522
706
  // `git push` is ask-gated via permissions (`*` ask in the generated
523
707
  // config), not guard-blocked. Publishing needs human approval, but the
524
708
  // guard lets it through so opencode can prompt first.
@@ -575,6 +759,11 @@ var WRITE_RULES = [
575
759
  {
576
760
  name: "write to a private key",
577
761
  test: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\b/i,
762
+ // The PUBLIC half of a key pair is a file the user is meant to publish.
763
+ // The rule named the key FILES, and `id_rsa.pub` is one of them, so
764
+ // writing a public key - the ordinary thing to do with a key - was
765
+ // refused for a reason nobody could find.
766
+ unless: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\.pub(\.[^/\\]*)?$/i,
578
767
  why: "private key material"
579
768
  }
580
769
  ];
@@ -642,10 +831,10 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
642
831
 
643
832
  // src/telemetry-store.ts
644
833
  import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs";
645
- import { join } from "node:path";
834
+ import { join as join2 } from "node:path";
646
835
  var MAX_BYTES = 4 * 1024 * 1024;
647
836
  function logPath(dir) {
648
- return join(dir, "telemetry.jsonl");
837
+ return join2(dir, "telemetry.jsonl");
649
838
  }
650
839
  function rotate(file, maxBytes = MAX_BYTES) {
651
840
  try {
@@ -670,13 +859,13 @@ function write(dir, record) {
670
859
 
671
860
  // src/project.js
672
861
  import { existsSync as existsSync2 } from "node:fs";
673
- import { dirname, join as join2, resolve } from "node:path";
862
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
674
863
  function projectRoot(directory, worktree) {
675
864
  const start = resolve(directory || worktree || ".");
676
865
  let dir = start;
677
866
  for (let i = 0; i < 64; i++) {
678
- if (existsSync2(join2(dir, ".git"))) return dir;
679
- const parent = dirname(dir);
867
+ if (existsSync2(join3(dir, ".git"))) return dir;
868
+ const parent = dirname2(dir);
680
869
  if (parent === dir) break;
681
870
  dir = parent;
682
871
  }
@@ -759,7 +948,7 @@ function truncate(s, n = 200) {
759
948
  }
760
949
  var GuardPlugin = async ({ worktree, directory }) => {
761
950
  const project = projectRoot(directory, worktree);
762
- const telemetryDir = join3(project, ".space");
951
+ const telemetryDir = join4(project, ".space");
763
952
  const refuse = (tool, rule, why, detail) => {
764
953
  write(telemetryDir, { kind: "guard", tool, rule });
765
954
  deny(why, detail);
@@ -770,7 +959,7 @@ var GuardPlugin = async ({ worktree, directory }) => {
770
959
  if (input.tool === "bash") {
771
960
  const raw = output.args?.workdir;
772
961
  if (typeof raw === "string" && raw.trim()) {
773
- const target = raw.startsWith("~") ? join3(homedir2(), raw.slice(1)) : resolve2(project, raw);
962
+ const target = raw.startsWith("~") ? join4(homedir2(), raw.slice(1)) : resolve2(project, raw);
774
963
  if (!insideRoot(target, project) && !insideRoot(target, tmpdir2())) {
775
964
  refuse(
776
965
  input.tool,
@@ -833,15 +1022,15 @@ var GuardPlugin = async ({ worktree, directory }) => {
833
1022
 
834
1023
  // src/profile.js
835
1024
  import { readFileSync, existsSync as existsSync3, readdirSync } from "node:fs";
836
- import { dirname as dirname2, join as join4, resolve as resolve3 } from "node:path";
1025
+ import { dirname as dirname3, join as join5, resolve as resolve3 } from "node:path";
837
1026
  import { homedir as homedir3 } from "node:os";
838
1027
  import { fileURLToPath } from "node:url";
839
- var HERE = dirname2(fileURLToPath(import.meta.url));
1028
+ var HERE = dirname3(fileURLToPath(import.meta.url));
840
1029
  function findRoot(start) {
841
1030
  let dir = start;
842
1031
  for (let i = 0; i < 12; i++) {
843
- if (existsSync3(join4(dir, "profiles"))) return dir;
844
- const parent = dirname2(dir);
1032
+ if (existsSync3(join5(dir, "profiles"))) return dir;
1033
+ const parent = dirname3(dir);
845
1034
  if (parent === dir) break;
846
1035
  dir = parent;
847
1036
  }
@@ -870,11 +1059,11 @@ function retentionSeverity(level) {
870
1059
  );
871
1060
  }
872
1061
  function profileDirs() {
873
- return [process.env.OPENCODE_HARNESS_PROFILES ?? join4(ROOT, "profiles")];
1062
+ return [process.env.OPENCODE_HARNESS_PROFILES ?? join5(ROOT, "profiles")];
874
1063
  }
875
1064
  function profilePath(id) {
876
1065
  for (const dir of profileDirs()) {
877
- const full = join4(dir, `${id}.json`);
1066
+ const full = join5(dir, `${id}.json`);
878
1067
  if (existsSync3(full)) return full;
879
1068
  }
880
1069
  return null;
@@ -914,7 +1103,10 @@ function normalize(p) {
914
1103
  keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
915
1104
  keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
916
1105
  keepUserMessages: p.prune?.keepUserMessages ?? true,
917
- keepDistinctOutputs: p.prune?.keepDistinctOutputs ?? true
1106
+ keepDistinctOutputs: p.prune?.keepDistinctOutputs ?? true,
1107
+ // null means "the pruner's own budget decides" - the profile's context
1108
+ // budget. A number is a ceiling in characters.
1109
+ keepDistinctBudgetChars: p.prune?.keepDistinctBudgetChars ?? null
918
1110
  },
919
1111
  fallback: p.fallback ?? []
920
1112
  };
@@ -922,7 +1114,7 @@ function normalize(p) {
922
1114
  var laneCache = null;
923
1115
  function loadLanes() {
924
1116
  if (laneCache) return laneCache;
925
- const file = join4(ROOT, "lanes.json");
1117
+ const file = join5(ROOT, "lanes.json");
926
1118
  if (!existsSync3(file)) {
927
1119
  throw new ProfileError(`lane table missing: ${file}`);
928
1120
  }
@@ -945,7 +1137,7 @@ function laneMeta(name) {
945
1137
  var retentionCache = null;
946
1138
  function loadRetention() {
947
1139
  if (retentionCache) return retentionCache;
948
- const file = join4(ROOT, "retention.yml");
1140
+ const file = join5(ROOT, "retention.yml");
949
1141
  if (!existsSync3(file)) {
950
1142
  retentionCache = { models: {}, defaults: {} };
951
1143
  return retentionCache;
@@ -1210,6 +1402,13 @@ function validate(p, path = "<memory>") {
1210
1402
  if (prune.keepDistinctOutputs !== void 0 && typeof prune.keepDistinctOutputs !== "boolean") {
1211
1403
  throw new ProfileError(`${where}: prune.keepDistinctOutputs must be a boolean`);
1212
1404
  }
1405
+ if (prune.keepDistinctBudgetChars !== void 0 && prune.keepDistinctBudgetChars !== null) {
1406
+ if (!Number.isInteger(prune.keepDistinctBudgetChars) || prune.keepDistinctBudgetChars < 0) {
1407
+ throw new ProfileError(
1408
+ `${where}: prune.keepDistinctBudgetChars must be an integer of at least 0`
1409
+ );
1410
+ }
1411
+ }
1213
1412
  if (prune.enabled !== void 0 && typeof prune.enabled !== "boolean") {
1214
1413
  throw new ProfileError(`${where}: prune.enabled must be a boolean`);
1215
1414
  }
@@ -1358,10 +1557,10 @@ function worstRetention(ids) {
1358
1557
  return { retention, worst };
1359
1558
  }
1360
1559
  function configDir() {
1361
- return process.env.OPENCODE_CONFIG_DIR ?? join4(homedir3(), ".config", "opencode");
1560
+ return process.env.OPENCODE_CONFIG_DIR ?? join5(homedir3(), ".config", "opencode");
1362
1561
  }
1363
1562
  function configFilePath() {
1364
- return join4(configDir(), "opencode.jsonc");
1563
+ return join5(configDir(), "opencode.jsonc");
1365
1564
  }
1366
1565
  function parseJsonc(text) {
1367
1566
  let out = "";
@@ -1452,11 +1651,11 @@ function profileIdFromConfig() {
1452
1651
 
1453
1652
  // src/prune.ts
1454
1653
  import { readFileSync as readFileSync2, existsSync as existsSync4 } from "node:fs";
1455
- import { join as join5 } from "node:path";
1654
+ import { join as join6 } from "node:path";
1456
1655
  function activeProfileId(worktree) {
1457
1656
  const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
1458
1657
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
1459
- const candidates = [join5(worktree, ".space", "active")];
1658
+ const candidates = [join6(worktree, ".space", "active")];
1460
1659
  for (const f of candidates) {
1461
1660
  if (!f) continue;
1462
1661
  try {
@@ -1516,33 +1715,58 @@ var PrunePlugin = async ({ worktree, directory }) => {
1516
1715
  keepTurns: 0,
1517
1716
  keepUsers: true,
1518
1717
  keepDistinct: false,
1718
+ distinctBudget: 0,
1519
1719
  threshold: 0
1520
1720
  };
1521
1721
  let cached = null;
1722
+ let reportedFailure = false;
1522
1723
  const resolve4 = () => {
1523
1724
  if (cached) return cached;
1524
1725
  let profile = null;
1525
1726
  try {
1526
1727
  const id = activeProfileId(project);
1527
1728
  if (id) profile = loadProfile(id);
1528
- } catch {
1729
+ } catch (err) {
1529
1730
  profile = null;
1731
+ if (!reportedFailure) {
1732
+ reportedFailure = true;
1733
+ console.error(
1734
+ `[opencode-harness-kit] prune disabled: ${err instanceof Error ? err.message : String(err)}`
1735
+ );
1736
+ }
1530
1737
  }
1531
1738
  if (!profile?.prune?.enabled) return DISABLED;
1739
+ const threshold = pruneThreshold(profile);
1532
1740
  cached = {
1533
1741
  profile,
1534
1742
  keepCalls: profile.prune.keepRecentToolCalls ?? 6,
1535
1743
  keepTurns: profile.prune.keepRecentTurns ?? 3,
1536
1744
  keepUsers: profile.prune.keepUserMessages ?? true,
1537
1745
  keepDistinct: profile.prune.keepDistinctOutputs ?? true,
1538
- threshold: pruneThreshold(profile)
1746
+ // The budget the distinct outputs have to fit in.
1747
+ //
1748
+ // It defaults to the profile's own context budget - the number the
1749
+ // pruner is measured against - because a rule with no ceiling is not a
1750
+ // trade-off, it is a leak. Measured on a real 137-message session: the
1751
+ // request was 5.26 MB and the pruner removed 0.04 MB of it (0.8%),
1752
+ // because EVERY distinct output the session had ever produced was kept
1753
+ // (4.74 MB, 92% of the request) "however old it is". The threshold
1754
+ // that session was supposed to live under was 176,000 characters and
1755
+ // the request was 5,140,000 - twenty-nine times over.
1756
+ //
1757
+ // The ceiling keeps the property the rule was added for (the newest
1758
+ // copy of a file the agent read survives, so it does not read it
1759
+ // again) and drops the part that was never wanted (the copy of every
1760
+ // file the session has EVER touched, for ever).
1761
+ distinctBudget: profile.prune.keepDistinctBudgetChars ?? threshold,
1762
+ threshold
1539
1763
  };
1540
1764
  return cached;
1541
1765
  };
1542
1766
  return {
1543
1767
  "experimental.chat.messages.transform": async (_input, output) => {
1544
1768
  try {
1545
- const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, threshold } = resolve4();
1769
+ const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, distinctBudget, threshold } = resolve4();
1546
1770
  if (!profile) return;
1547
1771
  const messages = output.messages;
1548
1772
  if (!Array.isArray(messages) || messages.length === 0) return;
@@ -1561,17 +1785,30 @@ var PrunePlugin = async ({ worktree, directory }) => {
1561
1785
  }
1562
1786
  if (keepDistinct) {
1563
1787
  const seenSignatures = /* @__PURE__ */ new Set();
1788
+ let kept = 0;
1564
1789
  for (let i = messages.length - 1; i >= 0; i--) {
1565
1790
  const msg = messages[i];
1566
1791
  if (!msg?.parts) continue;
1792
+ let added = 0;
1567
1793
  for (const part of msg.parts) {
1568
1794
  if (part?.type !== "tool") continue;
1569
1795
  if (part.state?.status !== "completed") continue;
1570
1796
  const sig = toolSignature(part);
1571
- if (!sig || seenSignatures.has(sig)) continue;
1797
+ if (!sig) continue;
1798
+ const seen2 = seenSignatures.has(sig);
1572
1799
  seenSignatures.add(sig);
1573
- keepToolMessageIds.add(msg.info?.id ?? String(i));
1800
+ if (seen2) continue;
1801
+ if (i >= floor) continue;
1802
+ added += partSize(part);
1574
1803
  }
1804
+ if (added === 0) continue;
1805
+ added = (msg.parts ?? []).reduce(
1806
+ (n, p) => n + partSize(p),
1807
+ 0
1808
+ );
1809
+ if (kept + added > distinctBudget) break;
1810
+ kept += added;
1811
+ keepToolMessageIds.add(msg.info?.id ?? String(i));
1575
1812
  }
1576
1813
  }
1577
1814
  const stats = {
@@ -1655,12 +1892,12 @@ var PrunePlugin = async ({ worktree, directory }) => {
1655
1892
  };
1656
1893
  };
1657
1894
  async function logPrune(worktree, profileId, stats) {
1658
- const dir = join5(worktree, ".space");
1895
+ const dir = join6(worktree, ".space");
1659
1896
  try {
1660
1897
  const { mkdir, appendFile } = await import("node:fs/promises");
1661
1898
  await mkdir(dir, { recursive: true });
1662
1899
  await appendFile(
1663
- join5(dir, "prune.jsonl"),
1900
+ join6(dir, "prune.jsonl"),
1664
1901
  JSON.stringify({
1665
1902
  t: (/* @__PURE__ */ new Date()).toISOString(),
1666
1903
  profile: profileId,
@@ -1696,7 +1933,7 @@ function utilityModel(p) {
1696
1933
 
1697
1934
  // src/health.js
1698
1935
  import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
1699
- import { join as join6 } from "node:path";
1936
+ import { join as join7 } from "node:path";
1700
1937
  var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
1701
1938
  function spentWindowMs() {
1702
1939
  const raw = process.env.HARNESS_SPENT_WINDOW_HOURS;
@@ -1726,7 +1963,7 @@ function liveUnhealthy(health, now = Date.now()) {
1726
1963
  return out;
1727
1964
  }
1728
1965
  function healthPath(dir) {
1729
- return join6(dir, ".space", "health.json");
1966
+ return join7(dir, ".space", "health.json");
1730
1967
  }
1731
1968
  function readHealth(dir) {
1732
1969
  let parsed;
@@ -1746,7 +1983,7 @@ function readHealth(dir) {
1746
1983
  }
1747
1984
  function writeHealth(dir, health) {
1748
1985
  try {
1749
- if (!existsSync5(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
1986
+ if (!existsSync5(join7(dir, ".space"))) mkdirSync2(join7(dir, ".space"), { recursive: true });
1750
1987
  const pruned = { ...health ?? {}, unhealthy: liveUnhealthy(health) };
1751
1988
  writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2));
1752
1989
  } catch {
@@ -1989,9 +2226,9 @@ function applySessionModel(cfg, profile, opts = {}) {
1989
2226
  }
1990
2227
 
1991
2228
  // src/telemetry.ts
1992
- import { join as join7 } from "node:path";
2229
+ import { join as join8 } from "node:path";
1993
2230
  var TelemetryPlugin = async ({ worktree, directory }) => {
1994
- const dir = join7(projectRoot(directory, worktree), ".space");
2231
+ const dir = join8(projectRoot(directory, worktree), ".space");
1995
2232
  const started = Date.now();
1996
2233
  const opened = /* @__PURE__ */ new Set();
1997
2234
  const closed = /* @__PURE__ */ new Set();
@@ -2172,9 +2409,9 @@ var HarnessPlugin = async (ctx, options) => {
2172
2409
  applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(project) }), {
2173
2410
  sessionModel
2174
2411
  });
2175
- const dir = join8(project, ".space");
2412
+ const dir = join9(project, ".space");
2176
2413
  mkdirSync3(dir, { recursive: true });
2177
- writeFileSync2(join8(dir, "active"), `${id}
2414
+ writeFileSync2(join9(dir, "active"), `${id}
2178
2415
  `);
2179
2416
  } catch {
2180
2417
  }
package/dist/profile.d.ts CHANGED
@@ -55,6 +55,7 @@ export type Profile = {
55
55
  keepRecentTurns?: number;
56
56
  keepUserMessages?: boolean;
57
57
  keepDistinctOutputs?: boolean;
58
+ keepDistinctBudgetChars?: number | null;
58
59
  };
59
60
  fallback?: string[];
60
61
  };
@@ -88,7 +89,7 @@ export type RetentionRecord = {
88
89
  * @property {Record<string, RoleModel>} [roleModels]
89
90
  * @property {{ enabled?: boolean, keepRecentToolCalls?: number,
90
91
  * keepRecentTurns?: number, keepUserMessages?: boolean,
91
- * keepDistinctOutputs?: boolean }} [prune]
92
+ * keepDistinctOutputs?: boolean, keepDistinctBudgetChars?: number | null }} [prune]
92
93
  * @property {string[]} [fallback]
93
94
  * @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
94
95
  */
package/dist/rules.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Whether a write target stays inside the project. A relative path is resolved
3
3
  * against the project root, which is where the agent's working directory is.
4
+ *
5
+ * The comparison is made on the REAL path, not on the text of the path. A
6
+ * symlink inside the project that points at /etc is a path inside the
7
+ * project, and it is a write outside it - and the text of the path is the
8
+ * only thing the lexical comparison ever saw.
9
+ *
4
10
  * @param {string} target
5
11
  * @param {string} root
6
12
  * @returns {boolean}