@astudioplus/compressor 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.
Files changed (108) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +20 -0
  3. package/README.md +167 -0
  4. package/dist/adapters/agents-md.d.ts +2 -0
  5. package/dist/adapters/agents-md.js +91 -0
  6. package/dist/adapters/apply.d.ts +3 -0
  7. package/dist/adapters/apply.js +83 -0
  8. package/dist/adapters/claude-code.d.ts +2 -0
  9. package/dist/adapters/claude-code.js +403 -0
  10. package/dist/adapters/copilot.d.ts +2 -0
  11. package/dist/adapters/copilot.js +418 -0
  12. package/dist/adapters/cursor.d.ts +2 -0
  13. package/dist/adapters/cursor.js +149 -0
  14. package/dist/adapters/index.d.ts +11 -0
  15. package/dist/adapters/index.js +19 -0
  16. package/dist/adapters/markers.d.ts +7 -0
  17. package/dist/adapters/markers.js +129 -0
  18. package/dist/adapters/types.d.ts +44 -0
  19. package/dist/adapters/types.js +1 -0
  20. package/dist/bench/ablate.d.ts +35 -0
  21. package/dist/bench/ablate.js +163 -0
  22. package/dist/bench/cell.d.ts +33 -0
  23. package/dist/bench/cell.js +437 -0
  24. package/dist/bench/results.d.ts +37 -0
  25. package/dist/bench/results.js +157 -0
  26. package/dist/bench/runner.d.ts +24 -0
  27. package/dist/bench/runner.js +121 -0
  28. package/dist/bench/tasks.d.ts +4 -0
  29. package/dist/bench/tasks.js +147 -0
  30. package/dist/bench/types.d.ts +109 -0
  31. package/dist/bench/types.js +1 -0
  32. package/dist/claude/transcripts.d.ts +30 -0
  33. package/dist/claude/transcripts.js +154 -0
  34. package/dist/cli/commands/benchmark.d.ts +33 -0
  35. package/dist/cli/commands/benchmark.js +203 -0
  36. package/dist/cli/commands/compress.d.ts +8 -0
  37. package/dist/cli/commands/compress.js +45 -0
  38. package/dist/cli/commands/count.d.ts +5 -0
  39. package/dist/cli/commands/count.js +25 -0
  40. package/dist/cli/commands/hook.d.ts +6 -0
  41. package/dist/cli/commands/hook.js +30 -0
  42. package/dist/cli/commands/init.d.ts +16 -0
  43. package/dist/cli/commands/init.js +76 -0
  44. package/dist/cli/commands/report.d.ts +90 -0
  45. package/dist/cli/commands/report.js +464 -0
  46. package/dist/cli/commands/savings.d.ts +38 -0
  47. package/dist/cli/commands/savings.js +196 -0
  48. package/dist/cli/commands/set-mode.d.ts +5 -0
  49. package/dist/cli/commands/set-mode.js +13 -0
  50. package/dist/cli/commands/stats.d.ts +5 -0
  51. package/dist/cli/commands/stats.js +51 -0
  52. package/dist/cli/commands/status.d.ts +1 -0
  53. package/dist/cli/commands/status.js +11 -0
  54. package/dist/cli/commands/uninstall.d.ts +7 -0
  55. package/dist/cli/commands/uninstall.js +22 -0
  56. package/dist/cli/index.d.ts +2 -0
  57. package/dist/cli/index.js +146 -0
  58. package/dist/copilot-hook-entry.d.ts +1 -0
  59. package/dist/copilot-hook-entry.js +36 -0
  60. package/dist/copilot-hook.js +1000 -0
  61. package/dist/engine/detect.d.ts +2 -0
  62. package/dist/engine/detect.js +47 -0
  63. package/dist/engine/index.d.ts +4 -0
  64. package/dist/engine/index.js +90 -0
  65. package/dist/engine/policy.d.ts +2 -0
  66. package/dist/engine/policy.js +48 -0
  67. package/dist/engine/tiers/code.d.ts +7 -0
  68. package/dist/engine/tiers/code.js +206 -0
  69. package/dist/engine/tiers/logs.d.ts +4 -0
  70. package/dist/engine/tiers/logs.js +139 -0
  71. package/dist/engine/tiers/structural.d.ts +28 -0
  72. package/dist/engine/tiers/structural.js +199 -0
  73. package/dist/engine/types.d.ts +71 -0
  74. package/dist/engine/types.js +5 -0
  75. package/dist/hook/copilot.d.ts +5 -0
  76. package/dist/hook/copilot.js +136 -0
  77. package/dist/hook/core.d.ts +36 -0
  78. package/dist/hook/core.js +138 -0
  79. package/dist/hook/exit.d.ts +22 -0
  80. package/dist/hook/exit.js +56 -0
  81. package/dist/hook/post-tool-use.d.ts +5 -0
  82. package/dist/hook/post-tool-use.js +57 -0
  83. package/dist/hook-entry.d.ts +1 -0
  84. package/dist/hook-entry.js +35 -0
  85. package/dist/hook.js +946 -0
  86. package/dist/index.d.ts +15 -0
  87. package/dist/index.js +16 -0
  88. package/dist/ledger/read.d.ts +9 -0
  89. package/dist/ledger/read.js +91 -0
  90. package/dist/ledger/write.d.ts +29 -0
  91. package/dist/ledger/write.js +61 -0
  92. package/dist/packs/atoms.d.ts +3 -0
  93. package/dist/packs/atoms.js +108 -0
  94. package/dist/packs/modes.d.ts +3 -0
  95. package/dist/packs/modes.js +34 -0
  96. package/dist/packs/render.d.ts +24 -0
  97. package/dist/packs/render.js +115 -0
  98. package/dist/packs/types.d.ts +32 -0
  99. package/dist/packs/types.js +1 -0
  100. package/dist/paths.d.ts +29 -0
  101. package/dist/paths.js +87 -0
  102. package/dist/tokens/estimate.d.ts +12 -0
  103. package/dist/tokens/estimate.js +23 -0
  104. package/dist/tokens/exact.d.ts +5 -0
  105. package/dist/tokens/exact.js +16 -0
  106. package/dist/tokens/index.d.ts +2 -0
  107. package/dist/tokens/index.js +2 -0
  108. package/package.json +77 -0
@@ -0,0 +1,199 @@
1
+ import { OMISSION_MARKER } from "../types.js";
2
+ export function tierResult(before, after, id) {
3
+ if (after === before)
4
+ return { content: before };
5
+ return { content: after, transform: { id, charsSaved: before.length - after.length } };
6
+ }
7
+ const ANSI_RE = /\u001b\[[0-9;:<=>?]*[ -\/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-_]/g;
8
+ // strips remaining C0 controls and DEL, but never \t (0x09) or \n (0x0a)
9
+ const CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
10
+ export function stripAnsi(content) {
11
+ const next = content.replace(ANSI_RE, '').replace(CONTROL_RE, '');
12
+ return tierResult(content, next, 'strip-ansi');
13
+ }
14
+ const BLANK_RE = /^[ \t]*$/;
15
+ export function collapseBlankRuns(content) {
16
+ const lines = content.split('\n');
17
+ const out = [];
18
+ let run = [];
19
+ const flush = () => {
20
+ if (run.length === 0)
21
+ return;
22
+ if (run.length >= 3)
23
+ out.push('');
24
+ else
25
+ out.push(...run);
26
+ run = [];
27
+ };
28
+ for (const line of lines) {
29
+ if (BLANK_RE.test(line)) {
30
+ run.push(line);
31
+ continue;
32
+ }
33
+ flush();
34
+ out.push(line);
35
+ }
36
+ flush();
37
+ return tierResult(content, out.join('\n'), 'collapse-blank');
38
+ }
39
+ export function dedupeLines(content) {
40
+ const lines = content.split('\n');
41
+ const out = [];
42
+ let i = 0;
43
+ while (i < lines.length) {
44
+ const line = lines[i];
45
+ if (line === undefined)
46
+ break;
47
+ let run = 1;
48
+ while (i + run < lines.length && lines[i + run] === line)
49
+ run += 1;
50
+ if (run >= 3 && line.trim() !== '') {
51
+ out.push(line, `[compressor: previous line repeated ${run - 1} more times]`);
52
+ }
53
+ else {
54
+ for (let k = 0; k < run; k += 1)
55
+ out.push(line);
56
+ }
57
+ i += run;
58
+ }
59
+ return tierResult(content, out.join('\n'), 'dedupe-lines');
60
+ }
61
+ // Claude Code Read prefixes: " 123→content" (U+2192) or tab-separated variants.
62
+ const LINE_NUM_CAPTURE_RE = /^ *(\d+)(?:→|\t)/;
63
+ export function lineNumberOf(line) {
64
+ const digits = LINE_NUM_CAPTURE_RE.exec(line)?.[1];
65
+ return digits === undefined ? undefined : Number(digits);
66
+ }
67
+ // 'informative' marker style: what the omitted region contains, so the model
68
+ // can skip it or retrieve surgically instead of paginating the whole file.
69
+ const FAILURE_LINE_RE = /\b(error|fail(ed|ure)?|warn(ing)?|exception|panic|fatal)\b/i;
70
+ /**
71
+ * Scan omitted lines for error/failure/warning content. Original coordinates
72
+ * follow the same rules as the marker math: embedded Read line numbers are
73
+ * authoritative; `firstFileLine + index` is the fallback when the caller
74
+ * vouches that positions are file lines. Marker lines inserted by earlier
75
+ * tiers in this run are never counted as content.
76
+ */
77
+ export function scanFailureLines(lines, firstFileLine) {
78
+ let count = 0;
79
+ const found = [];
80
+ for (let i = 0; i < lines.length; i += 1) {
81
+ const line = lines[i];
82
+ if (line === undefined || line.includes(OMISSION_MARKER))
83
+ continue;
84
+ if (!FAILURE_LINE_RE.test(line))
85
+ continue;
86
+ count += 1;
87
+ if (found.length < 3) {
88
+ const n = lineNumberOf(line) ?? (firstFileLine === undefined ? undefined : firstFileLine + i);
89
+ if (n !== undefined)
90
+ found.push(n);
91
+ }
92
+ }
93
+ return { count, lines: found };
94
+ }
95
+ /** "L1, L2, L3 (first 3)" — the qualifier only when matches were truncated. */
96
+ export function formatMatchLines(scan) {
97
+ return scan.lines.join(', ') + (scan.count > 3 ? ' (first 3)' : '');
98
+ }
99
+ export function omissionMarker(a, b, estTokens, meta, style, omittedLines) {
100
+ const head = `[compressor: lines ${a}-${b} omitted (~${estTokens} est tokens)`;
101
+ const limit = b - a + 1;
102
+ if (meta.tool === 'read' && meta.filePath !== undefined) {
103
+ const file = meta.filePath;
104
+ if (style === 'deterrent') {
105
+ return `${head} — likely irrelevant; Read ${file} offset=${a} limit=${limit} ONLY if the problem you are chasing points into this range]`;
106
+ }
107
+ if (style === 'informative') {
108
+ const scan = scanFailureLines(omittedLines, a);
109
+ if (scan.count === 0) {
110
+ return `${head} — no error/failure/warning lines in the omitted range; safe to skip. Read ${file} offset=${a} limit=${limit} only if needed]`;
111
+ }
112
+ const nearest = scan.lines[0] ?? a;
113
+ return `${head} — ${scan.count} lines matching error/fail/warn at lines ${formatMatchLines(scan)} — Read ${file} offset=${nearest} limit=20 for the nearest match; full range offset=${a} limit=${limit}]`;
114
+ }
115
+ return `${head} — Read ${file} with offset=${a} and limit=${limit} to retrieve]`;
116
+ }
117
+ if (style === 'deterrent') {
118
+ return `${head} — likely irrelevant; re-run with a narrower filter (grep, --quiet, head) ONLY if the problem you are chasing points into this range]`;
119
+ }
120
+ if (style === 'informative') {
121
+ const scan = scanFailureLines(omittedLines, a);
122
+ if (scan.count === 0) {
123
+ return `${head} — no error/failure/warning lines in the omitted range; safe to skip. Re-run with a narrower filter (grep, --quiet, head) only if needed]`;
124
+ }
125
+ return `${head} — ${scan.count} lines matching error/fail/warn at lines ${formatMatchLines(scan)} — re-run with a narrower filter (grep, --quiet, head) to retrieve]`;
126
+ }
127
+ return `${head} — re-run with a narrower filter (grep, --quiet, head) to retrieve]`;
128
+ }
129
+ /** For omissions whose original file line range is unknown: no offset/limit claim. */
130
+ function countMarker(count, unit, estTokens, meta, style, omitted) {
131
+ const head = `[compressor: ${count} ${unit} omitted (~${estTokens} est tokens)`;
132
+ if (meta.tool === 'read' && meta.filePath !== undefined) {
133
+ const file = meta.filePath;
134
+ if (style === 'deterrent') {
135
+ return `${head} — likely irrelevant; Read ${file} ONLY if the problem you are chasing points into the omitted content]`;
136
+ }
137
+ if (style === 'informative') {
138
+ const matches = scanFailureLines(omitted.split('\n')).count;
139
+ return matches === 0
140
+ ? `${head} — no error/failure/warning lines in the omitted content; safe to skip. Read ${file} only if needed]`
141
+ : `${head} — ${matches} lines matching error/fail/warn in the omitted content — Read ${file} to retrieve]`;
142
+ }
143
+ return `${head} — Read ${file} to retrieve]`;
144
+ }
145
+ if (style === 'deterrent') {
146
+ return `${head} — likely irrelevant; re-run with a narrower filter (grep, --quiet, head) ONLY if the problem you are chasing points into the omitted content]`;
147
+ }
148
+ if (style === 'informative') {
149
+ const matches = scanFailureLines(omitted.split('\n')).count;
150
+ return matches === 0
151
+ ? `${head} — no error/failure/warning lines in the omitted content; safe to skip. Re-run with a narrower filter (grep, --quiet, head) only if needed]`
152
+ : `${head} — ${matches} lines matching error/fail/warn in the omitted content — re-run with a narrower filter (grep, --quiet, head) to retrieve]`;
153
+ }
154
+ return `${head} — re-run with a narrower filter (grep, --quiet, head) to retrieve]`;
155
+ }
156
+ /** Fallback for content too few-lined to truncate by lines (minified blobs etc.). */
157
+ function truncateChars(content, meta, policy, estimate) {
158
+ const est = estimate(content);
159
+ const ratio = policy.truncateBudget / est;
160
+ const headChars = Math.max(1, Math.floor(content.length * ratio * 0.6));
161
+ const tailChars = Math.max(1, Math.floor(content.length * ratio * 0.4));
162
+ if (headChars + tailChars >= content.length)
163
+ return { content };
164
+ const omitted = content.slice(headChars, content.length - tailChars);
165
+ const marker = countMarker(omitted.length, 'chars', estimate(omitted), meta, policy.markerStyle, omitted);
166
+ const next = `${content.slice(0, headChars)}\n${marker}\n${content.slice(content.length - tailChars)}`;
167
+ return tierResult(content, next, 'truncate');
168
+ }
169
+ export function truncateHeadTail(content, meta, policy, estimate, positionsAreFileLines = true) {
170
+ const est = estimate(content);
171
+ if (est <= policy.truncateBudget)
172
+ return { content };
173
+ const lines = content.split('\n');
174
+ const total = lines.length;
175
+ const ratio = policy.truncateBudget / est;
176
+ const keep = Math.max(2, Math.floor(total * ratio));
177
+ if (keep >= total)
178
+ return truncateChars(content, meta, policy, estimate);
179
+ const headCount = Math.max(1, Math.floor(keep * 0.6));
180
+ const tailCount = Math.max(1, keep - headCount);
181
+ const omitStart = headCount + 1;
182
+ const omitEnd = total - tailCount;
183
+ if (omitEnd < omitStart)
184
+ return { content };
185
+ const omittedLines = lines.slice(headCount, total - tailCount);
186
+ const omitted = omittedLines.join('\n');
187
+ const estOmitted = estimate(omitted);
188
+ // Embedded Read line numbers are authoritative for the file range; array
189
+ // positions are valid file lines only when no earlier tier removed lines.
190
+ const a = lineNumberOf(omittedLines[0] ?? '');
191
+ const b = lineNumberOf(omittedLines[omittedLines.length - 1] ?? '');
192
+ const marker = a !== undefined && b !== undefined && a <= b
193
+ ? omissionMarker(a, b, estOmitted, meta, policy.markerStyle, omittedLines)
194
+ : positionsAreFileLines
195
+ ? omissionMarker(omitStart, omitEnd, estOmitted, meta, policy.markerStyle, omittedLines)
196
+ : countMarker(omittedLines.length, 'lines', estOmitted, meta, policy.markerStyle, omitted);
197
+ const next = [...lines.slice(0, headCount), marker, ...lines.slice(total - tailCount)].join('\n');
198
+ return tierResult(content, next, 'truncate');
199
+ }
@@ -0,0 +1,71 @@
1
+ /** Operating mode. 'full' = no optimization anywhere. */
2
+ export type Mode = 'full' | 'optimized' | 'slim';
3
+ /** What produced the content being compressed. */
4
+ export type ToolKind = 'read' | 'bash' | 'search' | 'other';
5
+ /** Detected content kind, used to pick transforms. */
6
+ export type ContentKind = 'code' | 'test-log' | 'build-log' | 'generic';
7
+ export interface CompressMeta {
8
+ tool: ToolKind;
9
+ mode: Mode;
10
+ /** Source file path when known (drives code detection by extension). */
11
+ filePath?: string;
12
+ /**
13
+ * True when the model explicitly requested a range (Read offset/limit).
14
+ * Targeted reads always pass through untouched.
15
+ */
16
+ targeted?: boolean;
17
+ }
18
+ /**
19
+ * Token estimator injected by the caller (engine stays dependency-free and
20
+ * pure). Estimates are used ONLY for threshold decisions, never reported as
21
+ * savings.
22
+ */
23
+ export type Estimator = (text: string) => number;
24
+ /**
25
+ * Omission-marker phrasing. Measured (bench-20260610-114234): the plain
26
+ * recovery affordance invites whole-file pagination via targeted reads,
27
+ * nullifying savings on ~half of cells. 'deterrent' frames recovery as
28
+ * conditional; 'informative' additionally reports what the omitted region
29
+ * contains (failure-pattern scan) so the model can skip or retrieve
30
+ * surgically.
31
+ */
32
+ export type MarkerStyle = 'plain' | 'deterrent' | 'informative';
33
+ /** All thresholds are estimated tokens. Content below `touch` is never modified. */
34
+ export interface Policy {
35
+ structural: boolean;
36
+ codeAware: boolean;
37
+ logAware: boolean;
38
+ markerStyle: MarkerStyle;
39
+ /** below this, return input unchanged */
40
+ touch: number;
41
+ /** head/tail truncation budget for a single tool result */
42
+ truncateBudget: number;
43
+ /** strip comment-only/blank lines in code above this */
44
+ commentStrip: number;
45
+ /** skeleton view (imports + signatures) above this; Infinity = never */
46
+ skeleton: number;
47
+ /** apply test/build log filtering above this; Infinity = never */
48
+ logFilter: number;
49
+ }
50
+ export interface AppliedTransform {
51
+ /** e.g. 'strip-ansi', 'dedupe-lines', 'truncate', 'comment-strip', 'skeleton', 'log-filter' */
52
+ id: string;
53
+ charsSaved: number;
54
+ }
55
+ export interface CompressStats {
56
+ bytesIn: number;
57
+ bytesOut: number;
58
+ estTokensIn: number;
59
+ estTokensOut: number;
60
+ kind: ContentKind;
61
+ transforms: AppliedTransform[];
62
+ }
63
+ export interface CompressResult {
64
+ content: string;
65
+ stats: CompressStats;
66
+ }
67
+ /**
68
+ * Marker wrapped around every omission so the model can recover what was cut.
69
+ * Content already containing this marker is never re-compressed (idempotency).
70
+ */
71
+ export declare const OMISSION_MARKER = "[compressor:";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Marker wrapped around every omission so the model can recover what was cut.
3
+ * Content already containing this marker is never re-compressed (idempotency).
4
+ */
5
+ export const OMISSION_MARKER = '[compressor:';
@@ -0,0 +1,5 @@
1
+ import type { MarkerStyle, Mode } from '../engine/types.ts';
2
+ export interface CopilotHookResult {
3
+ output: string | null;
4
+ }
5
+ export declare function handleCopilotPostToolUse(payloadJson: string, mode: Mode, markerStyle?: MarkerStyle): CopilotHookResult;
@@ -0,0 +1,136 @@
1
+ import { compressCall, isRecord, pickLeaf, rebuildWithLeaf, recordCompression } from "./core.js";
2
+ /** Copilot CLI / cloud-agent built-in tool names → engine ToolKind. */
3
+ function toolKindFor(toolName) {
4
+ switch (toolName) {
5
+ case 'view':
6
+ return 'read';
7
+ case 'bash':
8
+ case 'powershell':
9
+ return 'bash';
10
+ case 'grep':
11
+ case 'glob':
12
+ return 'search';
13
+ default:
14
+ return 'other';
15
+ }
16
+ }
17
+ // `toolArgs` is documented only as `unknown`, so the view tool's argument
18
+ // names are sniffed liberally and fail open: no match means no filePath /
19
+ // not targeted, which only ever makes compression more conservative.
20
+ const FILE_PATH_KEYS = ['path', 'filePath', 'file_path', 'file'];
21
+ /**
22
+ * The reference page types toolArgs as `unknown`, but the CLI docs' only
23
+ * concrete payload example shows it as a JSON-ENCODED STRING
24
+ * ("toolArgs":"{\"command\":\"ls\"}"). Accept both forms: object as-is,
25
+ * string via JSON.parse. Anything else (or unparseable) falls back to {} —
26
+ * fail open: no filePath, not targeted, compression stays conservative.
27
+ */
28
+ function parseToolArgs(raw) {
29
+ if (isRecord(raw)) {
30
+ return raw;
31
+ }
32
+ if (typeof raw === 'string') {
33
+ try {
34
+ const parsed = JSON.parse(raw);
35
+ if (isRecord(parsed)) {
36
+ return parsed;
37
+ }
38
+ }
39
+ catch {
40
+ // fall through to {}
41
+ }
42
+ }
43
+ return {};
44
+ }
45
+ const RANGE_KEYS = [
46
+ 'offset',
47
+ 'limit',
48
+ 'startLine',
49
+ 'endLine',
50
+ 'start_line',
51
+ 'end_line',
52
+ 'range',
53
+ 'viewRange',
54
+ 'view_range',
55
+ ];
56
+ function filePathFrom(args) {
57
+ for (const key of FILE_PATH_KEYS) {
58
+ const value = args[key];
59
+ if (typeof value === 'string') {
60
+ return value;
61
+ }
62
+ }
63
+ return undefined;
64
+ }
65
+ function isTargeted(args) {
66
+ return RANGE_KEYS.some((key) => args[key] != null);
67
+ }
68
+ export function handleCopilotPostToolUse(payloadJson, mode, markerStyle) {
69
+ try {
70
+ if (mode === 'full') {
71
+ return { output: null };
72
+ }
73
+ const payload = JSON.parse(payloadJson);
74
+ if (!isRecord(payload)) {
75
+ return { output: null };
76
+ }
77
+ const toolName = typeof payload['toolName'] === 'string' ? payload['toolName'] : '';
78
+ const toolArgs = parseToolArgs(payload['toolArgs']);
79
+ const tool = toolKindFor(toolName);
80
+ const toolResult = payload['toolResult'];
81
+ // postToolUse is success-only; if a non-success result ever arrives,
82
+ // emitting modifiedResult (which forces resultType "success") would
83
+ // rewrite a failure into a success. Never do that.
84
+ if (isRecord(toolResult) &&
85
+ toolResult['resultType'] !== undefined &&
86
+ toolResult['resultType'] !== 'success') {
87
+ return { output: null };
88
+ }
89
+ // Documented shape: the text the model sees is toolResult.textResultForLlm.
90
+ // Unknown shapes fall back to the generic longest-string-leaf walk.
91
+ let text = null;
92
+ let genericLeaf = null;
93
+ if (isRecord(toolResult) && typeof toolResult['textResultForLlm'] === 'string') {
94
+ text = toolResult['textResultForLlm'];
95
+ }
96
+ else {
97
+ genericLeaf = pickLeaf(toolResult, tool);
98
+ if (genericLeaf === null) {
99
+ return { output: null };
100
+ }
101
+ text = genericLeaf.text;
102
+ }
103
+ const call = {
104
+ toolKind: tool,
105
+ targeted: tool === 'read' && isTargeted(toolArgs),
106
+ text,
107
+ };
108
+ const filePath = tool === 'read' ? filePathFrom(toolArgs) : undefined;
109
+ if (filePath !== undefined) {
110
+ call.filePath = filePath;
111
+ }
112
+ const compressed = compressCall(call, mode, markerStyle);
113
+ if (!compressed.worthwhile) {
114
+ return { output: null };
115
+ }
116
+ recordCompression('copilot', call, compressed, mode);
117
+ // The replacement schema carries exactly one string. Documented shape (or
118
+ // a bare-string result): the compressed text IS the replacement. Unknown
119
+ // structured shapes: rebuild the structure with only the big leaf
120
+ // rewritten (siblings preserved) and render it as JSON.
121
+ const replacement = genericLeaf === null || genericLeaf.path.length === 0
122
+ ? compressed.text
123
+ : JSON.stringify(rebuildWithLeaf(toolResult, genericLeaf.path, compressed.text));
124
+ return {
125
+ output: JSON.stringify({
126
+ modifiedResult: {
127
+ resultType: 'success',
128
+ textResultForLlm: replacement,
129
+ },
130
+ }),
131
+ };
132
+ }
133
+ catch {
134
+ return { output: null };
135
+ }
136
+ }
@@ -0,0 +1,36 @@
1
+ import type { CompressStats, MarkerStyle, Mode, ToolKind } from '../engine/types.ts';
2
+ export interface CompressibleCall {
3
+ toolKind: ToolKind;
4
+ filePath?: string;
5
+ targeted: boolean;
6
+ text: string;
7
+ }
8
+ export interface CompressedCall {
9
+ text: string;
10
+ /** false = leave the tool output alone (below floor, marker present, throw) */
11
+ worthwhile: boolean;
12
+ /** engine stats for the worthwhile case (ledger needs tokens/transforms) */
13
+ stats?: CompressStats;
14
+ }
15
+ export declare function compressCall(call: CompressibleCall, mode: Mode, markerStyle?: MarkerStyle): CompressedCall;
16
+ /**
17
+ * Fire-and-forget ledger entry for a worthwhile compression. Called by the
18
+ * protocol layers (they know which agent they serve). Never awaited on the
19
+ * hot path; the hook entries settle pending writes (capped at 250ms) before
20
+ * exiting. Privacy: sizes and transform ids only — no paths, no content.
21
+ */
22
+ export declare function recordCompression(agent: 'claude-code' | 'copilot', call: CompressibleCall, compressed: CompressedCall, mode: Mode): void;
23
+ export type LeafPath = ReadonlyArray<string | number>;
24
+ export interface Leaf {
25
+ path: LeafPath;
26
+ text: string;
27
+ }
28
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
29
+ /**
30
+ * Find the single string worth compressing in a tool response of unknown
31
+ * shape: a bare string directly, a bash stdout field when present, otherwise
32
+ * the longest string leaf anywhere in the structure.
33
+ */
34
+ export declare function pickLeaf(toolResponse: unknown, tool: ToolKind): Leaf | null;
35
+ /** Shape-preserving rewrite: clone the response with only the leaf replaced. */
36
+ export declare function rebuildWithLeaf(toolResponse: unknown, path: LeafPath, text: string): unknown;
@@ -0,0 +1,138 @@
1
+ import { OMISSION_MARKER } from "../engine/types.js";
2
+ import { compress, policyFor } from "../engine/index.js";
3
+ import { cheapEstimator } from "../tokens/estimate.js";
4
+ import { appendLedger } from "../ledger/write.js";
5
+ /** Below either floor the rewrite is noise: don't churn the context. */
6
+ const MIN_SAVED_CHARS = 200;
7
+ const MIN_SAVED_RATIO = 0.1;
8
+ /**
9
+ * Length of the compressed output EXCLUDING marker lines, mirroring the
10
+ * engine's decide() filter (engine/index.ts). The floors must be measured
11
+ * against content only: marker text is the marker-style experiment's
12
+ * treatment (informative/deterrent markers run ~50-120 chars longer than
13
+ * plain, multiplied by one marker per skeleton gap), so a marker-inclusive
14
+ * `saved` lets arms flip between compressed and full passthrough near either
15
+ * floor — the arms would then differ in WHAT the model sees, not just in
16
+ * marker phrasing, and the treatment marker would be absent exactly when
17
+ * phrasing is being compared.
18
+ */
19
+ function lengthSansMarkers(text) {
20
+ if (!text.includes(OMISSION_MARKER)) {
21
+ return text.length;
22
+ }
23
+ return text
24
+ .split('\n')
25
+ .filter((line) => !line.includes(OMISSION_MARKER))
26
+ .join('\n').length;
27
+ }
28
+ export function compressCall(call, mode, markerStyle) {
29
+ try {
30
+ const meta = { tool: call.toolKind, mode, targeted: call.targeted };
31
+ if (call.filePath !== undefined) {
32
+ meta.filePath = call.filePath;
33
+ }
34
+ const base = policyFor(mode);
35
+ const policy = markerStyle === undefined ? base : { ...base, markerStyle };
36
+ const result = compress(call.text, meta, policy, cheapEstimator);
37
+ // marker-stripped so worthwhileness is style-invariant (see above)
38
+ const saved = call.text.length - lengthSansMarkers(result.content);
39
+ if (saved < MIN_SAVED_CHARS || saved < call.text.length * MIN_SAVED_RATIO) {
40
+ return { text: call.text, worthwhile: false };
41
+ }
42
+ return { text: result.content, worthwhile: true, stats: result.stats };
43
+ }
44
+ catch {
45
+ // FAIL-OPEN: a broken hook must never break the user's agent.
46
+ return { text: call.text, worthwhile: false };
47
+ }
48
+ }
49
+ /**
50
+ * Fire-and-forget ledger entry for a worthwhile compression. Called by the
51
+ * protocol layers (they know which agent they serve). Never awaited on the
52
+ * hot path; the hook entries settle pending writes (capped at 250ms) before
53
+ * exiting. Privacy: sizes and transform ids only — no paths, no content.
54
+ */
55
+ export function recordCompression(agent, call, compressed, mode) {
56
+ try {
57
+ if (!compressed.worthwhile) {
58
+ return;
59
+ }
60
+ void appendLedger({
61
+ ts: new Date().toISOString(),
62
+ agent,
63
+ tool: call.toolKind,
64
+ mode,
65
+ charsIn: call.text.length,
66
+ charsOut: compressed.text.length,
67
+ estTokensIn: compressed.stats?.estTokensIn ?? cheapEstimator(call.text),
68
+ estTokensOut: compressed.stats?.estTokensOut ?? cheapEstimator(compressed.text),
69
+ transforms: compressed.stats?.transforms.map((t) => t.id) ?? [],
70
+ }).catch(() => { });
71
+ }
72
+ catch {
73
+ // FAIL-OPEN: the ledger must never break the hook.
74
+ }
75
+ }
76
+ export function isRecord(value) {
77
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
78
+ }
79
+ function longestStringLeaf(value, path, best) {
80
+ if (typeof value === 'string') {
81
+ return best === null || value.length > best.text.length ? { path, text: value } : best;
82
+ }
83
+ if (Array.isArray(value)) {
84
+ return value.reduce((acc, item, i) => longestStringLeaf(item, [...path, i], acc), best);
85
+ }
86
+ if (isRecord(value)) {
87
+ return Object.entries(value).reduce((acc, [key, item]) => longestStringLeaf(item, [...path, key], acc), best);
88
+ }
89
+ return best;
90
+ }
91
+ /**
92
+ * Find the single string worth compressing in a tool response of unknown
93
+ * shape: a bare string directly, a bash stdout field when present, otherwise
94
+ * the longest string leaf anywhere in the structure.
95
+ */
96
+ export function pickLeaf(toolResponse, tool) {
97
+ if (typeof toolResponse === 'string') {
98
+ return { path: [], text: toolResponse };
99
+ }
100
+ if (tool === 'bash' && isRecord(toolResponse) && typeof toolResponse['stdout'] === 'string') {
101
+ return { path: ['stdout'], text: toolResponse['stdout'] };
102
+ }
103
+ if (isRecord(toolResponse) || Array.isArray(toolResponse)) {
104
+ return longestStringLeaf(toolResponse, [], null);
105
+ }
106
+ return null;
107
+ }
108
+ /** Shape-preserving rewrite: clone the response with only the leaf replaced. */
109
+ export function rebuildWithLeaf(toolResponse, path, text) {
110
+ if (path.length === 0) {
111
+ return text;
112
+ }
113
+ const clone = structuredClone(toolResponse);
114
+ let cursor = clone;
115
+ for (let i = 0; i < path.length - 1; i += 1) {
116
+ const key = path[i];
117
+ if (Array.isArray(cursor) && typeof key === 'number') {
118
+ cursor = cursor[key];
119
+ }
120
+ else if (isRecord(cursor) && typeof key === 'string') {
121
+ cursor = cursor[key];
122
+ }
123
+ else {
124
+ throw new Error('leaf path mismatch');
125
+ }
126
+ }
127
+ const last = path[path.length - 1];
128
+ if (Array.isArray(cursor) && typeof last === 'number') {
129
+ cursor[last] = text;
130
+ }
131
+ else if (isRecord(cursor) && typeof last === 'string') {
132
+ cursor[last] = text;
133
+ }
134
+ else {
135
+ throw new Error('leaf path mismatch');
136
+ }
137
+ return clone;
138
+ }
@@ -0,0 +1,22 @@
1
+ /** Hard cap on how long pending ledger appends may delay hook exit. */
2
+ export declare const SETTLE_CAP_MS = 250;
3
+ /**
4
+ * Deliver hook output, give in-flight ledger appends up to SETTLE_CAP_MS to
5
+ * flush, then guarantee process termination. Shared by both bundled hook
6
+ * entries and the CLI `hook` subcommands — every protocol surface gets the
7
+ * same hot-path bound. Never throws (fail-open).
8
+ *
9
+ * Order matters: stdout is written BEFORE the settle race, so marker delivery
10
+ * never serializes behind ledger filesystem latency.
11
+ *
12
+ * The cap alone only bounds the JS await. On modern Node (>= ~22),
13
+ * process.exit() performs a clean shutdown that joins the libuv threadpool;
14
+ * an appendFile blocked in open(2) (hung NFS/SMB home dir, dead FUSE mount,
15
+ * reader-less FIFO) never returns, so that join never completes and the
16
+ * process lives forever — stalling the agent for its hook timeout, or
17
+ * indefinitely on hosts without one. When the race times out we therefore
18
+ * terminate with SIGKILL: kernel-level, bypasses the threadpool join. The
19
+ * resulting non-zero exit makes the host ignore this call's stdout, which is
20
+ * still fail-open — the original tool output passes through unmodified.
21
+ */
22
+ export declare function settleThenExit(output: string | null): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import process from 'node:process';
2
+ import { settleLedger } from "../ledger/write.js";
3
+ /** Hard cap on how long pending ledger appends may delay hook exit. */
4
+ export const SETTLE_CAP_MS = 250;
5
+ /**
6
+ * Deliver hook output, give in-flight ledger appends up to SETTLE_CAP_MS to
7
+ * flush, then guarantee process termination. Shared by both bundled hook
8
+ * entries and the CLI `hook` subcommands — every protocol surface gets the
9
+ * same hot-path bound. Never throws (fail-open).
10
+ *
11
+ * Order matters: stdout is written BEFORE the settle race, so marker delivery
12
+ * never serializes behind ledger filesystem latency.
13
+ *
14
+ * The cap alone only bounds the JS await. On modern Node (>= ~22),
15
+ * process.exit() performs a clean shutdown that joins the libuv threadpool;
16
+ * an appendFile blocked in open(2) (hung NFS/SMB home dir, dead FUSE mount,
17
+ * reader-less FIFO) never returns, so that join never completes and the
18
+ * process lives forever — stalling the agent for its hook timeout, or
19
+ * indefinitely on hosts without one. When the race times out we therefore
20
+ * terminate with SIGKILL: kernel-level, bypasses the threadpool join. The
21
+ * resulting non-zero exit makes the host ignore this call's stdout, which is
22
+ * still fail-open — the original tool output passes through unmodified.
23
+ */
24
+ export async function settleThenExit(output) {
25
+ try {
26
+ if (output !== null) {
27
+ await new Promise((resolve) => {
28
+ process.stdout.write(output, () => resolve());
29
+ });
30
+ }
31
+ }
32
+ catch {
33
+ // fail-open: EPIPE etc. — still settle the ledger and exit below
34
+ }
35
+ // default true: if anything below throws, take the kernel-level exit
36
+ let timedOut = true;
37
+ try {
38
+ let timer;
39
+ timedOut = await Promise.race([
40
+ settleLedger().then(() => false),
41
+ new Promise((resolve) => {
42
+ timer = setTimeout(() => resolve(true), SETTLE_CAP_MS);
43
+ }),
44
+ ]);
45
+ if (timer !== undefined) {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ catch {
50
+ timedOut = true;
51
+ }
52
+ if (timedOut) {
53
+ process.kill(process.pid, 'SIGKILL');
54
+ }
55
+ process.exit(0);
56
+ }