opencode-harness-kit 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,976 @@
1
+ // src/index.ts
2
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
3
+ import { join as join5 } from "node:path";
4
+
5
+ // src/rules.js
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { resolve as resolvePath, sep } from "node:path";
8
+ var CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
9
+ function expandHome(p) {
10
+ if (p === "~") return homedir();
11
+ if (p.startsWith("~/")) return resolvePath(homedir(), p.slice(2));
12
+ return p;
13
+ }
14
+ function insideRoot(target, root) {
15
+ if (!root) return true;
16
+ const absTarget = resolvePath(root, expandHome(target));
17
+ const absRoot = resolvePath(root);
18
+ if (absTarget === absRoot) return true;
19
+ const a = CASE_INSENSITIVE_FS ? absTarget.toLowerCase() : absTarget;
20
+ const b = CASE_INSENSITIVE_FS ? absRoot.toLowerCase() : absRoot;
21
+ return a.startsWith(b.endsWith(sep) ? b : b + sep);
22
+ }
23
+ var HEREDOC = /^<<(-)?(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)"|([A-Za-z_][A-Za-z0-9_]*))/;
24
+ function heredocEnd(command, at) {
25
+ if (command[at] !== "<" || command[at + 1] !== "<") return -1;
26
+ if (command[at + 2] === "<") return -1;
27
+ const m = HEREDOC.exec(command.slice(at));
28
+ if (!m) return -1;
29
+ const delim = m[2] ?? m[3] ?? m[4];
30
+ const nl = command.indexOf("\n", at + m[0].length);
31
+ if (nl === -1) return -1;
32
+ for (let p = nl + 1; ; ) {
33
+ const end = command.indexOf("\n", p);
34
+ const line = command.slice(p, end === -1 ? command.length : end);
35
+ if (line.trim() === delim) return end === -1 ? command.length : end;
36
+ if (end === -1) return -1;
37
+ p = end + 1;
38
+ }
39
+ }
40
+ function segments(command) {
41
+ const parts = [];
42
+ let cur = "";
43
+ let quote = null;
44
+ const flush = () => {
45
+ const t = cur.trim();
46
+ if (t) parts.push(t);
47
+ cur = "";
48
+ };
49
+ for (let i = 0; i < command.length; i++) {
50
+ const ch = command[i];
51
+ if (quote) {
52
+ cur += ch;
53
+ if (ch === "\\" && quote === '"') {
54
+ if (i + 1 < command.length) cur += command[++i];
55
+ continue;
56
+ }
57
+ if (ch === quote) quote = null;
58
+ continue;
59
+ }
60
+ if (ch === "\\") {
61
+ cur += ch;
62
+ if (i + 1 < command.length) cur += command[++i];
63
+ continue;
64
+ }
65
+ if (ch === "'" || ch === '"') {
66
+ quote = ch;
67
+ cur += ch;
68
+ continue;
69
+ }
70
+ if (ch === "<" && command[i + 1] === "<") {
71
+ const end = heredocEnd(command, i);
72
+ if (end > i) {
73
+ cur += command.slice(i, end);
74
+ i = end - 1;
75
+ continue;
76
+ }
77
+ }
78
+ if (ch === "\n" || ch === ";") {
79
+ flush();
80
+ continue;
81
+ }
82
+ if (ch === "|") {
83
+ flush();
84
+ if (command[i + 1] === "|") i++;
85
+ continue;
86
+ }
87
+ if (ch === "&" && command[i + 1] === "&") {
88
+ flush();
89
+ i++;
90
+ continue;
91
+ }
92
+ cur += ch;
93
+ }
94
+ flush();
95
+ return parts;
96
+ }
97
+ function tokenize(segment) {
98
+ const tokens = [];
99
+ let cur = "";
100
+ let quote = null;
101
+ let started = false;
102
+ const flush = () => {
103
+ if (cur || started) tokens.push(cur);
104
+ cur = "";
105
+ started = false;
106
+ };
107
+ for (let i = 0; i < segment.length; i++) {
108
+ const ch = segment[i];
109
+ if (quote) {
110
+ if (ch === "\\" && quote === '"') {
111
+ if (i + 1 < segment.length) cur += segment[++i];
112
+ continue;
113
+ }
114
+ if (ch === quote) {
115
+ quote = null;
116
+ continue;
117
+ }
118
+ cur += ch;
119
+ continue;
120
+ }
121
+ if (ch === "\\" && i + 1 < segment.length) {
122
+ cur += segment[++i];
123
+ started = true;
124
+ continue;
125
+ }
126
+ if (ch === "'" || ch === '"') {
127
+ quote = ch;
128
+ started = true;
129
+ continue;
130
+ }
131
+ if (/\s/.test(ch)) {
132
+ flush();
133
+ continue;
134
+ }
135
+ cur += ch;
136
+ }
137
+ flush();
138
+ return tokens;
139
+ }
140
+ var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
141
+ var GIT_WRAPPERS = /* @__PURE__ */ new Set(["env", "sudo", "doas", "nohup", "time", "command", "exec", "xargs"]);
142
+ var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
143
+ var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
144
+ "-C",
145
+ "-c",
146
+ "--git-dir",
147
+ "--work-tree",
148
+ "--namespace",
149
+ "--super-prefix",
150
+ "--exec-path",
151
+ "--config-env"
152
+ ]);
153
+ var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
154
+ "--bare",
155
+ "--no-pager",
156
+ "--paginate",
157
+ "--no-replace-objects",
158
+ "--no-optional-locks",
159
+ "--literal-pathspecs",
160
+ "--glob-pathspecs",
161
+ "--icase-pathspecs"
162
+ ]);
163
+ function skipPrefixes(tokens) {
164
+ let i = 0;
165
+ for (; ; ) {
166
+ const word = tokens[i];
167
+ if (word === void 0) return tokens.length;
168
+ if (ENV_ASSIGN.test(word)) {
169
+ i++;
170
+ continue;
171
+ }
172
+ if (GIT_WRAPPERS.has(word)) {
173
+ i++;
174
+ while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
175
+ const flag = tokens[i];
176
+ i++;
177
+ if (WRAPPER_VALUE_FLAGS.has(flag) && i < tokens.length) i++;
178
+ }
179
+ continue;
180
+ }
181
+ return i;
182
+ }
183
+ }
184
+ function stripPrefixes(segment) {
185
+ const tokens = tokenize(segment);
186
+ const i = skipPrefixes(tokens);
187
+ if (i >= tokens.length) return "";
188
+ const rest = tokens.slice(i);
189
+ const slash = Math.max(rest[0].lastIndexOf("/"), rest[0].lastIndexOf("\\"));
190
+ if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
191
+ return rest.join(" ");
192
+ }
193
+ function gitSubcommand(segment) {
194
+ const tokens = tokenize(segment);
195
+ let i = skipPrefixes(tokens);
196
+ if (i >= tokens.length) return null;
197
+ if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null;
198
+ i++;
199
+ while (i < tokens.length) {
200
+ const flag = tokens[i];
201
+ if (GIT_VALUE_FLAGS.has(flag)) {
202
+ i += 2;
203
+ continue;
204
+ }
205
+ if (/^--[A-Za-z][\w-]*=/.test(flag) || GIT_GLOBAL_FLAGS.has(flag)) {
206
+ i++;
207
+ continue;
208
+ }
209
+ break;
210
+ }
211
+ return tokens[i] ?? null;
212
+ }
213
+ var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
214
+ var MAX_SHELL_DEPTH = 2;
215
+ function shellPayload(segment) {
216
+ const tokens = tokenize(segment);
217
+ if (tokens.length < 2) return null;
218
+ if (tokens[0] === "eval") return tokens.slice(1).join(" ") || null;
219
+ if (!SHELL_BIN.test(tokens[0])) return null;
220
+ const c = tokens.findIndex((t) => /^-[a-zA-Z]*c$/.test(t));
221
+ if (c === -1 || c === tokens.length - 1) return null;
222
+ return tokens.slice(c + 1).join(" ") || null;
223
+ }
224
+ var BASH_RULES = [
225
+ {
226
+ name: "secret exfiltration via shell",
227
+ scope: "command",
228
+ test: /\b(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
229
+ why: "assigns a fetched value into a credential-named variable"
230
+ },
231
+ {
232
+ name: "remote delete over ssh",
233
+ scope: "command",
234
+ test: /\bssh\b[^|;&]*\brm\s+-[a-zA-Z]*[rf]/i,
235
+ why: "destructive command executed on another host"
236
+ },
237
+ {
238
+ name: "pipe remote script into a shell",
239
+ scope: "command",
240
+ test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
241
+ why: "downloads and executes a script in one step"
242
+ },
243
+ {
244
+ name: "git push with force",
245
+ scope: "segment",
246
+ // A dry run publishes nothing, so it is not the thing being gated.
247
+ check: (seg) => gitSubcommand(seg) === "push" && tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
248
+ why: "overwrites published history"
249
+ },
250
+ {
251
+ name: "git push",
252
+ scope: "segment",
253
+ // Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
254
+ // name, not a force flag.
255
+ check: (seg) => gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
256
+ why: "publishing is a human decision"
257
+ },
258
+ {
259
+ name: "recursive delete",
260
+ scope: "segment",
261
+ test: /^rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*)\s+/i,
262
+ why: "rm with a recursive or force flag"
263
+ },
264
+ {
265
+ name: "chmod 777",
266
+ scope: "segment",
267
+ test: /^chmod\s+(-[a-zA-Z]+\s+)*777\b/,
268
+ why: "opens permissions on everything"
269
+ },
270
+ {
271
+ name: "history rewrite",
272
+ scope: "segment",
273
+ test: /^git\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f|rebase\s+.*--force)/i,
274
+ why: "destroys work that only exists locally"
275
+ }
276
+ ];
277
+ var WRITE_RULES = [
278
+ {
279
+ // `unless` exists because the safe variants are the same shape as the
280
+ // dangerous ones: `.env.example` is a template meant to be committed, and
281
+ // protecting it would block ordinary work.
282
+ name: "write to a dotenv file",
283
+ test: /(^|[\\/])\.env(\.|$)/i,
284
+ unless: /(^|[\\/])\.env\.(example|sample|template|defaults?)\b/i,
285
+ why: "dotenv files hold live credentials"
286
+ },
287
+ {
288
+ name: "write to a credentials file",
289
+ test: /(^|[\\/])\.(npmrc|pypirc|netrc|git-credentials)(\.|$)/i,
290
+ unless: /(^|[\\/])\.(npmrc|pypirc)\.example$/i,
291
+ why: "registry and git credentials"
292
+ },
293
+ {
294
+ name: "write to a private key",
295
+ test: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\b/i,
296
+ why: "private key material"
297
+ }
298
+ ];
299
+ function matches(rule, text) {
300
+ if (rule.check) return rule.check(text);
301
+ if (!rule.test?.test(text)) return false;
302
+ return !rule.unless?.test(text);
303
+ }
304
+ function checkBash(command, rules = BASH_RULES, depth = 0) {
305
+ if (!command || !command.trim()) return null;
306
+ const parts = segments(command);
307
+ const normalized = parts.map((seg) => stripPrefixes(seg));
308
+ for (const rule of rules) {
309
+ if (rule.scope === "command") {
310
+ if (matches(rule, command)) return { name: rule.name, why: rule.why, segment: command };
311
+ continue;
312
+ }
313
+ for (let i = 0; i < parts.length; i++) {
314
+ if (matches(rule, normalized[i])) return { name: rule.name, why: rule.why, segment: parts[i] };
315
+ }
316
+ }
317
+ if (depth < MAX_SHELL_DEPTH) {
318
+ for (const seg of parts) {
319
+ const payload = shellPayload(seg);
320
+ if (!payload) continue;
321
+ const hit = checkBash(payload, rules, depth + 1);
322
+ if (hit) return hit;
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+ function checkWrite(target, rules = WRITE_RULES, root = "") {
328
+ if (!target) return null;
329
+ for (const rule of rules) {
330
+ if (!matches(rule, target)) continue;
331
+ return { name: rule.name, why: rule.why, segment: target };
332
+ }
333
+ if (root && !insideRoot(target, root) && !insideRoot(target, tmpdir())) {
334
+ return {
335
+ name: "write outside the project",
336
+ why: `this project's files are the ones you were asked to change, and ${root} is where they live`,
337
+ segment: target
338
+ };
339
+ }
340
+ return null;
341
+ }
342
+
343
+ // src/guard.ts
344
+ var MARKER = "[opencode-harness-kit] blocked";
345
+ function deny(why, detail) {
346
+ throw new Error(
347
+ `${MARKER}: ${why}
348
+ ${detail}
349
+ This is enforced in code, not by a prompt. If the command is genuinely safe, run it yourself in a terminal.`
350
+ );
351
+ }
352
+ function truncate(s, n = 200) {
353
+ const one = s.replace(/\s+/g, " ").trim();
354
+ return one.length > n ? `${one.slice(0, n)}...` : one;
355
+ }
356
+ var GuardPlugin = async ({ worktree }) => {
357
+ return {
358
+ "tool.execute.before": async (input, output) => {
359
+ try {
360
+ if (input.tool === "bash") {
361
+ const hit = checkBash(String(output.args?.command ?? ""));
362
+ if (hit) deny(hit.name, `${hit.why}
363
+ command: ${truncate(hit.segment)}`);
364
+ return;
365
+ }
366
+ if (input.tool === "write" || input.tool === "edit" || input.tool === "patch") {
367
+ const target = String(output.args?.filePath ?? output.args?.path ?? "") || String(output.args?.changes?.[0]?.filePath ?? "");
368
+ const hit = checkWrite(target, void 0, worktree);
369
+ if (hit) deny(hit.name, `${hit.why}
370
+ file: ${truncate(hit.segment)}`);
371
+ }
372
+ } catch (err) {
373
+ if (err instanceof Error && err.message.startsWith(MARKER)) throw err;
374
+ throw new Error(
375
+ `${MARKER} (guard failed closed): ${err instanceof Error ? err.message : String(err)}`
376
+ );
377
+ }
378
+ }
379
+ };
380
+ };
381
+
382
+ // src/profile.js
383
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
384
+ import { dirname, join, resolve } from "node:path";
385
+ import { fileURLToPath } from "node:url";
386
+ var HERE = dirname(fileURLToPath(import.meta.url));
387
+ var ROOT = resolve(HERE, "..");
388
+ var ProfileError = class extends Error {
389
+ };
390
+ function profileDirs() {
391
+ return [join(ROOT, "profiles"), join(ROOT, "examples")];
392
+ }
393
+ function profilePath(id) {
394
+ for (const dir of profileDirs()) {
395
+ const full = join(dir, `${id}.json`);
396
+ if (existsSync(full)) return full;
397
+ }
398
+ return null;
399
+ }
400
+ function loadProfile(id) {
401
+ const path = profilePath(id);
402
+ if (!path) {
403
+ throw new ProfileError(`no profile named "${id}". Run: oc-harness list`);
404
+ }
405
+ let raw;
406
+ try {
407
+ raw = JSON.parse(readFileSync(path, "utf8"));
408
+ } catch (err) {
409
+ const msg = err instanceof Error ? err.message : String(err);
410
+ throw new ProfileError(`cannot read ${path}: ${msg}`);
411
+ }
412
+ validate(raw, path);
413
+ return normalize(raw);
414
+ }
415
+ function normalize(p) {
416
+ return {
417
+ ...p,
418
+ limits: { ...p.limits, input: p.limits.input ?? p.limits.context },
419
+ effort: {
420
+ levels: p.effort?.levels ?? [],
421
+ default: p.effort?.default ?? null,
422
+ reserveShare: p.effort?.reserveShare ?? 0.12
423
+ },
424
+ prune: {
425
+ enabled: p.prune?.enabled ?? false,
426
+ keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
427
+ keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
428
+ keepUserMessages: p.prune?.keepUserMessages ?? true
429
+ },
430
+ fallback: p.fallback ?? []
431
+ };
432
+ }
433
+ var VALID_RETENTION = ["zero-retention", "may-train", "unknown"];
434
+ function validate(p, path = "<memory>") {
435
+ const where = p?.id ?? path;
436
+ if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
437
+ if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
438
+ if (!p.model) throw new ProfileError(`${where}: missing "model"`);
439
+ if (!VALID_RETENTION.includes(p.retention)) {
440
+ throw new ProfileError(
441
+ `${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
442
+ );
443
+ }
444
+ if (!p.limits?.context || p.limits.context <= 0) {
445
+ throw new ProfileError(`${where}: limits.context must be a positive integer`);
446
+ }
447
+ if (!p.limits?.output || p.limits.output <= 0) {
448
+ throw new ProfileError(`${where}: limits.output must be a positive integer`);
449
+ }
450
+ const share = p.effort?.reserveShare;
451
+ if (share !== void 0 && (share < 0 || share > 0.5)) {
452
+ throw new ProfileError(
453
+ `${where}: effort.reserveShare must be between 0 and 0.5`
454
+ );
455
+ }
456
+ }
457
+ var CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
458
+ function resolveEffort(levels, requested) {
459
+ if (!requested) return null;
460
+ if (!levels || levels.length === 0) return null;
461
+ if (levels.includes(requested)) return requested;
462
+ const want = CANON.indexOf(requested);
463
+ if (want < 0) {
464
+ return levels[levels.length - 1] ?? null;
465
+ }
466
+ let best = null;
467
+ let bestDist = Infinity;
468
+ for (const level of levels) {
469
+ const have = CANON.indexOf(level);
470
+ if (have < 0) continue;
471
+ const d = Math.abs(have - want);
472
+ if (d < bestDist) {
473
+ bestDist = d;
474
+ best = level;
475
+ }
476
+ }
477
+ return best ?? levels[levels.length - 1] ?? null;
478
+ }
479
+ function resolveRoles(p) {
480
+ const levels = p.effort?.levels ?? [];
481
+ const out = (
482
+ /** @type {Record<string, string | null>} */
483
+ {}
484
+ );
485
+ for (const [role, want] of Object.entries(p.roles ?? {})) {
486
+ out[role] = resolveEffort(levels, want);
487
+ }
488
+ return out;
489
+ }
490
+ function resolveDefaultVariant(p) {
491
+ return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null);
492
+ }
493
+ function reservedTokens(p) {
494
+ const input = p.limits.input ?? p.limits.context;
495
+ const share = p.effort?.reserveShare ?? 0.12;
496
+ return Math.max(8e3, Math.round(input * share));
497
+ }
498
+ function pruneThreshold(p) {
499
+ return (p.limits.input ?? p.limits.context) - reservedTokens(p);
500
+ }
501
+ function configFilePath() {
502
+ const dir = process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
503
+ return join(dir, "opencode.jsonc");
504
+ }
505
+ function profileIdFromConfig() {
506
+ const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
507
+ if (fromEnv) return fromEnv.trim() || null;
508
+ const file = configFilePath();
509
+ if (!existsSync(file)) return null;
510
+ try {
511
+ const text = readFileSync(file, "utf8");
512
+ const json = text.slice(text.indexOf("{"));
513
+ const parsed = JSON.parse(json);
514
+ const id = parsed?.harness?.profile;
515
+ return typeof id === "string" && id ? id : null;
516
+ } catch {
517
+ return null;
518
+ }
519
+ }
520
+
521
+ // src/prune.ts
522
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
523
+ import { join as join2 } from "node:path";
524
+ function activeProfileId(worktree) {
525
+ const candidates = [
526
+ process.env.OPENCODE_HARNESS_PROFILE,
527
+ join2(worktree, ".space", "active"),
528
+ join2(ROOT, "profiles", ".active")
529
+ ];
530
+ for (const f of candidates) {
531
+ if (!f) continue;
532
+ try {
533
+ if (existsSync2(f)) {
534
+ const v = readFileSync2(f, "utf8").trim();
535
+ if (v) return v;
536
+ }
537
+ } catch {
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+ function sizeOf(part) {
543
+ if (typeof part?.text === "string") return part.text.length;
544
+ if (typeof part?.state?.output === "string") return part.state.output.length;
545
+ return 0;
546
+ }
547
+ function stub(part) {
548
+ const tool = part?.tool ?? "tool";
549
+ const state = part?.state ?? {};
550
+ const input = state.input ?? {};
551
+ if (tool === "bash") {
552
+ const cmd = String(input.command ?? "").replace(/\s+/g, " ").slice(0, 80);
553
+ const status = state.status ?? "done";
554
+ let tail = "";
555
+ const out = String(state.output ?? "");
556
+ const m = out.match(/(passed|failed|error|Error|ok|OK|\d+ (?:passed|failed))/g);
557
+ if (m && m.length) tail = `, ${m[m.length - 1]}`;
558
+ return `[${tool}] ${cmd} -> ${status}${tail} | full output was pruned from context; re-run the command if you need it`;
559
+ }
560
+ if (tool === "read" || tool === "grep" || tool === "glob" || tool === "list") {
561
+ const target = String(input.filePath ?? input.path ?? input.pattern ?? "");
562
+ return `[${tool}] ${target.slice(0, 100)} | result pruned from context; re-read it if you need it`;
563
+ }
564
+ return `[${tool}] result pruned from context (${state.status ?? "done"}); re-run if you need it`;
565
+ }
566
+ var PrunePlugin = async ({ worktree, $ }) => {
567
+ let profile = null;
568
+ try {
569
+ const id = activeProfileId(worktree);
570
+ if (id) profile = loadProfile(id);
571
+ } catch {
572
+ profile = null;
573
+ }
574
+ if (!profile?.prune?.enabled) {
575
+ return {};
576
+ }
577
+ const keepCalls = profile.prune.keepRecentToolCalls ?? 6;
578
+ const keepTurns = profile.prune.keepRecentTurns ?? 3;
579
+ const keepUsers = profile.prune.keepUserMessages ?? true;
580
+ const threshold = pruneThreshold(profile);
581
+ return {
582
+ "experimental.chat.messages.transform": async (_input, output) => {
583
+ try {
584
+ const messages = output.messages;
585
+ if (!Array.isArray(messages) || messages.length === 0) return;
586
+ const floor = Math.max(0, messages.length - 1 - keepTurns);
587
+ const keepToolMessageIds = /* @__PURE__ */ new Set();
588
+ let seen = 0;
589
+ for (let i = messages.length - 1; i >= 0 && seen < keepCalls; i--) {
590
+ const msg = messages[i];
591
+ if (!msg?.parts) continue;
592
+ for (const part of msg.parts) {
593
+ if (part?.type !== "tool") continue;
594
+ if (seen >= keepCalls) break;
595
+ keepToolMessageIds.add(msg.info?.id ?? String(i));
596
+ seen++;
597
+ }
598
+ }
599
+ const stats = {
600
+ stubbed: 0,
601
+ kept: 0,
602
+ bytesBefore: 0,
603
+ bytesAfter: 0
604
+ };
605
+ for (let i = 0; i < floor; i++) {
606
+ const msg = messages[i];
607
+ if (!msg?.parts) continue;
608
+ const isUser = msg.info?.role === "user";
609
+ for (const part of msg.parts) {
610
+ const size = sizeOf(part);
611
+ stats.bytesBefore += size;
612
+ if (isUser && keepUsers) {
613
+ stats.bytesAfter += size;
614
+ stats.kept++;
615
+ continue;
616
+ }
617
+ if (keepToolMessageIds.has(msg.info?.id ?? String(i))) {
618
+ stats.bytesAfter += size;
619
+ stats.kept++;
620
+ continue;
621
+ }
622
+ switch (part?.type) {
623
+ case "patch":
624
+ case "file":
625
+ stats.bytesAfter += size;
626
+ stats.kept++;
627
+ break;
628
+ case "reasoning":
629
+ stats.bytesAfter += Math.min(size, 200);
630
+ stats.stubbed++;
631
+ break;
632
+ case "tool": {
633
+ const state = part.state;
634
+ if (state?.status !== "completed") {
635
+ stats.bytesAfter += size;
636
+ stats.kept++;
637
+ break;
638
+ }
639
+ part.state = {
640
+ status: "completed",
641
+ input: state.input,
642
+ output: stub(part),
643
+ title: state.title,
644
+ time: state.time,
645
+ metadata: { ...state.metadata ?? {}, pruned: true, prunedAt: (/* @__PURE__ */ new Date()).toISOString() }
646
+ };
647
+ stats.bytesAfter += sizeOf(part);
648
+ stats.stubbed++;
649
+ break;
650
+ }
651
+ case "text":
652
+ stats.bytesAfter += size;
653
+ stats.kept++;
654
+ break;
655
+ default:
656
+ stats.bytesAfter += size;
657
+ stats.kept++;
658
+ }
659
+ }
660
+ }
661
+ if (stats.stubbed > 0) {
662
+ const saved = Math.max(0, stats.bytesBefore - stats.bytesAfter);
663
+ await logPrune(profile.id, {
664
+ ...stats,
665
+ saved,
666
+ threshold
667
+ });
668
+ }
669
+ } catch (err) {
670
+ console.error(
671
+ `[opencode-harness-kit] prune skipped: ${err instanceof Error ? err.message : String(err)}`
672
+ );
673
+ }
674
+ }
675
+ };
676
+ };
677
+ async function logPrune(profileId, stats) {
678
+ const dir = join2(process.cwd(), ".space");
679
+ try {
680
+ const { mkdir, appendFile } = await import("node:fs/promises");
681
+ await mkdir(dir, { recursive: true });
682
+ await appendFile(
683
+ join2(dir, "prune.jsonl"),
684
+ JSON.stringify({
685
+ t: (/* @__PURE__ */ new Date()).toISOString(),
686
+ profile: profileId,
687
+ stubbed: stats.stubbed,
688
+ kept: stats.kept,
689
+ savedBytes: stats.saved,
690
+ threshold: stats.threshold
691
+ }) + "\n"
692
+ );
693
+ } catch {
694
+ }
695
+ }
696
+
697
+ // src/router.ts
698
+ import { readFileSync as readFileSync3, writeFileSync, mkdirSync } from "node:fs";
699
+ import { join as join3 } from "node:path";
700
+ function healthPath(worktree) {
701
+ return join3(worktree, ".space", "health.json");
702
+ }
703
+ function readHealth(worktree) {
704
+ try {
705
+ return JSON.parse(readFileSync3(healthPath(worktree), "utf8"));
706
+ } catch {
707
+ return { unhealthy: {} };
708
+ }
709
+ }
710
+ function writeHealth(worktree, h) {
711
+ try {
712
+ mkdirSync(join3(worktree, ".space"), { recursive: true });
713
+ writeFileSync(healthPath(worktree), JSON.stringify(h, null, 2));
714
+ } catch {
715
+ }
716
+ }
717
+ var SPENT = [
718
+ /FreeUsageLimitError/i,
719
+ /rate[_ -]?limit/i,
720
+ /\b429\b/,
721
+ /quota (?:exceeded|exhausted)/i,
722
+ /overloaded_error/i,
723
+ /model .*not found/i,
724
+ /no available provider/i
725
+ ];
726
+ function classify(text) {
727
+ for (const p of SPENT) if (p.test(text)) return p.source;
728
+ return null;
729
+ }
730
+ var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the fallback queue, or `oc-harness doctor` to check whether the profile still matches live metadata.";
731
+ var RouterPlugin = async ({ worktree }) => {
732
+ let failedSessions = 0;
733
+ let warned = false;
734
+ return {
735
+ // Runs once, before the session does any work.
736
+ config: async (config) => {
737
+ try {
738
+ const cfg = config;
739
+ const health = readHealth(worktree);
740
+ const activeId = cfg.harness?.profile ?? profileIdFromConfig();
741
+ if (activeId) {
742
+ const id = activeId;
743
+ const mark = health.unhealthy[`opencode/${id}`];
744
+ if (mark) {
745
+ let chosen = id;
746
+ try {
747
+ const queue = [id, ...loadProfile(id).fallback ?? []];
748
+ for (const candidate of queue) {
749
+ if (!health.unhealthy[`opencode/${candidate}`]) {
750
+ chosen = candidate;
751
+ break;
752
+ }
753
+ }
754
+ } catch {
755
+ }
756
+ if (chosen !== id) {
757
+ applyProfileToConfig(cfg, chosen);
758
+ health.active = chosen;
759
+ writeHealth(worktree, health);
760
+ }
761
+ }
762
+ }
763
+ } catch {
764
+ }
765
+ },
766
+ event: async ({ event }) => {
767
+ const e = event;
768
+ const type = e?.type;
769
+ if (!type) return;
770
+ const text = JSON.stringify(e);
771
+ const reason = classify(text);
772
+ if (!reason) return;
773
+ const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID;
774
+ const key = modelID ? `opencode/${modelID}` : void 0;
775
+ if (!key) return;
776
+ const health = readHealth(worktree);
777
+ if (health.unhealthy[key]) return;
778
+ health.unhealthy[key] = { at: (/* @__PURE__ */ new Date()).toISOString(), reason };
779
+ writeHealth(worktree, health);
780
+ failedSessions++;
781
+ },
782
+ "chat.message": async (input, output) => {
783
+ if (failedSessions === 0) return;
784
+ if (warned) return;
785
+ warned = true;
786
+ const parts = output.parts ?? [];
787
+ const last = parts[parts.length - 1];
788
+ if (last?.type === "text") {
789
+ last.text = `${last.text}
790
+
791
+ ${WARN_TEXT}`;
792
+ return;
793
+ }
794
+ writeHealth(worktree, readHealth(worktree));
795
+ }
796
+ };
797
+ };
798
+ function applyProfileToConfig(cfg, profileId) {
799
+ try {
800
+ const p = loadProfile(profileId);
801
+ const model = `${p.provider}/${p.model}`;
802
+ cfg.model = model;
803
+ cfg.small_model = model;
804
+ const variant = p.effort?.default;
805
+ if (p.effort?.levels?.length && variant) cfg.variant = variant;
806
+ else delete cfg.variant;
807
+ for (const [role, want] of Object.entries(p.roles ?? {})) {
808
+ const agent = cfg.agent ?? (cfg.agent = {});
809
+ const existing = agent[role] ?? (agent[role] = {});
810
+ existing.model = model;
811
+ const level = resolveEffort(p.effort?.levels ?? [], want);
812
+ if (level) existing.variant = level;
813
+ else delete existing.variant;
814
+ }
815
+ if (cfg.compaction) {
816
+ cfg.compaction.prune = true;
817
+ cfg.compaction.reserved = reservedTokens(p);
818
+ }
819
+ cfg.harness = { ...cfg.harness ?? {}, profile: profileId, threshold: pruneThreshold(p) };
820
+ } catch {
821
+ }
822
+ }
823
+
824
+ // src/telemetry.ts
825
+ import { appendFileSync, mkdirSync as mkdirSync2, existsSync as existsSync4, statSync, renameSync } from "node:fs";
826
+ import { join as join4 } from "node:path";
827
+ var MAX_BYTES = 4 * 1024 * 1024;
828
+ function logPath(dir) {
829
+ return join4(dir, "telemetry.jsonl");
830
+ }
831
+ function rotate(file) {
832
+ try {
833
+ if (existsSync4(file) && statSync(file).size > MAX_BYTES) {
834
+ renameSync(file, `${file}.1`);
835
+ }
836
+ } catch {
837
+ }
838
+ }
839
+ function write(dir, record) {
840
+ try {
841
+ if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
842
+ const file = logPath(dir);
843
+ rotate(file);
844
+ appendFileSync(file, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...record }) + "\n");
845
+ } catch {
846
+ }
847
+ }
848
+ var TelemetryPlugin = async ({ worktree }) => {
849
+ const dir = join4(worktree, ".space");
850
+ const started = Date.now();
851
+ return {
852
+ event: async ({ event }) => {
853
+ const type = event?.type;
854
+ if (typeof type !== "string") return;
855
+ if (/error|rate|limit|quota/i.test(type)) {
856
+ write(dir, { kind: "provider-event", type, detail: truncate2(safeStringify(event)) });
857
+ }
858
+ },
859
+ "tool.execute.after": async (input, output) => {
860
+ const text = typeof output?.output === "string" ? output.output : "";
861
+ if (text.includes("[opencode-harness-kit] blocked")) {
862
+ write(dir, { kind: "guard", tool: input.tool, rule: extractRule(text) });
863
+ return;
864
+ }
865
+ const command = String(input.args?.command ?? "");
866
+ if (input.tool === "bash" && /git push|rm -rf|chmod 777/.test(command)) {
867
+ write(dir, { kind: "guard-attempt", tool: input.tool, command: truncate2(command) });
868
+ }
869
+ },
870
+ dispose: async () => {
871
+ write(dir, { kind: "session", uptimeMs: Date.now() - started });
872
+ }
873
+ };
874
+ };
875
+ function extractRule(output) {
876
+ const m = output.match(/blocked:\s*([^\n]+)/);
877
+ return m ? m[1].trim() : "unknown";
878
+ }
879
+ function truncate2(s, n = 300) {
880
+ return s.length > n ? `${s.slice(0, n)}...` : s;
881
+ }
882
+ function safeStringify(value) {
883
+ try {
884
+ return JSON.stringify(value) ?? String(value);
885
+ } catch {
886
+ return String(value);
887
+ }
888
+ }
889
+
890
+ // src/effort.js
891
+ var MANAGED_ROLES = ["plan", "build", "reviewer", "locate", "verify"];
892
+ function planEffort(profileId) {
893
+ const p = loadProfile(profileId);
894
+ const levels = p.effort?.levels ?? [];
895
+ const roles = resolveRoles(p);
896
+ const notes = [];
897
+ if (levels.length === 0) {
898
+ notes.push(
899
+ `${p.model} publishes no tunable reasoning effort; variant will be omitted for every role.`
900
+ );
901
+ }
902
+ for (const [role, want] of Object.entries(p.roles ?? {})) {
903
+ const got = roles[role];
904
+ if (want && got && want !== got) {
905
+ notes.push(`role ${role} asked for ${want}; ${p.model} tops out at ${got}.`);
906
+ }
907
+ }
908
+ return {
909
+ profileId,
910
+ model: `${p.provider}/${p.model}`,
911
+ defaultVariant: resolveDefaultVariant(p),
912
+ roles,
913
+ notes
914
+ };
915
+ }
916
+ function applyEffort(config, result, opts = {}) {
917
+ const cfg = (
918
+ /** @type {Record<string, any>} */
919
+ config
920
+ );
921
+ cfg.model = result.model;
922
+ cfg.agent = cfg.agent ?? {};
923
+ for (const role of MANAGED_ROLES) {
924
+ const level = result.roles[role];
925
+ if (level === void 0) continue;
926
+ const existing = cfg.agent[role] ?? {};
927
+ if (!existing.model) existing.model = result.model;
928
+ if (level === null) delete existing.variant;
929
+ else existing.variant = level;
930
+ cfg.agent[role] = existing;
931
+ }
932
+ cfg.harness = opts.fallbackProfileId ? { profile: result.profileId, fallback: opts.fallbackProfileId } : { profile: result.profileId };
933
+ return config;
934
+ }
935
+
936
+ // src/index.ts
937
+ var HarnessPlugin = async (ctx, options) => {
938
+ const opts = options ?? {};
939
+ const want = new Set(opts.only ?? ["guard", "prune", "router", "telemetry"]);
940
+ const plugins = [];
941
+ if (want.has("guard")) plugins.push(GuardPlugin);
942
+ if (want.has("telemetry")) plugins.push(TelemetryPlugin);
943
+ if (want.has("router")) plugins.push(RouterPlugin);
944
+ if (want.has("prune")) plugins.push(PrunePlugin);
945
+ const merged = {};
946
+ for (const p of plugins) {
947
+ const hooks = await p(ctx, {});
948
+ for (const [name, fn] of Object.entries(hooks)) {
949
+ const existing = merged[name];
950
+ merged[name] = typeof existing === "function" && typeof fn === "function" ? async (...a) => {
951
+ await existing(...a);
952
+ await fn(...a);
953
+ } : fn;
954
+ }
955
+ }
956
+ const routerConfig = merged["config"];
957
+ merged["config"] = async (config) => {
958
+ if (routerConfig) await routerConfig(config);
959
+ const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig();
960
+ if (!id) return;
961
+ try {
962
+ applyEffort(config, planEffort(id));
963
+ const dir = join5(ctx.worktree, ".space");
964
+ mkdirSync3(dir, { recursive: true });
965
+ writeFileSync2(join5(dir, "active"), `${id}
966
+ `);
967
+ } catch {
968
+ }
969
+ };
970
+ return merged;
971
+ };
972
+ var index_default = HarnessPlugin;
973
+ export {
974
+ HarnessPlugin,
975
+ index_default as default
976
+ };