harnesstrim 0.0.7 → 0.2.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.
@@ -59,9 +59,29 @@ def register(ctx):
59
59
  ctx.register_hook("transform_tool_result", on_tool_result)
60
60
 
61
61
 
62
+ def _load_file_config():
63
+ """Read the baked config from `config.json` beside the plugin (written by
64
+ `harnesstrim install hermes --mode/--min-length --apply`). Environment variables
65
+ still override it at runtime. Any malformed/missing file degrades to {}."""
66
+ cfg_path = PLUGIN_DIR / "config.json"
67
+ if not cfg_path.is_file():
68
+ return {}
69
+ try:
70
+ loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
71
+ except (OSError, ValueError):
72
+ return {}
73
+ if not isinstance(loaded, dict):
74
+ return {}
75
+ return {key: loaded[key] for key in CONFIG_DEFAULTS if key in loaded}
76
+
77
+
62
78
  def _load_config():
63
- """Return the active plugin config from the environment (no global registry needed)."""
79
+ """Return the active plugin config: env vars override the baked config.json
80
+ (from install), which overrides the built-in defaults. Keeps the runtime
81
+ override (HARNESSTRIM_MODE / HARNESSTRIM_MINLENGTH) winning, so an installed
82
+ state can always be switched without reinstalling."""
64
83
  cfg = dict(CONFIG_DEFAULTS)
84
+ cfg.update(_load_file_config())
65
85
  for key in CONFIG_DEFAULTS:
66
86
  env_key = f"HARNESSTRIM_{key.upper()}"
67
87
  val = os.environ.get(env_key)
@@ -89,10 +109,11 @@ def _find_harnesstrim_cli() -> str | None:
89
109
  return None
90
110
 
91
111
 
92
- def _call_reducer(text: str, min_length: int) -> tuple[str, str | None]:
93
- """Shell out to ``harnesstrim reduce`` and return (slimmed_text, reducer_name).
112
+ def _call_reducer(text: str, min_length: int) -> tuple[str, str | None, bool]:
113
+ """Shell out to ``harnesstrim reduce`` and return
114
+ (slimmed_text, reducer_name, reduction_failed).
94
115
 
95
- Falls back to (original_text, None) if the CLI cannot be found or the pipe fails.
116
+ Falls back to (original_text, None, False) if the CLI cannot be found or the pipe fails.
96
117
  The reducer name is parsed from the ``--stats`` stderr line (e.g. ``test-output-slim``),
97
118
  used only for telemetry when enabled.
98
119
  """
@@ -105,10 +126,10 @@ def _call_reducer(text: str, min_length: int) -> tuple[str, str | None]:
105
126
  "or build from source: git clone https://github.com/harnesstrim/harnesstrim",
106
127
  stacklevel=2,
107
128
  )
108
- return (text, None)
129
+ return (text, None, False)
109
130
 
110
131
  if len(text) < min_length:
111
- return (text, None)
132
+ return (text, None, False)
112
133
 
113
134
  try:
114
135
  result = subprocess.run(
@@ -127,11 +148,12 @@ def _call_reducer(text: str, min_length: int) -> tuple[str, str | None]:
127
148
  rest = line[len("[harnesstrim reduce] "):]
128
149
  if ":" in rest and "no reduction" not in rest:
129
150
  reducer = rest.split(":")[0].strip()
130
- return (result.stdout.rstrip("\n"), reducer)
151
+ reduction_failed = "reducer failed; original output preserved" in result.stderr
152
+ return (result.stdout, reducer, reduction_failed)
131
153
  except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
132
154
  pass
133
155
 
134
- return (text, None)
156
+ return (text, None, False)
135
157
 
136
158
 
137
159
  def _text_targets(payload):
@@ -193,8 +215,17 @@ def on_tool_result(tool_name, args, result, **kwargs):
193
215
  if "[harnesstrim:" in text or "[hermes-trim" in text:
194
216
  continue
195
217
 
196
- after, reducer = _call_reducer(text, cfg["minLength"])
218
+ after, reducer, reduction_failed = _call_reducer(text, cfg["minLength"])
197
219
  if after == text:
220
+ if reduction_failed and cfg["telemetry"]:
221
+ _write_metric(
222
+ tool_name,
223
+ reducer,
224
+ before_len,
225
+ before_len,
226
+ changed=False,
227
+ reduction_failed=True,
228
+ )
198
229
  continue
199
230
 
200
231
  changed = True
@@ -218,7 +249,14 @@ def on_tool_result(tool_name, args, result, **kwargs):
218
249
  return json.dumps(payload, ensure_ascii=False) if not isinstance(payload, str) else payload
219
250
 
220
251
 
221
- def _write_metric(tool: str, reducer: str | None, before: int, after: int) -> None:
252
+ def _write_metric(
253
+ tool: str,
254
+ reducer: str | None,
255
+ before: int,
256
+ after: int,
257
+ changed: bool = True,
258
+ reduction_failed: bool = False,
259
+ ) -> None:
222
260
  """Append one TrimEvent JSONL line to METRICS_PATH (read by `harnesstrim metrics`).
223
261
 
224
262
  Only called in active mode when telemetry is explicitly enabled. Creates the parent
@@ -236,6 +274,8 @@ def _write_metric(tool: str, reducer: str | None, before: int, after: int) -> No
236
274
  "reducer": reducer,
237
275
  "beforeChars": before,
238
276
  "afterChars": after,
277
+ "changed": changed,
278
+ "reductionFailed": reduction_failed,
239
279
  "beforeTokens": None,
240
280
  "afterTokens": None,
241
281
  }
@@ -0,0 +1,177 @@
1
+ // HarnessTrim OMP hook — slims noisy tool output via the `tool_result` hook.
2
+ // Marker: harnesstrim:omp-hook
3
+ //
4
+ // omp auto-discovers default-export TS factories in ~/.omp/agent/hooks/post/*.ts
5
+ // (global) and .omp/hooks/post/*.ts (project), loads them through its extension
6
+ // runner, and fires `tool_result` after every successful tool call BEFORE the
7
+ // result reaches the model. Returning `{ content }` replaces the result; returning
8
+ // nothing leaves it untouched. Files in hooks/post/ are loaded with no trust gate
9
+ // and no settings.json entry, so this hook's handlers bind on session start.
10
+ //
11
+ // This file is a hook FACTORY, not a harness process — it shells out to
12
+ // `harnesstrim reduce` so the reducers live in the shared CLI core. Requires
13
+ // `harnesstrim` on PATH; if missing or failing, output passes through unchanged
14
+ // (a reducer must never break a tool result).
15
+ //
16
+ // Config precedence (highest first):
17
+ // 1. Environment: HARNESSTRIM_MODE=dryrun|active|off, HARNESSTRIM_MINLENGTH=<chars>,
18
+ // HARNESSTRIM_METRICS=<path>
19
+ // 2. config.json beside the hooks dir (written by `harnesstrim install omp --apply`
20
+ // with --mode/--min-length/--metrics)
21
+ // 3. built-in defaults: dryrun, minLength 400.
22
+ //
23
+ // --metrics records a TrimEvent JSONL receipt per reduction (read by
24
+ // `harnesstrim metrics`) — the receipt that makes interception verifiable.
25
+ import { spawnSync } from "node:child_process";
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
29
+ const env = process.env;
30
+ const MARKER = "[harnesstrim";
31
+ const DEFAULT_MIN_LENGTH = 400;
32
+
33
+ const HERE =
34
+ (typeof import.meta !== "undefined" && (import.meta as { dirname?: string }).dirname) || "";
35
+ const CONFIG_PATH = HERE ? path.join(HERE, "..", "harnesstrim.json") : "";
36
+
37
+ function readConfig(): { mode?: string; minLength?: number; metrics?: string } {
38
+ if (!CONFIG_PATH) return {};
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) as Record<string, unknown>;
41
+ return {
42
+ mode: typeof parsed.mode === "string" ? parsed.mode : undefined,
43
+ minLength: typeof parsed.minLength === "number" ? parsed.minLength : undefined,
44
+ metrics: typeof parsed.metrics === "string" ? parsed.metrics : undefined,
45
+ };
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ const baked = readConfig();
52
+ const MODE = env.HARNESSTRIM_MODE ?? baked.mode ?? "dryrun";
53
+ const MIN_LENGTH = Number(env.HARNESSTRIM_MINLENGTH ?? (baked.minLength ?? DEFAULT_MIN_LENGTH)) || DEFAULT_MIN_LENGTH;
54
+ const METRICS_PATH = env.HARNESSTRIM_METRICS || baked.metrics || undefined;
55
+
56
+ interface TextChunk {
57
+ type: string;
58
+ text?: unknown;
59
+ }
60
+
61
+ function reduce(
62
+ text: string,
63
+ ): { output: string | null; reducer: string | null; reductionFailed: boolean } {
64
+ try {
65
+ const r = spawnSync("harnesstrim", ["reduce", "--min-length", String(MIN_LENGTH), "--stats"], {
66
+ input: text,
67
+ encoding: "utf8",
68
+ timeout: 30_000,
69
+ });
70
+ const stdout = typeof r.stdout === "string" ? r.stdout : "";
71
+ const stderr = typeof r.stderr === "string" ? r.stderr : "";
72
+ const output = stdout;
73
+ if (r.status === 0 && output && output.length > 0) {
74
+ return {
75
+ output,
76
+ reducer: parseReducer(stderr),
77
+ reductionFailed: stderr.includes("reducer failed; original output preserved"),
78
+ };
79
+ }
80
+ } catch {
81
+ /* harnesstrim not on PATH or failed — pass through */
82
+ }
83
+ return { output: null, reducer: null, reductionFailed: false };
84
+ }
85
+
86
+ function parseReducer(stderr: string): string | null {
87
+ for (const line of stderr.split(/\r?\n/)) {
88
+ const trimmed = line.trim();
89
+ if (trimmed.startsWith("[harnesstrim reduce]")) {
90
+ const rest = trimmed.slice("[harnesstrim reduce] ".length);
91
+ if (rest.includes(":") && !rest.includes("no reduction")) return rest.split(":")[0].trim();
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function eventTool(event: Record<string, unknown>): string {
98
+ return typeof event.toolName === "string" && event.toolName ? event.toolName : "tool_result";
99
+ }
100
+
101
+ function writeMetric(partial: {
102
+ tool: string;
103
+ reducer: string | null;
104
+ before: number;
105
+ after: number;
106
+ changed: boolean;
107
+ reductionFailed?: boolean;
108
+ }): void {
109
+ if (!METRICS_PATH) return;
110
+ try {
111
+ const event = {
112
+ schemaVersion: 1,
113
+ eventId:
114
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
115
+ ? crypto.randomUUID()
116
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`,
117
+ ts: new Date().toISOString(),
118
+ harness: "omp",
119
+ tool: partial.tool,
120
+ reducer: partial.reducer,
121
+ beforeChars: partial.before,
122
+ afterChars: partial.after,
123
+ changed: partial.changed,
124
+ reductionFailed: partial.reductionFailed ?? false,
125
+ beforeTokens: null,
126
+ afterTokens: null,
127
+ };
128
+ fs.mkdirSync(path.dirname(path.resolve(METRICS_PATH)), { recursive: true });
129
+ fs.appendFileSync(METRICS_PATH, JSON.stringify(event) + "\n");
130
+ } catch {
131
+ /* telemetry must never break the hook */
132
+ }
133
+ }
134
+
135
+ export default function harnessTrim(pi: { on(event: string, handler: (event: unknown) => unknown): void }): void {
136
+ if (MODE === "off") return;
137
+ pi.on("tool_result", (event) => {
138
+ const ev = event as { content?: TextChunk[] };
139
+ if (!Array.isArray(ev.content)) return;
140
+ let changed = false;
141
+ const content = ev.content.map((chunk: TextChunk) => {
142
+ if (chunk.type !== "text" || typeof chunk.text !== "string") return chunk;
143
+ const text = chunk.text;
144
+ if (text.length < MIN_LENGTH || text.includes(MARKER)) return chunk;
145
+
146
+ const { output: reduced, reducer, reductionFailed } = reduce(text);
147
+ if (!reduced || reduced.length >= text.length) {
148
+ if (METRICS_PATH) {
149
+ writeMetric({
150
+ tool: eventTool(ev),
151
+ reducer: reductionFailed ? reducer : null,
152
+ before: text.length,
153
+ after: text.length,
154
+ changed: false,
155
+ reductionFailed,
156
+ });
157
+ }
158
+ return chunk;
159
+ }
160
+
161
+ if (MODE === "dryrun") {
162
+ process.stderr.write(`[harnesstrim] dryrun OMP tool_result: ${text.length} -> ${reduced.length} chars\n`);
163
+ if (METRICS_PATH) {
164
+ writeMetric({ tool: eventTool(ev), reducer, before: text.length, after: reduced.length, changed: true });
165
+ }
166
+ return chunk;
167
+ }
168
+
169
+ changed = true;
170
+ if (METRICS_PATH) {
171
+ writeMetric({ tool: eventTool(ev), reducer, before: text.length, after: reduced.length, changed: true });
172
+ }
173
+ return { ...chunk, text: reduced };
174
+ });
175
+ return changed ? { content } : undefined;
176
+ });
177
+ }
@@ -7,10 +7,20 @@
7
7
  // and loads from `~/.pi/agent/extensions/` or `<project>/.pi/extensions/`.
8
8
  //
9
9
  // Requires `harnesstrim` on PATH; if it is missing or fails, the output is passed through
10
- // unchanged (a reducer must never break a tool result). Config via env:
11
- // HARNESSTRIM_MODE=dryrun|active|off (default dryrun — logs, does not mutate)
12
- // HARNESSTRIM_MINLENGTH=<chars> (default 400)
10
+ // unchanged (a reducer must never break a tool result).
11
+ //
12
+ // Config precedence (highest first):
13
+ // 1. Environment: HARNESSTRIM_MODE=dryrun|active|off, HARNESSTRIM_MINLENGTH=<chars>,
14
+ // HARNESSTRIM_METRICS=<path> (default dryrun — logs, does not mutate; min 400)
15
+ // 2. config.json beside this file (written by `harnesstrim install pi --apply`
16
+ // with --mode/--min-length/--metrics)
17
+ // 3. built-in defaults below.
18
+ //
19
+ // --metrics records a TrimEvent JSONL receipt per reduction attempt (read by
20
+ // `harnesstrim metrics`), the receipt that makes interception verifiable.
13
21
  import { spawnSync } from "node:child_process";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
14
24
 
15
25
  type TextContent = { type: "text"; text: string };
16
26
  type ToolContent = TextContent | { type: string; [key: string]: unknown };
@@ -25,29 +35,130 @@ interface ExtensionAPI {
25
35
 
26
36
  const runtime = globalThis as typeof globalThis & { process?: NodeJS.Process };
27
37
  const env = runtime.process?.env ?? {};
28
- const MODE = env.HARNESSTRIM_MODE ?? "dryrun";
29
- const MIN_LENGTH = Number(env.HARNESSTRIM_MINLENGTH ?? "400") || 400;
30
38
  const MARKER = "[harnesstrim";
31
39
 
40
+ /** The directory this extension file lives in (the installed extension dir). */
41
+ const HERE =
42
+ (typeof import.meta !== "undefined" &&
43
+ (import.meta as { dirname?: string }).dirname) ||
44
+ "";
45
+
46
+ interface BakedConfig {
47
+ mode?: string;
48
+ minLength?: number;
49
+ metrics?: string;
50
+ }
51
+
52
+ function readBakedConfig(): BakedConfig {
53
+ if (!HERE) return {};
54
+ try {
55
+ const raw = fs.readFileSync(path.join(HERE, "config.json"), "utf8");
56
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
57
+ return {
58
+ mode: typeof parsed.mode === "string" ? parsed.mode : undefined,
59
+ minLength: typeof parsed.minLength === "number" ? parsed.minLength : undefined,
60
+ metrics: typeof parsed.metrics === "string" ? parsed.metrics : undefined,
61
+ };
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ function resolveMode(baked: BakedConfig): string {
68
+ return env.HARNESSTRIM_MODE ?? baked.mode ?? "dryrun";
69
+ }
70
+
71
+ function resolveMinLength(baked: BakedConfig): number {
72
+ const raw = env.HARNESSTRIM_MINLENGTH ?? (baked.minLength !== undefined ? String(baked.minLength) : "400");
73
+ return Number(raw) || 400;
74
+ }
75
+
76
+ function resolveMetricsPath(baked: BakedConfig): string | undefined {
77
+ return env.HARNESSTRIM_METRICS || baked.metrics || undefined;
78
+ }
79
+
80
+ // Read the baked config once at load: the file cannot change mid-session without a
81
+ // reinstall, and the env vars are the runtime override.
82
+ const BAKED = readBakedConfig();
83
+ const MODE = resolveMode(BAKED);
84
+ const MIN_LENGTH = resolveMinLength(BAKED);
85
+ const METRICS_PATH = resolveMetricsPath(BAKED);
86
+
32
87
  /** True when a text chunk should not be reduced: too short, or already reduced. */
33
88
  export function shouldSkip(text: string, minLength: number): boolean {
34
89
  return text.length < minLength || text.includes(MARKER);
35
90
  }
36
91
 
37
- function reduceViaCli(text: string): string | null {
92
+ /** Parse the reducer name from `harnesstrim reduce --stats` stderr, if any. */
93
+ function parseReducer(stderr: string): string | null {
94
+ for (const line of stderr.split(/\r?\n/)) {
95
+ const trimmed = line.trim();
96
+ if (trimmed.startsWith("[harnesstrim reduce]")) {
97
+ const rest = trimmed.slice("[harnesstrim reduce] ".length);
98
+ if (rest.includes(":") && !rest.includes("no reduction")) return rest.split(":")[0].trim();
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function reduceViaCli(
105
+ text: string,
106
+ ): { output: string | null; reducer: string | null; reductionFailed: boolean } {
38
107
  try {
39
- const r = spawnSync("harnesstrim", ["reduce", "--min-length", String(MIN_LENGTH)], {
108
+ const r = spawnSync("harnesstrim", ["reduce", "--min-length", String(MIN_LENGTH), "--stats"], {
40
109
  input: text,
41
110
  encoding: "utf8",
42
111
  timeout: 30000,
43
112
  });
44
113
  if (r.status === 0 && typeof r.stdout === "string" && r.stdout.length > 0) {
45
- return r.stdout.replace(/\n$/, "");
114
+ const output = r.stdout;
115
+ const stderr = typeof r.stderr === "string" ? r.stderr : "";
116
+ const reducer = parseReducer(stderr);
117
+ return {
118
+ output,
119
+ reducer,
120
+ reductionFailed: stderr.includes("reducer failed; original output preserved"),
121
+ };
46
122
  }
47
123
  } catch {
48
124
  /* harnesstrim not on PATH or failed — pass through */
49
125
  }
50
- return null;
126
+ return { output: null, reducer: null, reductionFailed: false };
127
+ }
128
+
129
+ /** Append a TrimEvent JSONL receipt (self-contained: no workspace imports allowed). */
130
+ function writeMetric(partial: {
131
+ tool: string;
132
+ reducer: string | null;
133
+ before: number;
134
+ after: number;
135
+ changed: boolean;
136
+ reductionFailed?: boolean;
137
+ }): void {
138
+ if (!METRICS_PATH) return;
139
+ try {
140
+ const event = {
141
+ schemaVersion: 1,
142
+ eventId:
143
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
144
+ ? crypto.randomUUID()
145
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`,
146
+ ts: new Date().toISOString(),
147
+ harness: "pi",
148
+ tool: partial.tool,
149
+ reducer: partial.reducer,
150
+ beforeChars: partial.before,
151
+ afterChars: partial.after,
152
+ changed: partial.changed,
153
+ reductionFailed: partial.reductionFailed ?? false,
154
+ beforeTokens: null,
155
+ afterTokens: null,
156
+ };
157
+ fs.mkdirSync(path.dirname(path.resolve(METRICS_PATH)), { recursive: true });
158
+ fs.appendFileSync(METRICS_PATH, JSON.stringify(event) + "\n");
159
+ } catch {
160
+ /* telemetry must never break the extension */
161
+ }
51
162
  }
52
163
 
53
164
  export default function harnesstrim(pi: ExtensionAPI): void {
@@ -61,20 +172,46 @@ export default function harnesstrim(pi: ExtensionAPI): void {
61
172
  const text = chunk.text;
62
173
  if (shouldSkip(text, MIN_LENGTH)) return chunk;
63
174
 
64
- const reduced = reduceViaCli(text);
65
- if (!reduced || reduced.length >= text.length) return chunk;
175
+ const { output: reduced, reducer, reductionFailed } = reduceViaCli(text);
176
+ if (!reduced || reduced.length >= text.length) {
177
+ // A reducer exception is a distinct fail-open event; a no-match is a pass-through.
178
+ if (METRICS_PATH) {
179
+ writeMetric({
180
+ tool: eventTool(event),
181
+ reducer: reductionFailed ? reducer : null,
182
+ before: text.length,
183
+ after: text.length,
184
+ changed: false,
185
+ reductionFailed,
186
+ });
187
+ }
188
+ return chunk;
189
+ }
66
190
 
67
191
  if (MODE === "dryrun") {
68
192
  runtime.process?.stderr?.write(
69
193
  `[harnesstrim] dryrun tool_result: ${text.length} -> ${reduced.length} chars\n`
70
194
  );
195
+ // Receipt with the would-be counts — dryrun's value is proof it WOULD reduce.
196
+ if (METRICS_PATH) {
197
+ writeMetric({ tool: eventTool(event), reducer, before: text.length, after: reduced.length, changed: true });
198
+ }
71
199
  return chunk;
72
200
  }
73
201
 
74
202
  changed = true;
203
+ if (METRICS_PATH) {
204
+ writeMetric({ tool: eventTool(event), reducer, before: text.length, after: reduced.length, changed: true });
205
+ }
75
206
  return { ...chunk, text: reduced };
76
207
  });
77
208
 
78
209
  return changed ? { content } : undefined;
79
210
  });
80
211
  }
212
+
213
+ /** Best-effort tool name from the event (Pi's event carries toolName when present). */
214
+ function eventTool(event: ToolResultEvent): string {
215
+ const name = (event as ToolResultEvent & { toolName?: string }).toolName;
216
+ return typeof name === "string" && name.length > 0 ? name : "tool_result";
217
+ }