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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to do about the sentence the gateway just failed with.
|
|
3
|
+
*
|
|
4
|
+
* The gateway reports failures in its own words, and the README's
|
|
5
|
+
* Troubleshooting section already tells a reader what each one means — but only
|
|
6
|
+
* a reader who has the README open. In the TUI the same sentence arrives as a
|
|
7
|
+
* feed row titled `failed` and nothing else: the user is left to remember that
|
|
8
|
+
* `No available channel for model` means "the channel behind that id is down",
|
|
9
|
+
* not "my token is broken". The other documented sentences have the same gap.
|
|
10
|
+
*
|
|
11
|
+
* So the mapping lives here, as a pure string -> string function: no network,
|
|
12
|
+
* no session, no model runtime. The caller decides where the hint is shown
|
|
13
|
+
* (under a feed row in the TUI, on stderr in `--print`), which keeps this file
|
|
14
|
+
* free of UI so it can be exercised directly by a test.
|
|
15
|
+
*
|
|
16
|
+
* Every pattern is grounded in a sentence the gateway actually sends — the
|
|
17
|
+
* README's Troubleshooting list, the `probeModel` measurement (a listed model
|
|
18
|
+
* answering 500, another answering 401), and the `code 11128` refusal documented
|
|
19
|
+
* on `repairGatewayModels`. A pattern that matched nothing real would be a hint
|
|
20
|
+
* that lies, so nothing speculative is matched: unknown sentences return
|
|
21
|
+
* `undefined` and the caller prints the failure untouched.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The one sentence the gateway sends when the channel behind a model id is
|
|
25
|
+
* down, read as the id it names.
|
|
26
|
+
*
|
|
27
|
+
* Shared with `diagnoseGatewayFailure` on purpose: the TUI does not only want
|
|
28
|
+
* a hint for this failure, it wants the *id* so it can probe the other
|
|
29
|
+
* candidates for a live one — and two copies of this pattern would drift, so
|
|
30
|
+
* the caller that opens the model picker would silently stop matching.
|
|
31
|
+
*/
|
|
32
|
+
const NO_LIVE_CHANNEL = /no available channel for model\s+([^\s"',)\]]+)/i;
|
|
33
|
+
/** The model the gateway has no live channel for, or `undefined` for any other sentence. */
|
|
34
|
+
export function deadChannelModel(message) {
|
|
35
|
+
const text = message ?? "";
|
|
36
|
+
if (!text.trim())
|
|
37
|
+
return undefined;
|
|
38
|
+
return NO_LIVE_CHANNEL.exec(text)?.[1];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The fix for a known gateway sentence, or `undefined` for anything else.
|
|
42
|
+
*
|
|
43
|
+
* Case-insensitive on purpose: the same sentence arrives from the gateway, from
|
|
44
|
+
* an SDK wrapper (`Upstream API error (HTTP 400)`) and from a probe, with
|
|
45
|
+
* punctuation that differs. The first pattern that matches wins, ordered from
|
|
46
|
+
* the most specific sentence to the most generic, so `HTTP 429` inside a rate
|
|
47
|
+
* limit is read as rate limiting rather than as an upstream error.
|
|
48
|
+
*/
|
|
49
|
+
export function diagnoseGatewayFailure(message) {
|
|
50
|
+
const text = message ?? "";
|
|
51
|
+
if (!text.trim())
|
|
52
|
+
return undefined;
|
|
53
|
+
// The gateway has no live channel behind that id. Not an account problem:
|
|
54
|
+
// `/model` lists what answers *now* (README, Troubleshooting). The id is
|
|
55
|
+
// read by `deadChannelModel` too, which is what lets the TUI go looking for
|
|
56
|
+
// a channel that *is* alive instead of only saying so.
|
|
57
|
+
const deadModel = deadChannelModel(text);
|
|
58
|
+
if (deadModel) {
|
|
59
|
+
return `the gateway has no live channel for ${deadModel} right now — /model lists the models that answer; pick another.`;
|
|
60
|
+
}
|
|
61
|
+
// No credential at all: nothing left the machine, so the message is about
|
|
62
|
+
// the install rather than about the model or the account balance. Seen on a
|
|
63
|
+
// fresh install as `401: {"type":"AuthError","message":"Missing API key."}`.
|
|
64
|
+
if (/\bmissing api key\b|\bauthenticationerror\b|\bauth\s*error\b/i.test(text)) {
|
|
65
|
+
return "no key for this model yet — /connect signs you in and pulls the model list.";
|
|
66
|
+
}
|
|
67
|
+
// `unknown model` means the id is not in models.json at all, which /connect
|
|
68
|
+
// fixes by pulling the current list (README, Troubleshooting).
|
|
69
|
+
if (/\bunknown model\b/i.test(text)) {
|
|
70
|
+
return "/connect pulls the current model list from the gateway.";
|
|
71
|
+
}
|
|
72
|
+
// The gateway refused a role the request was sent with (`code 11128`,
|
|
73
|
+
// "Illegal API invocation") — the compatibility flag on the model entry is
|
|
74
|
+
// what decides which roles are sent, and /connect re-reads it.
|
|
75
|
+
if (/\bcode 11128\b|\billegal api invocation\b/i.test(text)) {
|
|
76
|
+
return "the gateway refused the request shape (code 11128) — /connect re-syncs each model's compatibility flags.";
|
|
77
|
+
}
|
|
78
|
+
// 402/insufficient balance: the token is fine, the credit is not.
|
|
79
|
+
if (/\binsufficient (?:balance|quota|credit)\b|\bexceeded your current quota\b|http\s*402/i.test(text)) {
|
|
80
|
+
return "the account is out of credit — /usage shows what is left.";
|
|
81
|
+
}
|
|
82
|
+
// The gateway's channel failing, not the CLI (README, Troubleshooting).
|
|
83
|
+
if (/\bserver error\b|http\s*5\d\d/i.test(text)) {
|
|
84
|
+
return "the gateway's channel is failing, not the CLI — /model lists what your account can reach; pick another.";
|
|
85
|
+
}
|
|
86
|
+
// Rate limiting: waiting is the fix, and the run can simply be resent.
|
|
87
|
+
if (/\brate.?limit|\b429\b/i.test(text)) {
|
|
88
|
+
return "the gateway is rate-limiting this token — wait a moment, then resend the prompt.";
|
|
89
|
+
}
|
|
90
|
+
// Context overflow: the prompt alone is past the model's window. This must be
|
|
91
|
+
// read before the generic `upstream api error` branch below, because the
|
|
92
|
+
// gateway wraps the provider's sentence in that prefix.
|
|
93
|
+
//
|
|
94
|
+
// The SDK recovers from this by compacting and retrying once; when the user
|
|
95
|
+
// sees the sentence at all, that recovery has already been spent or refused.
|
|
96
|
+
// `/compact` is the manual escape, and it is deliberately still allowed while
|
|
97
|
+
// the auto-compaction breaker is tripped.
|
|
98
|
+
if (/\bprompt (?:is )?too long\b|\bprompt too long\b|\bexceeds? (?:the )?(?:model'?s )?maximum context length\b|\bmaximum context length is \d+ tokens\b|\bexceeds the context window\b|\bcontext[_ ]length[_ ]exceeded\b|\breduce the length of the messages\b/i.test(text)) {
|
|
99
|
+
return "the conversation is past this model's context window — /compact summarises it and continues, or /model picks a model with a bigger window.";
|
|
100
|
+
}
|
|
101
|
+
// `Upstream API error (HTTP nnn)` — the gateway refused the request; a model
|
|
102
|
+
// entry written before a gateway change carries stale flags (README).
|
|
103
|
+
if (/\bupstream api error\b/i.test(text)) {
|
|
104
|
+
return "the gateway refused the request — run /connect again to re-sync your models.";
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { cp, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* Run git in the project directory. Never throws; failures come back as text.
|
|
9
|
+
*
|
|
10
|
+
* Async on purpose: every caller is a slash command running inside the workbench,
|
|
11
|
+
* and a synchronous spawn blocks the event loop — on Windows a process spawn costs
|
|
12
|
+
* far more than on Linux, so `/commit`, `/undo` and `/review` froze the screen for
|
|
13
|
+
* the duration. Measured on Linux: 6.5 ms for a `status` spawn alone.
|
|
14
|
+
*/
|
|
15
|
+
export async function git(cwd, args) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
18
|
+
cwd,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
21
|
+
});
|
|
22
|
+
return { ok: true, output: stdout.trim() };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const stderr = error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim() : "";
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
return { ok: false, output: stderr || message };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function isGitRepo(cwd) {
|
|
31
|
+
return (await git(cwd, ["rev-parse", "--is-inside-work-tree"])).output === "true";
|
|
32
|
+
}
|
|
33
|
+
export async function hasChanges(cwd) {
|
|
34
|
+
return (await git(cwd, ["status", "--porcelain"])).output.length > 0;
|
|
35
|
+
}
|
|
36
|
+
/** Stage everything, commit, and return the one-line summary. */
|
|
37
|
+
export async function commitAll(cwd, message) {
|
|
38
|
+
const add = await git(cwd, ["add", "-A"]);
|
|
39
|
+
if (!add.ok)
|
|
40
|
+
return add;
|
|
41
|
+
const commit = await git(cwd, ["commit", "-m", message]);
|
|
42
|
+
if (!commit.ok)
|
|
43
|
+
return commit;
|
|
44
|
+
const head = await git(cwd, ["log", "-1", "--oneline"]);
|
|
45
|
+
return { ok: true, output: head.output || commit.output };
|
|
46
|
+
}
|
|
47
|
+
/** Drop the last commit, keeping its changes staged. The inverse of `/commit`. */
|
|
48
|
+
export async function undoLastCommit(cwd) {
|
|
49
|
+
const reset = await git(cwd, ["reset", "--soft", "HEAD~1"]);
|
|
50
|
+
if (!reset.ok)
|
|
51
|
+
return reset;
|
|
52
|
+
const head = await git(cwd, ["log", "-1", "--oneline"]);
|
|
53
|
+
return { ok: true, output: head.output || "reset to the previous commit" };
|
|
54
|
+
}
|
|
55
|
+
export async function diffStat(cwd) {
|
|
56
|
+
return (await git(cwd, ["diff", "--stat"])).output;
|
|
57
|
+
}
|
|
58
|
+
export async function fullDiff(cwd) {
|
|
59
|
+
const unstaged = (await git(cwd, ["diff"])).output;
|
|
60
|
+
const staged = (await git(cwd, ["diff", "--cached"])).output;
|
|
61
|
+
return [unstaged, staged].filter(Boolean).join("\n");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Cap a diff for display.
|
|
65
|
+
*
|
|
66
|
+
* A transcript is for reading, and a full patch of a large change pushes
|
|
67
|
+
* everything else off the screen — the note holding it can be hundreds of lines.
|
|
68
|
+
* The file summary is kept whole (it is what you scan), and the body is cut with
|
|
69
|
+
* a line saying how much is hidden, so nothing is silently dropped.
|
|
70
|
+
*/
|
|
71
|
+
export function clipDiff(diff, maxLines = 60) {
|
|
72
|
+
const lines = diff.split("\n");
|
|
73
|
+
if (lines.length <= maxLines)
|
|
74
|
+
return diff;
|
|
75
|
+
const head = lines.slice(0, maxLines);
|
|
76
|
+
const hidden = lines.length - maxLines;
|
|
77
|
+
return [...head, `… ${hidden} more line${hidden === 1 ? "" : "s"} not shown — /copy puts the full diff on the clipboard`].join("\n");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Directories that are (a) expensive to rebuild and (b) always git-ignored.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately a fixed list rather than "everything ignored". `git status --ignored`
|
|
83
|
+
* also reports `.env`, `*.pem`, editor state and local databases — copying those into a
|
|
84
|
+
* tree that a parallel agent shares widens the blast radius of a mistake for no gain.
|
|
85
|
+
* A worktree needs what it takes to BUILD; it does not need someone's secrets.
|
|
86
|
+
*/
|
|
87
|
+
const SHARED_IGNORED = ["node_modules", "vendor", "target", ".venv", "venv", "__pycache__", ".pnpm-store", "dist", "build"];
|
|
88
|
+
/**
|
|
89
|
+
* The weight past which copying is not attempted.
|
|
90
|
+
*
|
|
91
|
+
* This runs on the critical path of spawning an agent, so it has a budget. A reflink is
|
|
92
|
+
* nearly free; a real copy of 200 MB is not, and a worktree that takes four seconds to
|
|
93
|
+
* appear is one the user will turn off. Past the cap the directory is skipped and named
|
|
94
|
+
* in the result, so the caller can say why rather than leaving the agent to discover it.
|
|
95
|
+
*/
|
|
96
|
+
const SHARE_BUDGET_BYTES = 1024 * 1024 * 1024;
|
|
97
|
+
async function directorySize(path) {
|
|
98
|
+
let total = 0;
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const child = join(path, entry.name);
|
|
108
|
+
if (entry.isDirectory())
|
|
109
|
+
total += await directorySize(child);
|
|
110
|
+
else if (entry.isFile()) {
|
|
111
|
+
try {
|
|
112
|
+
total += (await stat(child)).size;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// A file that vanished mid-walk costs nothing to skip.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Stop early once the answer is already over budget: the exact figure is not used.
|
|
119
|
+
if (total > SHARE_BUDGET_BYTES)
|
|
120
|
+
return total;
|
|
121
|
+
}
|
|
122
|
+
return total;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Give the worktree the ignored directories it needs to build.
|
|
126
|
+
*
|
|
127
|
+
* Measured on this host for a 15,585-file / 163 MB `node_modules`:
|
|
128
|
+
* `git worktree add` 37 ms, a size walk 383 ms, and then the copy. Node's own
|
|
129
|
+
* recursive `fs.cp` took **13.4 s** — per-file async overhead dominates at this file
|
|
130
|
+
* count — while spawning `cp -a` took about **1 s**. So the copy is done by `cp` where
|
|
131
|
+
* it exists, and `fs.cp` is kept only as the fallback for Windows and for a `cp` that
|
|
132
|
+
* fails. Thirteen seconds is not a slower agent, it is a spawn that looks hung.
|
|
133
|
+
*
|
|
134
|
+
* Where the filesystem supports reflinks `cp -a` uses them; where it does not, the copy
|
|
135
|
+
* is real and the budget below is what bounds it. Returns the directories it could not
|
|
136
|
+
* share, because silence here would surface later as an agent claiming a pass it never
|
|
137
|
+
* ran.
|
|
138
|
+
*/
|
|
139
|
+
export async function shareIgnoredDirectories(from, to) {
|
|
140
|
+
const skipped = [];
|
|
141
|
+
for (const name of SHARED_IGNORED) {
|
|
142
|
+
const source = join(from, name);
|
|
143
|
+
const target = join(to, name);
|
|
144
|
+
try {
|
|
145
|
+
if (!(await stat(source)).isDirectory())
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
continue; // Not present: the common case, and not a problem.
|
|
150
|
+
}
|
|
151
|
+
if ((await directorySize(source)) > SHARE_BUDGET_BYTES) {
|
|
152
|
+
skipped.push(name);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (await copyDirectory(source, target))
|
|
156
|
+
continue;
|
|
157
|
+
skipped.push(name);
|
|
158
|
+
}
|
|
159
|
+
return skipped;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Copy one directory tree, preferring the platform's own `cp`.
|
|
163
|
+
*
|
|
164
|
+
* `-a` keeps symlinks as symlinks and preserves mode and timestamps, which matters for
|
|
165
|
+
* an executable in `node_modules/.bin` and for a build cache that decides what to
|
|
166
|
+
* rebuild by timestamp.
|
|
167
|
+
*/
|
|
168
|
+
async function copyDirectory(source, target) {
|
|
169
|
+
if (process.platform !== "win32") {
|
|
170
|
+
try {
|
|
171
|
+
await execFileAsync("cp", ["-a", source, target], { maxBuffer: 8 * 1024 * 1024 });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Fall through to the portable path: a `cp` that is missing or refused is not a
|
|
176
|
+
// reason to leave the agent without the tree it needs.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await cp(source, target, { recursive: true, force: true, errorOnExist: false, dereference: false });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Give one agent call its own checkout, for `agent({ isolation: "worktree" })`.
|
|
189
|
+
*
|
|
190
|
+
* This is what makes parallel writers safe: two agents told to fix different
|
|
191
|
+
* files in the same tree still race each other's `git add -A`, each other's
|
|
192
|
+
* builds, and any tool that walks the project. A detached worktree at HEAD gives
|
|
193
|
+
* each call a private tree, so a sibling's half-finished edit cannot break it.
|
|
194
|
+
*
|
|
195
|
+
* Returns `null` when the project is not a git repository (or the worktree could
|
|
196
|
+
* not be made), because isolation is an optimisation for correctness, not a
|
|
197
|
+
* reason to fail a run: the caller falls back to the shared tree.
|
|
198
|
+
*
|
|
199
|
+
* The worktree is placed under the system temp dir rather than inside the
|
|
200
|
+
* project, so it never shows up in the project's own `git status` and is not
|
|
201
|
+
* walked by the agents themselves.
|
|
202
|
+
*
|
|
203
|
+
* **The ignored directories are the point of this function.** A worktree is a
|
|
204
|
+
* checkout of tracked files only, so it arrives without `node_modules/`,
|
|
205
|
+
* `target/`, `.venv/` — every artifact that is large precisely because it is not
|
|
206
|
+
* in git. An agent told to fix a bug and verify the fix therefore cannot run the
|
|
207
|
+
* build or the tests at all: it reports success it never checked, or gives up and
|
|
208
|
+
* says the tree is broken. Isolation that removes the ability to verify is not
|
|
209
|
+
* isolation, it is a broken checkout. We copy them in.
|
|
210
|
+
*/
|
|
211
|
+
export async function createAgentWorktree(cwd, label) {
|
|
212
|
+
if (!(await isGitRepo(cwd)))
|
|
213
|
+
return null;
|
|
214
|
+
const safe = label.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 40) || "agent";
|
|
215
|
+
const path = join(tmpdir(), `howtofix-wt-${process.pid}-${Date.now()}-${safe}`);
|
|
216
|
+
const created = await git(cwd, ["worktree", "add", "--detach", path, "HEAD"]);
|
|
217
|
+
if (!created.ok)
|
|
218
|
+
return null;
|
|
219
|
+
const skipped = await shareIgnoredDirectories(cwd, path);
|
|
220
|
+
return {
|
|
221
|
+
path,
|
|
222
|
+
skipped,
|
|
223
|
+
async remove() {
|
|
224
|
+
await git(cwd, ["worktree", "remove", "--force", path]);
|
|
225
|
+
// A crash can leave the directory behind; the worktree registration is
|
|
226
|
+
// what matters, so prune after removing.
|
|
227
|
+
await git(cwd, ["worktree", "prune"]);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readMessageText, writeMessageText } from "./prompt-text.js";
|
|
2
|
+
import { GOAL_CONTINUE, GOAL_ENTRY, continuationPrompt, readGoal, renderGoalBlock, setMirror, shouldContinue } from "./goal.js";
|
|
3
|
+
/** Reseed the HUD mirror. Exported so the workbench can call it on /new and /resume. */
|
|
4
|
+
export function seedGoalMirror(ctx) {
|
|
5
|
+
setMirror(readGoal(ctx));
|
|
6
|
+
}
|
|
7
|
+
/** The note written when the continuation budget is what stopped the run. */
|
|
8
|
+
function budgetNote(goal) {
|
|
9
|
+
return `budget exhausted (${goal.budget ?? "default"} continuations)`;
|
|
10
|
+
}
|
|
11
|
+
export function createGoalExtension() {
|
|
12
|
+
return (hxf) => {
|
|
13
|
+
hxf.on("session_start", async (_event, ctx) => {
|
|
14
|
+
seedGoalMirror(ctx);
|
|
15
|
+
});
|
|
16
|
+
hxf.on("context_with_system", async (event, ctx) => {
|
|
17
|
+
const goal = readGoal(ctx);
|
|
18
|
+
setMirror(goal);
|
|
19
|
+
if (!goal || goal.status !== "active")
|
|
20
|
+
return undefined;
|
|
21
|
+
const head = event.messages[0];
|
|
22
|
+
if (!head || head.role !== "system")
|
|
23
|
+
return undefined;
|
|
24
|
+
const combined = `${readMessageText(head.content)}\n\n${renderGoalBlock(goal)}`;
|
|
25
|
+
const messages = event.messages.map((message) => (message === head ? { ...head, content: writeMessageText(head.content, combined) } : message));
|
|
26
|
+
return { messages };
|
|
27
|
+
});
|
|
28
|
+
hxf.on("agent_before_settle", async (event, ctx) => {
|
|
29
|
+
// After a session replacement the SDK still fires settle events for the
|
|
30
|
+
// session that was replaced, and any read through that ctx is rejected as
|
|
31
|
+
// stale. There is nothing to do for a session that is no longer on screen,
|
|
32
|
+
// so a stale ctx ends the handler quietly instead of surfacing an error
|
|
33
|
+
// the user can do nothing about.
|
|
34
|
+
try {
|
|
35
|
+
const goal = readGoal(ctx);
|
|
36
|
+
setMirror(goal);
|
|
37
|
+
if (shouldContinue(goal).action === "stop" || !goal)
|
|
38
|
+
return undefined;
|
|
39
|
+
// A run already continuing for another reason (a queued continuation, a
|
|
40
|
+
// retry) must not be doubled up, and continuing is pointless when the
|
|
41
|
+
// context cannot accept another request.
|
|
42
|
+
if (event.continue || !event.context.canContinue)
|
|
43
|
+
return undefined;
|
|
44
|
+
// Persist the spend before continuing: if the process dies mid-run the
|
|
45
|
+
// count is already on disk, so a resume cannot reset the budget.
|
|
46
|
+
const spent = { ...goal, used: goal.used + 1 };
|
|
47
|
+
hxf.appendEntry(GOAL_ENTRY, spent);
|
|
48
|
+
setMirror(spent);
|
|
49
|
+
if (shouldContinue(spent).action === "stop") {
|
|
50
|
+
// This spend was the last one the budget allowed. Close it here as well
|
|
51
|
+
// as on settle, so the state is right even if no settle event follows.
|
|
52
|
+
const closed = { ...spent, status: "done", note: budgetNote(spent) };
|
|
53
|
+
hxf.appendEntry(GOAL_ENTRY, closed);
|
|
54
|
+
setMirror(closed);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
entries: [{ type: "custom_message", customType: GOAL_CONTINUE, content: continuationPrompt(spent), display: true }],
|
|
59
|
+
continue: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
hxf.on("agent_settled", async (_event, ctx) => {
|
|
67
|
+
// The run is over. If the budget was the reason, close the goal so the
|
|
68
|
+
// next prompt does not silently resume a runaway. A ctx left over from a
|
|
69
|
+
// replaced session throws on read; that session is gone, so there is
|
|
70
|
+
// nothing to close.
|
|
71
|
+
try {
|
|
72
|
+
const goal = readGoal(ctx);
|
|
73
|
+
const decision = shouldContinue(goal);
|
|
74
|
+
if (goal && decision.action === "stop" && decision.close) {
|
|
75
|
+
const closed = { ...goal, status: "done", note: budgetNote(goal) };
|
|
76
|
+
hxf.appendEntry(GOAL_ENTRY, closed);
|
|
77
|
+
setMirror(closed);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
setMirror(goal);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/goal.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A persistent goal for the session, with usage accounting and a continuation
|
|
3
|
+
* loop.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: opencode's most-reacted session request (#27167) is a native
|
|
6
|
+
* goal, and its own author states the reason it is not a command template — it
|
|
7
|
+
* "needs persistence, runtime continuation, usage accounting, and client
|
|
8
|
+
* sync/status surfaces". A slash command that prints a prompt can do none of
|
|
9
|
+
* those. What a goal buys over a plain message is that the harness knows what
|
|
10
|
+
* the run is *for*, so it can:
|
|
11
|
+
*
|
|
12
|
+
* - keep the goal in the prompt every request (a long task survives a
|
|
13
|
+
* compaction that drops the original message),
|
|
14
|
+
* - continue working when the agent tries to stop with the goal still open,
|
|
15
|
+
* - account the cost against it and stop at a budget instead of running away,
|
|
16
|
+
* - refuse to let the model declare victory without evidence.
|
|
17
|
+
*
|
|
18
|
+
* State lives in the session branch (a `custom` entry), so it survives a resume
|
|
19
|
+
* and stays correct across a fork — the same reason `todo.ts` keeps its list
|
|
20
|
+
* there. A module-level mirror exists only so the HUD can paint the goal without
|
|
21
|
+
* a session handle; it is reseeded from the branch on `session_start`.
|
|
22
|
+
*
|
|
23
|
+
* Pure where it matters: {@link applyGoalOp} and {@link shouldContinue} hold the
|
|
24
|
+
* policy, so both are testable without a session or a model.
|
|
25
|
+
*/
|
|
26
|
+
import { StringEnum } from "@netrodev/hxf-ai";
|
|
27
|
+
import { defineTool } from "@netrodev/hxf-coding-agent";
|
|
28
|
+
import { Type } from "typebox";
|
|
29
|
+
/** Session entry type the goal is persisted under. */
|
|
30
|
+
export const GOAL_ENTRY = "howtofix.goal";
|
|
31
|
+
/** Custom message type used for the automatic continuation. */
|
|
32
|
+
export const GOAL_CONTINUE = "howtofix.goal.continue";
|
|
33
|
+
/** Longest goal text accepted. */
|
|
34
|
+
export const MAX_GOAL = 400;
|
|
35
|
+
/**
|
|
36
|
+
* Continuations allowed when the goal sets no budget.
|
|
37
|
+
*
|
|
38
|
+
* A goal that can continue forever is the failure mode this whole feature is
|
|
39
|
+
* meant to avoid, so the default is a cap rather than "unlimited": an unattended
|
|
40
|
+
* run stops and says so, and the user can resume it.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_BUDGET = 25;
|
|
43
|
+
const MARK = {
|
|
44
|
+
active: "●",
|
|
45
|
+
paused: "‖",
|
|
46
|
+
done: "✓",
|
|
47
|
+
cleared: "○",
|
|
48
|
+
};
|
|
49
|
+
/** One line for the tool result and the HUD. */
|
|
50
|
+
export function formatGoal(goal) {
|
|
51
|
+
if (!goal || goal.status === "cleared")
|
|
52
|
+
return "No goal set.";
|
|
53
|
+
const cap = goal.budget ?? DEFAULT_BUDGET;
|
|
54
|
+
const parts = [`${MARK[goal.status]} ${goal.status}`, `${goal.used}/${cap} continuations`];
|
|
55
|
+
if (goal.note)
|
|
56
|
+
parts.push(goal.note);
|
|
57
|
+
return `${parts.join(" · ")}\n${goal.text}`;
|
|
58
|
+
}
|
|
59
|
+
/** The short form painted in the HUD. Empty when there is nothing worth showing. */
|
|
60
|
+
export function goalSegment(goal) {
|
|
61
|
+
if (!goal || goal.status === "cleared" || goal.status === "done")
|
|
62
|
+
return "";
|
|
63
|
+
const cap = goal.budget ?? DEFAULT_BUDGET;
|
|
64
|
+
const text = goal.text.length > 48 ? `${goal.text.slice(0, 47)}…` : goal.text;
|
|
65
|
+
return `goal ${MARK[goal.status]} ${goal.used}/${cap} · ${text}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The block injected into the prompt, so the goal survives compaction.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately free of anything that changes between turns. This is appended to
|
|
71
|
+
* the system message, which is the cacheable prefix of every request, so a
|
|
72
|
+
* counter here would invalidate the prompt cache on every continuation — the
|
|
73
|
+
* same class of mistake that cost Claude Code 10.2% of fleet-wide cache
|
|
74
|
+
* creation tokens when a dynamic agent list sat in a tool schema. The spend is
|
|
75
|
+
* reported where it belongs: in the continuation message and the tool result,
|
|
76
|
+
* both of which are new content and cannot invalidate a cached prefix.
|
|
77
|
+
*/
|
|
78
|
+
export function renderGoalBlock(goal) {
|
|
79
|
+
const lines = ["<goal>", goal.text, `Status: ${goal.status}.`];
|
|
80
|
+
if (goal.status === "active") {
|
|
81
|
+
lines.push("Work toward this goal until it is genuinely met. When it is, call the `goal` tool with", 'op="complete" and a note naming the evidence; a command you ran or a file you checked.', "If you are blocked and cannot proceed, say so plainly instead of repeating an attempt.");
|
|
82
|
+
}
|
|
83
|
+
lines.push("</goal>");
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
/** What the agent is told when the harness decides to keep going. */
|
|
87
|
+
export function continuationPrompt(goal) {
|
|
88
|
+
return [
|
|
89
|
+
"[goal] The goal is still open and you stopped without closing it:",
|
|
90
|
+
` ${goal.text}`,
|
|
91
|
+
"",
|
|
92
|
+
"Continue from where you left off. Do not repeat work you already did. If the goal is met,",
|
|
93
|
+
'call the `goal` tool with op="complete" and a note naming the evidence. Do not call it',
|
|
94
|
+
"complete to escape a hard problem: if you cannot make progress, state what is blocking you.",
|
|
95
|
+
`Continuations used: ${goal.used}/${goal.budget ?? DEFAULT_BUDGET}.`,
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
/** A stable in-memory mirror for surfaces with no session handle (the HUD). */
|
|
99
|
+
let mirror;
|
|
100
|
+
export function currentGoal() {
|
|
101
|
+
return mirror;
|
|
102
|
+
}
|
|
103
|
+
export function setMirror(goal) {
|
|
104
|
+
mirror = goal;
|
|
105
|
+
}
|
|
106
|
+
export function shouldContinue(goal) {
|
|
107
|
+
if (!goal)
|
|
108
|
+
return { action: "stop", reason: "no goal", close: false };
|
|
109
|
+
if (goal.status === "done")
|
|
110
|
+
return { action: "stop", reason: "goal complete", close: false };
|
|
111
|
+
if (goal.status === "cleared")
|
|
112
|
+
return { action: "stop", reason: "goal cleared", close: false };
|
|
113
|
+
if (goal.status === "paused")
|
|
114
|
+
return { action: "stop", reason: "goal paused", close: false };
|
|
115
|
+
const cap = goal.budget ?? DEFAULT_BUDGET;
|
|
116
|
+
if (goal.used >= cap)
|
|
117
|
+
return { action: "stop", reason: `budget exhausted (${cap} continuations)`, close: true };
|
|
118
|
+
return { action: "continue" };
|
|
119
|
+
}
|
|
120
|
+
/** Apply one op. Pure, so the policy is testable without a session. */
|
|
121
|
+
export function applyGoalOp(goal, op, text, budget, note) {
|
|
122
|
+
switch (op) {
|
|
123
|
+
case "view":
|
|
124
|
+
return { goal };
|
|
125
|
+
case "set": {
|
|
126
|
+
if (!text?.trim())
|
|
127
|
+
return { goal, error: "set needs text" };
|
|
128
|
+
if (text.length > MAX_GOAL)
|
|
129
|
+
return { goal, error: `text longer than ${MAX_GOAL} characters` };
|
|
130
|
+
if (budget !== undefined && (!Number.isInteger(budget) || budget < 1)) {
|
|
131
|
+
return { goal, error: "budget must be a positive integer" };
|
|
132
|
+
}
|
|
133
|
+
// A new goal resets the accounting: the cap belongs to the goal, not to
|
|
134
|
+
// the session, so a fresh goal gets its full budget.
|
|
135
|
+
return { goal: { text: text.trim(), status: "active", used: 0, ...(budget === undefined ? {} : { budget }) } };
|
|
136
|
+
}
|
|
137
|
+
case "pause":
|
|
138
|
+
case "resume":
|
|
139
|
+
case "complete":
|
|
140
|
+
case "clear": {
|
|
141
|
+
if (!goal || goal.status === "cleared")
|
|
142
|
+
return { goal, error: "no goal to change" };
|
|
143
|
+
if (op === "resume") {
|
|
144
|
+
if (goal.status === "done")
|
|
145
|
+
return { goal, error: "goal is complete; set a new one instead" };
|
|
146
|
+
return { goal: { ...goal, status: "active", note: undefined } };
|
|
147
|
+
}
|
|
148
|
+
if (op === "pause")
|
|
149
|
+
return { goal: { ...goal, status: "paused" } };
|
|
150
|
+
if (op === "clear")
|
|
151
|
+
return { goal: { ...goal, status: "cleared" } };
|
|
152
|
+
// `complete` requires evidence. The model may only declare victory with a
|
|
153
|
+
// note naming what it verified; a bare "done" is what lets a wrong answer
|
|
154
|
+
// ship as a finished task.
|
|
155
|
+
if (!note?.trim())
|
|
156
|
+
return { goal, error: "complete needs a note naming the evidence (a command you ran or a file you checked)" };
|
|
157
|
+
return { goal: { ...goal, status: "done", note: note.trim() } };
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
return { goal, error: `unknown op ${JSON.stringify(op)}` };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Rebuild the goal by replaying this branch in order.
|
|
165
|
+
*
|
|
166
|
+
* Two sources, because two things write it: the `goal` tool (whose result
|
|
167
|
+
* `details` are persisted in the transcript, exactly like `todo.ts`) and the
|
|
168
|
+
* continuation extension, which appends a `custom` entry to record a spend
|
|
169
|
+
* before it continues. Whichever is later in the branch wins, so a resume sees
|
|
170
|
+
* the true state and the accounting cannot be reset by a restart.
|
|
171
|
+
*/
|
|
172
|
+
export function readGoal(ctx) {
|
|
173
|
+
let goal;
|
|
174
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
175
|
+
if (entry.type === "custom" && entry.customType === GOAL_ENTRY) {
|
|
176
|
+
goal = entry.data;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// The slash command persists through `sendCustomMessage`, which writes a
|
|
180
|
+
// `custom_message` carrying the goal in `details` rather than `data`.
|
|
181
|
+
if (entry.type === "custom_message" && entry.customType === GOAL_ENTRY) {
|
|
182
|
+
goal = entry.details ?? goal;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (entry.type !== "message")
|
|
186
|
+
continue;
|
|
187
|
+
const message = entry.message;
|
|
188
|
+
if (message.role !== "toolResult" || message.toolName !== "goal")
|
|
189
|
+
continue;
|
|
190
|
+
const details = message.details;
|
|
191
|
+
if (details?.goal)
|
|
192
|
+
goal = details.goal;
|
|
193
|
+
}
|
|
194
|
+
return goal;
|
|
195
|
+
}
|
|
196
|
+
export const goalParams = Type.Object({
|
|
197
|
+
op: StringEnum(["set", "view", "pause", "resume", "complete", "clear"], {
|
|
198
|
+
description: "set starts a goal; complete closes it with evidence; pause/resume/clear manage it; view reads it",
|
|
199
|
+
}),
|
|
200
|
+
text: Type.Optional(Type.String({ description: "The goal, in one sentence, as the user would state it (for set)" })),
|
|
201
|
+
budget: Type.Optional(Type.Number({ description: "Optional cap on automatic continuations before the run stops" })),
|
|
202
|
+
note: Type.Optional(Type.String({ description: "Evidence that the goal is met (required for complete)" })),
|
|
203
|
+
});
|
|
204
|
+
export const goalTool = defineTool({
|
|
205
|
+
name: "goal",
|
|
206
|
+
label: "goal",
|
|
207
|
+
description: "Track the goal for this session. The goal is already shown in your context every request, so you rarely need `view`. Call `complete` only when the goal is genuinely met, and pass a note naming the evidence. When a goal is active the harness continues the run if you stop without closing it, so closing it is how you stop.",
|
|
208
|
+
promptSnippet: "goal: the session's objective, and how to close it",
|
|
209
|
+
parameters: goalParams,
|
|
210
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
211
|
+
const before = readGoal(ctx);
|
|
212
|
+
const { goal, error } = applyGoalOp(before, params.op, params.text, params.budget, params.note);
|
|
213
|
+
setMirror(goal);
|
|
214
|
+
const body = formatGoal(goal);
|
|
215
|
+
const summary = error ? `Error: ${error}\n${body}` : body;
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: summary }],
|
|
218
|
+
details: { op: params.op, goal },
|
|
219
|
+
...(error ? { isError: true } : {}),
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
});
|