howtofixcli 0.7.24 → 0.7.26
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/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4931 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- 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
|
+
}
|