howtofixcli 0.7.23 → 0.7.25

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 (167) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/acp.js +234 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +250 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +121 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +381 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/connect-prompt.js +22 -0
  24. package/dist/context-extension.js +17 -0
  25. package/dist/dcp/commands.js +172 -0
  26. package/dist/dcp/compress-state.js +139 -0
  27. package/dist/dcp/compress.js +329 -0
  28. package/dist/dcp/dcp-extension.js +295 -0
  29. package/dist/dcp/dedup.js +93 -0
  30. package/dist/dcp/ids.js +189 -0
  31. package/dist/dcp/index.js +25 -0
  32. package/dist/dcp/middle-prune.js +76 -0
  33. package/dist/dcp/prompts.js +68 -0
  34. package/dist/dcp/protected-content.js +221 -0
  35. package/dist/dcp/protected.js +161 -0
  36. package/dist/dcp/prune.js +418 -0
  37. package/dist/dcp/state.js +176 -0
  38. package/dist/errors.js +63 -0
  39. package/dist/extensions.js +48 -0
  40. package/dist/fatal.js +35 -0
  41. package/dist/feed.js +498 -0
  42. package/dist/fs-atomic.js +54 -0
  43. package/dist/gateway-errors.js +107 -0
  44. package/dist/git.js +230 -0
  45. package/dist/goal-extension.js +88 -0
  46. package/dist/goal.js +222 -0
  47. package/dist/hooks-extension.js +176 -0
  48. package/dist/hooks.js +443 -0
  49. package/dist/howtofix-account.js +834 -0
  50. package/dist/hud-line.js +158 -0
  51. package/dist/hwid.js +130 -0
  52. package/dist/interrupt.js +60 -0
  53. package/dist/json-events.js +50 -0
  54. package/dist/level-style.js +112 -0
  55. package/dist/loop-guard.js +95 -0
  56. package/dist/lsp-tools.js +32 -0
  57. package/dist/lsp.js +429 -0
  58. package/dist/main.js +573 -98
  59. package/dist/mcp-config.js +85 -0
  60. package/dist/mcp-lifecycle.js +147 -0
  61. package/dist/model-catalog.js +217 -0
  62. package/dist/model-facts.js +35 -0
  63. package/dist/model-resolve.js +65 -0
  64. package/dist/notes.js +78 -0
  65. package/dist/oauth-login.js +568 -0
  66. package/dist/paths.js +61 -0
  67. package/dist/permissions.js +298 -0
  68. package/dist/plan-mode.js +363 -0
  69. package/dist/preset-command.js +227 -0
  70. package/dist/preset-extension.js +35 -0
  71. package/dist/preset-mcp.js +292 -0
  72. package/dist/presets/mcp/rea/preset.json +28 -0
  73. package/dist/presets/mcp/rea/prompt.md +5 -0
  74. package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
  75. package/dist/presets/mcp/remotion/preset.json +27 -0
  76. package/dist/presets/mcp/remotion/prompt.md +7 -0
  77. package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
  78. package/dist/presets.js +727 -0
  79. package/dist/prompt-files.js +103 -0
  80. package/dist/prompt-text.js +41 -0
  81. package/dist/proxy.js +137 -0
  82. package/dist/quality/ast.js +196 -0
  83. package/dist/quality/cognitive.js +83 -0
  84. package/dist/quality/cyclomatic.js +30 -0
  85. package/dist/quality/halstead.js +83 -0
  86. package/dist/quality/index.js +18 -0
  87. package/dist/quality/length.js +3 -0
  88. package/dist/quality/maintainability.js +15 -0
  89. package/dist/quality/nesting.js +57 -0
  90. package/dist/quality/report.js +803 -0
  91. package/dist/quality/smells.js +67 -0
  92. package/dist/quality-tool.js +63 -0
  93. package/dist/read-only-commands.js +597 -0
  94. package/dist/repo-map.js +292 -0
  95. package/dist/roblox-connect.js +125 -0
  96. package/dist/rpc.js +91 -0
  97. package/dist/rtk.js +193 -0
  98. package/dist/sandbox.js +105 -0
  99. package/dist/schema.js +214 -0
  100. package/dist/serve.js +50 -0
  101. package/dist/session-lifecycle.js +19 -0
  102. package/dist/session-retention.js +111 -0
  103. package/dist/session.js +460 -0
  104. package/dist/sessions.js +100 -0
  105. package/dist/settings.js +148 -0
  106. package/dist/skills.js +35 -0
  107. package/dist/slop-tool.js +96 -0
  108. package/dist/spend-cap.js +43 -0
  109. package/dist/subagent.js +171 -0
  110. package/dist/tar.js +146 -0
  111. package/dist/theme.js +79 -0
  112. package/dist/thinking.js +45 -0
  113. package/dist/todo.js +114 -0
  114. package/dist/tool-loadout.js +102 -0
  115. package/dist/tool-render.js +769 -0
  116. package/dist/tools.js +176 -0
  117. package/dist/ui.js +4930 -0
  118. package/dist/update.js +551 -0
  119. package/dist/usage-segment.js +74 -0
  120. package/dist/web-tools.js +46 -0
  121. package/dist/web.js +265 -0
  122. package/dist/welcome.js +179 -0
  123. package/dist/workflow-engine.js +246 -0
  124. package/dist/workflow-progress.js +446 -0
  125. package/dist/workflow-view.js +195 -0
  126. package/dist/workflow.js +349 -0
  127. package/package.json +62 -15
  128. package/dist/chunk-27TRKD65.js +0 -2
  129. package/dist/chunk-3FYCFLNK.js +0 -1
  130. package/dist/chunk-3LZR7OEU.js +0 -1
  131. package/dist/chunk-3XL2557N.js +0 -6
  132. package/dist/chunk-56SEAGAQ.js +0 -1
  133. package/dist/chunk-7CHDNTH6.js +0 -1
  134. package/dist/chunk-7QU425F4.js +0 -1
  135. package/dist/chunk-AGGIYXN7.js +0 -2
  136. package/dist/chunk-AJJCVTO4.js +0 -2
  137. package/dist/chunk-AUDMRGWF.js +0 -189
  138. package/dist/chunk-DAPXGSGD.js +0 -12
  139. package/dist/chunk-DL5PTYAO.js +0 -5
  140. package/dist/chunk-DOD7MUGX.js +0 -1
  141. package/dist/chunk-FPXVDP32.js +0 -8
  142. package/dist/chunk-GO2QIQ5P.js +0 -1
  143. package/dist/chunk-GQHBNOPS.js +0 -2
  144. package/dist/chunk-GXB7RZHH.js +0 -1
  145. package/dist/chunk-I6CAUN2V.js +0 -1
  146. package/dist/chunk-ILDDDLRC.js +0 -1
  147. package/dist/chunk-JCBRLX44.js +0 -2
  148. package/dist/chunk-JFYVRWJI.js +0 -2
  149. package/dist/chunk-JX4PCVYT.js +0 -34
  150. package/dist/chunk-LAHVHRCS.js +0 -2
  151. package/dist/chunk-LSJC73DG.js +0 -1
  152. package/dist/chunk-MQVP243W.js +0 -117
  153. package/dist/chunk-N345KWWK.js +0 -1
  154. package/dist/chunk-ONZLNFQ2.js +0 -3
  155. package/dist/chunk-ORJ2QZBG.js +0 -3
  156. package/dist/chunk-P6WSWRXK.js +0 -5
  157. package/dist/chunk-PCALBZOO.js +0 -2
  158. package/dist/chunk-TSEKPGK4.js +0 -1
  159. package/dist/chunk-UTYFLLSM.js +0 -1
  160. package/dist/chunk-V3R4FYTJ.js +0 -1
  161. package/dist/chunk-V656XVXV.js +0 -8
  162. package/dist/chunk-WKUJ7CFO.js +0 -1
  163. package/dist/chunk-WOT6VMZA.js +0 -1
  164. package/dist/chunk-WUPU6LTD.js +0 -4
  165. package/dist/chunk-WX3V5P7U.js +0 -5
  166. package/dist/chunk-YJSES7RK.js +0 -1
  167. package/dist/chunk-YW7NORH3.js +0 -2
@@ -0,0 +1,418 @@
1
+ /**
2
+ * The prune transform: replaces stale context with fixed placeholders in the
3
+ * copy of the transcript that is sent to the LLM. The session itself is never
4
+ * touched — every function here is pure over a message array and returns new
5
+ * messages.
6
+ *
7
+ * Placeholder constants are verbatim from Opencode-DCP `lib/messages/prune.ts`,
8
+ * and the three appliers (`pruneToolOutputs` / `pruneToolInputs` /
9
+ * `pruneToolErrors`) plus `filterCompressedRanges` port DCP's mechanics to the harness's
10
+ * message shape: outputs live on `toolResult` messages, inputs on the
11
+ * `toolCall` parts inside assistant messages. On top of that sits `planPrune`,
12
+ * this repo's keep-set-aware planner (spec §3.4): three rules, each op carrying
13
+ * the reason it was planned.
14
+ *
15
+ * Hard exceptions, never planned or applied onto:
16
+ * - the `todo` / `goal` / `context` / `task` tools (HARD_EXCEPTION_TOOLS)
17
+ * - the plan file's results
18
+ * - user messages, assistant text/thinking (appliers only touch toolCall args)
19
+ * - the message at array index 0, whatever it says
20
+ *
21
+ * Safety rule: when the keep-set is empty, rule 3 (big unrelated output) is
22
+ * OFF — structural pruning (rules 1 and 2, dedup, compressed ranges) still
23
+ * runs, but nothing is dropped for being unrelated to a goal that does not
24
+ * exist.
25
+ *
26
+ * Pipeline order (spec §3.4): filterCompressedRanges → pruneToolOutputs →
27
+ * pruneToolInputs → pruneToolErrors.
28
+ */
29
+ import { refIndicesOf } from "./ids.js";
30
+ import { HARD_EXCEPTION_TOOLS, getFilePathsFromParameters, normalizePath } from "./protected.js";
31
+ import { createToolSignature } from "./dedup.js";
32
+ export const PRUNED_TOOL_OUTPUT_REPLACEMENT = "[Output removed to save context - information superseded or no longer needed]";
33
+ export const PRUNED_TOOL_ERROR_INPUT_REPLACEMENT = "[input removed due to failed tool call]";
34
+ export const PRUNED_QUESTION_INPUT_REPLACEMENT = "[questions removed - see output for user's answers]";
35
+ /** Rule 3 threshold: outputs longer than this may be pruned when unrelated. */
36
+ export const BIG_OUTPUT_CHARS = 2000;
37
+ /** Tools that ask the user questions: their output holds the answers, so only
38
+ * their input is ever pruned (with PRUNED_QUESTION_INPUT_REPLACEMENT). */
39
+ export const QUESTION_TOOLS = ["ask", "question"];
40
+ /** Tools whose results feed rule 1 (superseded file reads). */
41
+ const READ_TOOLS = ["read", "grep", "ls"];
42
+ /** Tools whose success supersedes an earlier read of the same path (rule 1). */
43
+ const MUTATING_TOOLS = ["edit", "write", "multiedit", "apply_patch", "patch"];
44
+ function textOfResult(content) {
45
+ return content
46
+ .filter((part) => part.type === "text")
47
+ .map((part) => part.text)
48
+ .join("");
49
+ }
50
+ function stringArgTotal(args) {
51
+ if (!args)
52
+ return { total: 0, count: 0 };
53
+ let total = 0;
54
+ let count = 0;
55
+ for (const value of Object.values(args)) {
56
+ if (typeof value === "string") {
57
+ total += value.length;
58
+ count++;
59
+ }
60
+ }
61
+ return { total, count };
62
+ }
63
+ function escapeRegExp(value) {
64
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
65
+ }
66
+ /**
67
+ * Plan the prune: returns which tool calls to replace and why, without
68
+ * touching anything. Apply the result with `applyPrune`.
69
+ */
70
+ export function planPrune(messages, keep, options) {
71
+ const bigOutputChars = options?.bigOutputChars ?? BIG_OUTPUT_CHARS;
72
+ const calls = new Map();
73
+ const results = [];
74
+ for (let index = 0; index < messages.length; index++) {
75
+ const message = messages[index];
76
+ if (message.role === "assistant") {
77
+ for (const part of message.content) {
78
+ if (part.type === "toolCall") {
79
+ calls.set(part.id, { name: part.name, args: part.arguments, callIndex: index });
80
+ }
81
+ }
82
+ }
83
+ else if (message.role === "toolResult") {
84
+ const call = calls.get(message.toolCallId);
85
+ const tool = message.toolName;
86
+ results.push({
87
+ index,
88
+ callId: message.toolCallId,
89
+ tool,
90
+ isError: message.isError,
91
+ text: textOfResult(message.content),
92
+ paths: call ? getFilePathsFromParameters(tool, call.args) : [],
93
+ signature: createToolSignature(tool, call?.args),
94
+ callIndex: call?.callIndex ?? -1,
95
+ });
96
+ }
97
+ }
98
+ const planFile = keep.planFile ? normalizePath(keep.planFile) : undefined;
99
+ const isOutputExempt = (r) => r.index === 0 || HARD_EXCEPTION_TOOLS.includes(r.tool) || (planFile !== undefined && r.paths.some((p) => normalizePath(p) === planFile));
100
+ const isInputExempt = (r) => r.callIndex < 0 || r.callIndex === 0 || HARD_EXCEPTION_TOOLS.includes(r.tool) || (planFile !== undefined && r.paths.some((p) => normalizePath(p) === planFile));
101
+ const ops = [];
102
+ const emitted = new Set();
103
+ const emit = (op) => {
104
+ const key = `${op.kind}:${op.callId}`;
105
+ if (emitted.has(key))
106
+ return;
107
+ emitted.add(key);
108
+ ops.push(op);
109
+ };
110
+ // Rule 1 — a read/grep/ls result whose file was later edited or re-read.
111
+ // The newest result for the path stays; every older read is superseded.
112
+ const latestByPath = new Map();
113
+ for (const r of results) {
114
+ if (r.isError || (!READ_TOOLS.includes(r.tool) && !MUTATING_TOOLS.includes(r.tool)))
115
+ continue;
116
+ for (const path of r.paths) {
117
+ const key = normalizePath(path);
118
+ const previous = latestByPath.get(key);
119
+ if (previous === undefined || r.index > previous)
120
+ latestByPath.set(key, r.index);
121
+ }
122
+ }
123
+ for (const r of results) {
124
+ if (r.isError || !READ_TOOLS.includes(r.tool) || isOutputExempt(r))
125
+ continue;
126
+ // Already pruned: the paths are still attached, so without this the read
127
+ // looks superseded on every pass and the plan is never empty. Skipping it
128
+ // is what makes a second pass over a pruned transcript a no-op.
129
+ if (r.text === PRUNED_TOOL_OUTPUT_REPLACEMENT)
130
+ continue;
131
+ const superseded = r.paths.some((path) => (latestByPath.get(normalizePath(path)) ?? -1) > r.index);
132
+ if (superseded) {
133
+ emit({
134
+ kind: "output",
135
+ reason: "superseded-read",
136
+ callId: r.callId,
137
+ messageIndex: r.index,
138
+ savedChars: Math.max(0, r.text.length - PRUNED_TOOL_OUTPUT_REPLACEMENT.length),
139
+ });
140
+ }
141
+ }
142
+ // Rule 2 — an error result followed by an identical successful call: the
143
+ // error output is superseded, and the failed call's arguments (the most
144
+ // expensive part — a rejected patch) are useless now that the retry worked.
145
+ const latestSuccessBySignature = new Map();
146
+ for (const r of results) {
147
+ if (r.isError)
148
+ continue;
149
+ const previous = latestSuccessBySignature.get(r.signature);
150
+ if (previous === undefined || r.index > previous)
151
+ latestSuccessBySignature.set(r.signature, r.index);
152
+ }
153
+ for (const r of results) {
154
+ if (!r.isError || isOutputExempt(r))
155
+ continue;
156
+ const success = latestSuccessBySignature.get(r.signature);
157
+ if (success === undefined || success <= r.index)
158
+ continue;
159
+ emit({
160
+ kind: "output",
161
+ reason: "error-then-success",
162
+ callId: r.callId,
163
+ messageIndex: r.index,
164
+ savedChars: Math.max(0, r.text.length - PRUNED_TOOL_OUTPUT_REPLACEMENT.length),
165
+ });
166
+ if (!isInputExempt(r)) {
167
+ const args = calls.get(r.callId)?.args;
168
+ const { total, count } = stringArgTotal(args);
169
+ if (count > 0) {
170
+ emit({
171
+ kind: "failed-input",
172
+ reason: "error-then-success",
173
+ callId: r.callId,
174
+ messageIndex: r.callIndex,
175
+ savedChars: Math.max(0, total - count * PRUNED_TOOL_ERROR_INPUT_REPLACEMENT.length),
176
+ });
177
+ }
178
+ }
179
+ }
180
+ // Rule 3 — an output over the threshold that does not touch the keep-set.
181
+ // OFF when the keep-set is empty: nothing is dropped for being unrelated to
182
+ // a goal that does not exist.
183
+ if (keep.files.length > 0 || keep.identifiers.length > 0) {
184
+ const touchFiles = [...keep.files.map(normalizePath), ...(planFile ? [planFile] : [])];
185
+ const basenames = touchFiles.map((file) => file.split("/").pop() ?? "").filter((name) => name.length > 0);
186
+ const identifierPatterns = keep.identifiers.map((identifier) => new RegExp(`(?<![\\w.-])${escapeRegExp(identifier)}(?![\\w.-])`));
187
+ const touchesKeep = (text) => {
188
+ const normalized = normalizePath(text);
189
+ if (touchFiles.some((file) => normalized.includes(file)))
190
+ return true;
191
+ if (basenames.some((name) => text.includes(name)))
192
+ return true;
193
+ return identifierPatterns.some((pattern) => pattern.test(text));
194
+ };
195
+ for (const r of results) {
196
+ if (isOutputExempt(r) || QUESTION_TOOLS.includes(r.tool))
197
+ continue;
198
+ if (r.text.length <= bigOutputChars)
199
+ continue;
200
+ if (touchesKeep(r.text))
201
+ continue;
202
+ emit({
203
+ kind: "output",
204
+ reason: "unrelated-big-output",
205
+ callId: r.callId,
206
+ messageIndex: r.index,
207
+ savedChars: Math.max(0, r.text.length - PRUNED_TOOL_OUTPUT_REPLACEMENT.length),
208
+ });
209
+ }
210
+ }
211
+ let savedChars = 0;
212
+ for (const op of ops)
213
+ savedChars += op.savedChars;
214
+ return { ops, savedChars };
215
+ }
216
+ /** Apply planned ops to a copy of the transcript, in DCP's order:
217
+ * outputs → question inputs → failed inputs. Pure; input is never mutated. */
218
+ export function applyPrune(messages, ops) {
219
+ const outputs = new Set();
220
+ const questions = new Set();
221
+ const failures = new Set();
222
+ for (const op of ops) {
223
+ if (op.kind === "output")
224
+ outputs.add(op.callId);
225
+ else if (op.kind === "question-input")
226
+ questions.add(op.callId);
227
+ else
228
+ failures.add(op.callId);
229
+ }
230
+ return pruneToolErrors(pruneToolInputs(pruneToolOutputs(messages, outputs), questions), failures);
231
+ }
232
+ /** Replace the toolResult content of every marked call with the output
233
+ placeholder. Question tools are skipped — their answers live in the output
234
+ and their input is pruned instead. Never touches array index 0. */
235
+ export function pruneToolOutputs(messages, marks) {
236
+ if (marks.size === 0)
237
+ return [...messages];
238
+ return messages.map((message, index) => {
239
+ if (index === 0 || message.role !== "toolResult" || !marks.has(message.toolCallId))
240
+ return message;
241
+ if (QUESTION_TOOLS.includes(message.toolName))
242
+ return message;
243
+ return { ...message, content: [{ type: "text", text: PRUNED_TOOL_OUTPUT_REPLACEMENT }] };
244
+ });
245
+ }
246
+ /** For marked, successfully answered question tools (ask/question), replace
247
+ the question-bearing input fields — the answers are in the output. */
248
+ export function pruneToolInputs(messages, marks) {
249
+ if (marks.size === 0)
250
+ return [...messages];
251
+ const answered = new Set();
252
+ for (const message of messages) {
253
+ if (message.role === "toolResult" && !message.isError && marks.has(message.toolCallId)) {
254
+ answered.add(message.toolCallId);
255
+ }
256
+ }
257
+ if (answered.size === 0)
258
+ return [...messages];
259
+ return messages.map((message, index) => {
260
+ if (index === 0 || message.role !== "assistant")
261
+ return message;
262
+ let changed = false;
263
+ const content = message.content.map((part) => {
264
+ if (part.type !== "toolCall" || !answered.has(part.id) || !QUESTION_TOOLS.includes(part.name))
265
+ return part;
266
+ const args = { ...part.arguments };
267
+ let touched = false;
268
+ for (const field of ["question", "questions", "options"]) {
269
+ if (field in args) {
270
+ args[field] = PRUNED_QUESTION_INPUT_REPLACEMENT;
271
+ touched = true;
272
+ }
273
+ }
274
+ if (!touched)
275
+ return part;
276
+ changed = true;
277
+ return { ...part, arguments: args };
278
+ });
279
+ return changed ? { ...message, content } : message;
280
+ });
281
+ }
282
+ /** For marked calls whose tool result is an error, replace every string
283
+ argument with the failed-call placeholder — a rejected patch's arguments are
284
+ the most expensive dead weight in the transcript. */
285
+ export function pruneToolErrors(messages, marks) {
286
+ if (marks.size === 0)
287
+ return [...messages];
288
+ const failed = new Set();
289
+ for (const message of messages) {
290
+ if (message.role === "toolResult" && message.isError && marks.has(message.toolCallId)) {
291
+ failed.add(message.toolCallId);
292
+ }
293
+ }
294
+ if (failed.size === 0)
295
+ return [...messages];
296
+ return messages.map((message, index) => {
297
+ if (index === 0 || message.role !== "assistant")
298
+ return message;
299
+ let changed = false;
300
+ const content = message.content.map((part) => {
301
+ if (part.type !== "toolCall" || !failed.has(part.id))
302
+ return part;
303
+ const args = {};
304
+ let hasString = false;
305
+ for (const [key, value] of Object.entries(part.arguments)) {
306
+ if (typeof value === "string") {
307
+ args[key] = PRUNED_TOOL_ERROR_INPUT_REPLACEMENT;
308
+ hasString = true;
309
+ }
310
+ else {
311
+ args[key] = value;
312
+ }
313
+ }
314
+ if (!hasString)
315
+ return part;
316
+ changed = true;
317
+ return { ...part, arguments: args };
318
+ });
319
+ return changed ? { ...message, content } : message;
320
+ });
321
+ }
322
+ /** Drop every message covered by an active compressed range and insert the
323
+ block's summary (a synthetic user message) at the anchor position. Unref'd
324
+ positions — the system message above all — can never be covered. */
325
+ export function filterCompressedRanges(messages, ranges, state) {
326
+ if (ranges.length === 0)
327
+ return [...messages];
328
+ const refs = refIndicesOf(state, messages);
329
+ const posByRef = new Map();
330
+ for (let pos = 0; pos < refs.length; pos++) {
331
+ const ref = refs[pos];
332
+ if (ref !== null)
333
+ posByRef.set(ref, pos);
334
+ }
335
+ const covered = new Set();
336
+ const summaries = new Map();
337
+ for (const range of ranges) {
338
+ const positions = [];
339
+ for (const coveredIndex of range.coveredIndices) {
340
+ const pos = posByRef.get(coveredIndex);
341
+ if (pos !== undefined) {
342
+ covered.add(pos);
343
+ positions.push(pos);
344
+ }
345
+ }
346
+ const anchorPos = posByRef.get(range.anchorIndex) ?? (positions.length > 0 ? Math.min(...positions) : undefined);
347
+ if (range.summary && anchorPos !== undefined) {
348
+ const existing = summaries.get(anchorPos);
349
+ if (existing)
350
+ existing.push(range.summary);
351
+ else
352
+ summaries.set(anchorPos, [range.summary]);
353
+ }
354
+ }
355
+ // A tool call and its result are one exchange, and a provider rejects a
356
+ // transcript where they are separated: an assistant message calling a tool
357
+ // whose result is absent is an invalid request, and that is what the 400 from
358
+ // the provider was. So the covered set is closed over exchanges first - if
359
+ // either half is covered, both are - before anything is dropped.
360
+ //
361
+ // Nothing before this noticed because every fixture compressed whole
362
+ // exchanges; a range boundary landing between a call and its result is what
363
+ // a real model produces.
364
+ const callIdByPosition = new Map();
365
+ const resultPositionByCallId = new Map();
366
+ for (let position = 0; position < messages.length; position++) {
367
+ const message = messages[position];
368
+ if (!message)
369
+ continue;
370
+ if (message.role === "assistant" && Array.isArray(message.content)) {
371
+ for (const part of message.content) {
372
+ if (part.type === "toolCall")
373
+ callIdByPosition.set(position, part.id);
374
+ }
375
+ }
376
+ else if (message.role === "toolResult") {
377
+ resultPositionByCallId.set(message.toolCallId, position);
378
+ }
379
+ }
380
+ // One pass over a worklist rather than nested scans: a position pulled in can
381
+ // pull in another exchange behind it.
382
+ const queue = [...covered];
383
+ while (queue.length > 0) {
384
+ const position = queue.pop();
385
+ const callId = callIdByPosition.get(position);
386
+ if (callId !== undefined) {
387
+ const resultPosition = resultPositionByCallId.get(callId);
388
+ if (resultPosition !== undefined && !covered.has(resultPosition)) {
389
+ covered.add(resultPosition);
390
+ queue.push(resultPosition);
391
+ }
392
+ }
393
+ const message = messages[position];
394
+ if (message?.role === "toolResult") {
395
+ for (const [callPosition, id] of callIdByPosition) {
396
+ if (id === message.toolCallId && !covered.has(callPosition)) {
397
+ covered.add(callPosition);
398
+ queue.push(callPosition);
399
+ }
400
+ }
401
+ }
402
+ }
403
+ const out = [];
404
+ for (let index = 0; index < messages.length; index++) {
405
+ const pending = summaries.get(index);
406
+ if (pending) {
407
+ const anchor = messages[index];
408
+ for (const summary of pending) {
409
+ const synthetic = { role: "user", content: summary, timestamp: anchor.timestamp };
410
+ out.push(synthetic);
411
+ }
412
+ }
413
+ if (covered.has(index))
414
+ continue;
415
+ out.push(messages[index]);
416
+ }
417
+ return out;
418
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * DCP session state: what has been pruned, what has been compressed, and the
3
+ * three-layer switch that decides whether any of it runs.
4
+ *
5
+ * Two responsibilities live here deliberately:
6
+ *
7
+ * 1. **The on/off switch** (spec §2). `HOWTOFIX_DCP` (env, strongest) →
8
+ * `enabled` in `<cwd>/.howtofix/dcp.json` (config) → the session's own
9
+ * `/dcp on|off` override (weakest) → default on. Every resolution reports
10
+ * WHICH layer decided, so `/dcp` can say "off (config)" instead of a bare
11
+ * value the user cannot attribute — the pattern `antislopEnabled` in
12
+ * `paths.ts` uses for the skill suite. The config layer is read once when
13
+ * the extension is created (spec §2), so `resolveDcpEnabled` takes the
14
+ * layers as plain values and stays pure; `readConfigEnabled` is the file
15
+ * read the factory and the `/dcp` command each perform for themselves.
16
+ *
17
+ * 2. **The per-session state file** (spec §3.6): prune marks, compressed
18
+ * blocks with their anchors, the block/run id counters, and the savings
19
+ * stats. Plain JSON under `AGENT_DIR/dcp/<session>.json` — machine state,
20
+ * not a project document, so it is never git-backed — written atomically
21
+ * like every other persisted state file in this codebase. Loaded into a
22
+ * per-session cache so the request handler and the slash commands mutate
23
+ * one object instead of racing load-modify-save cycles.
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { writeFileAtomicSync } from "../fs-atomic.js";
28
+ import { AGENT_DIR } from "../paths.js";
29
+ import { createMessageIdState } from "./ids.js";
30
+ /**
31
+ * Read `enabled` from `<cwd>/.howtofix/dcp.json`.
32
+ *
33
+ * Returns `undefined` when the file is missing, unparseable, or holds no
34
+ * boolean there: a broken config is "no decision", not an error, because the
35
+ * next layer down must still get its say — same rule as `configuredAntislop`.
36
+ */
37
+ export function readConfigEnabled(cwd) {
38
+ try {
39
+ const file = join(cwd, ".howtofix", "dcp.json");
40
+ if (!existsSync(file))
41
+ return undefined;
42
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
43
+ if (typeof parsed !== "object" || parsed === null)
44
+ return undefined;
45
+ const enabled = parsed.enabled;
46
+ return typeof enabled === "boolean" ? enabled : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ /**
53
+ * The three-layer switch, strongest layer first (spec §2):
54
+ *
55
+ * 1. `HOWTOFIX_DCP` — `0`/`false` off, any other value on. Wins both ways,
56
+ * because it is how one person disables pruning across every project at
57
+ * once while chasing a suspected pruning bug.
58
+ * 2. `enabled` in `.howtofix/dcp.json` — the per-project say.
59
+ * 3. The session's `/dcp on|off` override — consulted only when neither
60
+ * layer above decided, so it persists across a resume of the same session
61
+ * without out-ranking the project's own config.
62
+ * 4. Default: on.
63
+ */
64
+ export function resolveDcpEnabled(layers) {
65
+ if (layers.env !== undefined) {
66
+ const value = layers.env.trim().toLowerCase();
67
+ return { enabled: value !== "0" && value !== "false", source: "env" };
68
+ }
69
+ if (layers.config !== undefined)
70
+ return { enabled: layers.config, source: "config" };
71
+ if (layers.runtime !== undefined)
72
+ return { enabled: layers.runtime, source: "session" };
73
+ return { enabled: true, source: "default" };
74
+ }
75
+ /** A fresh state: no marks, no blocks, counters start where validation expects them. */
76
+ export function createDcpState() {
77
+ return {
78
+ messageIds: createMessageIdState(),
79
+ prune: { tools: {}, messages: { blocks: [], nextBlockId: 1, nextRunId: 1 } },
80
+ stats: { totalPruneTokens: 0, totalCompressTokens: 0 },
81
+ };
82
+ }
83
+ /**
84
+ * Where a session's state is stored. Session ids are already file-safe, but
85
+ * normalise anyway: this value builds a path, and a stray separator here would
86
+ * write outside the dcp directory.
87
+ */
88
+ export function dcpStatePath(sessionId) {
89
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "session";
90
+ return join(AGENT_DIR, "dcp", `${safe}.json`);
91
+ }
92
+ function isDcpState(value) {
93
+ if (typeof value !== "object" || value === null)
94
+ return false;
95
+ const state = value;
96
+ const messages = state.prune?.messages;
97
+ const ids = state.messageIds;
98
+ return (typeof state.prune === "object" &&
99
+ state.prune !== null &&
100
+ typeof state.prune.tools === "object" &&
101
+ state.prune.tools !== null &&
102
+ typeof messages === "object" &&
103
+ messages !== null &&
104
+ Array.isArray(messages.blocks) &&
105
+ Number.isInteger(messages.nextBlockId) &&
106
+ Number.isInteger(messages.nextRunId) &&
107
+ typeof ids === "object" &&
108
+ ids !== null &&
109
+ Number.isInteger(ids.nextRef) &&
110
+ typeof ids.keyToRef === "object" &&
111
+ ids.keyToRef !== null &&
112
+ typeof ids.refToKey === "object" &&
113
+ ids.refToKey !== null &&
114
+ typeof state.stats === "object" &&
115
+ state.stats !== null &&
116
+ Number.isFinite(state.stats.totalPruneTokens) &&
117
+ Number.isFinite(state.stats.totalCompressTokens));
118
+ }
119
+ /**
120
+ * Per-session cache. The handler runs before every LLM call and the slash
121
+ * commands mutate between them; one live object per session keeps them from
122
+ * overwriting each other's load-modify-save cycles.
123
+ */
124
+ const states = new Map();
125
+ /** The session's state, loaded from disk on first touch. */
126
+ export function getDcpState(sessionId) {
127
+ const cached = states.get(sessionId);
128
+ if (cached)
129
+ return cached;
130
+ let loaded;
131
+ try {
132
+ const file = dcpStatePath(sessionId);
133
+ if (existsSync(file)) {
134
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
135
+ if (isDcpState(parsed))
136
+ loaded = parsed;
137
+ }
138
+ }
139
+ catch {
140
+ // An unreadable or hand-mangled state file starts fresh rather than
141
+ // killing every request with a parse error.
142
+ loaded = undefined;
143
+ }
144
+ const state = loaded ?? createDcpState();
145
+ states.set(sessionId, state);
146
+ return state;
147
+ }
148
+ /** Persist the cached state for this session. No-op before first touch. */
149
+ export function saveDcpState(sessionId) {
150
+ const state = states.get(sessionId);
151
+ if (!state)
152
+ return;
153
+ const file = dcpStatePath(sessionId);
154
+ mkdirSync(dirname(file), { recursive: true });
155
+ writeFileAtomicSync(file, JSON.stringify(state));
156
+ }
157
+ /**
158
+ * Drop a session's state, in memory and on disk.
159
+ *
160
+ * The cache is process-wide and keyed by session, and the file under
161
+ * `AGENT_DIR/dcp/` is one per session. Neither had a delete: `/new` and
162
+ * `/resume` replace the session, but the previous one's ref maps and its file
163
+ * stayed for good. The design spec says the state is deleted when the session
164
+ * is discarded. A later resume of the same session finds no file and starts
165
+ * fresh, which is the correct state for a session that was thrown away — the
166
+ * refs it stored pointed at a transcript that no longer exists.
167
+ */
168
+ export function dropDcpState(sessionId) {
169
+ states.delete(sessionId);
170
+ try {
171
+ unlinkSync(dcpStatePath(sessionId));
172
+ }
173
+ catch {
174
+ // Already gone is the state we wanted.
175
+ }
176
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * One way to tell a user that something failed.
3
+ *
4
+ * Every command used to phrase its own failure, which meant a missing file, a
5
+ * refused connection and a typo in a flag could all arrive in the same shape:
6
+ * a sentence that says what broke but not what to do. This module answers both
7
+ * questions from the error itself, so the answer does not depend on which code
8
+ * path happened to throw.
9
+ */
10
+ /** The action a user can take, inferred from the code Node attached. */
11
+ function hintFor(code, message) {
12
+ const evidence = `${code ?? ""} ${message}`;
13
+ if (/EACCES|EPERM/.test(evidence))
14
+ return "the file or directory is not writable; check permissions and try again";
15
+ if (/ENOENT/.test(evidence))
16
+ return "a file or directory this command needs is missing";
17
+ if (/ENOSPC/.test(evidence))
18
+ return "the disk is full";
19
+ if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|UND_ERR|fetch failed/i.test(evidence)) {
20
+ return "the network request failed; check the connection and the gateway address";
21
+ }
22
+ if (/EADDRINUSE/.test(evidence))
23
+ return "another process is already using that port";
24
+ if (/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND/.test(evidence)) {
25
+ return "a required file or package is missing; reinstall HowToFix";
26
+ }
27
+ if (/JSON/i.test(evidence) && /parse|unexpected token|end of input/i.test(evidence)) {
28
+ return "a configuration file is not valid JSON";
29
+ }
30
+ if (/not a git repository/i.test(evidence))
31
+ return "run the command inside a project, or pass --cwd";
32
+ return undefined;
33
+ }
34
+ /** The message, and the action that follows from it. */
35
+ export function describeError(error) {
36
+ const raw = error instanceof Error ? error.message : String(error);
37
+ const message = raw.trim() || "unknown error";
38
+ const code = error?.code;
39
+ return { message, hint: hintFor(code, message) };
40
+ }
41
+ /**
42
+ * The line(s) a failure prints.
43
+ *
44
+ * `context` names the thing that failed ("doctor", "update"), because "not
45
+ * permitted" alone does not say what was not permitted. The stack is evidence,
46
+ * and evidence only appears when `HOWTOFIX_DEBUG` asks for it.
47
+ */
48
+ export function formatError(appTitle, context, error, debug = false) {
49
+ const { message, hint } = describeError(error);
50
+ const head = context ? `${appTitle}: ${context}: ${message}` : `${appTitle}: ${message}`;
51
+ const lines = [head];
52
+ if (hint)
53
+ lines.push(` ${hint}`);
54
+ if (debug && error instanceof Error && error.stack)
55
+ lines.push(error.stack);
56
+ return lines.join("\n");
57
+ }
58
+ /** Print a failure the same way everywhere. */
59
+ export function reportError(appTitle, context, error, options) {
60
+ const debug = options?.debug ?? process.env.HOWTOFIX_DEBUG === "1";
61
+ const write = options?.write ?? ((text) => console.error(text));
62
+ write(formatError(appTitle, context, error, debug));
63
+ }