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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/dcp` — the switch and the inspector.
|
|
3
|
+
*
|
|
4
|
+
* DCP is on by default and costs nothing when idle: with no blocks and no stale
|
|
5
|
+
* output the transform returns the transcript unchanged. The switch exists so a
|
|
6
|
+
* user who suspects the pruning is hurting can turn it off without losing the
|
|
7
|
+
* session, and the override is persisted, so it survives a restart.
|
|
8
|
+
*
|
|
9
|
+
* `readConfigEnabled` is consulted only to report *where* the current value
|
|
10
|
+
* came from. Resolution order is session override → env → config → default, and
|
|
11
|
+
* the session override always wins: a user who typed `/dcp off` did not mean
|
|
12
|
+
* "unless a config file disagrees".
|
|
13
|
+
*
|
|
14
|
+
* The functions return text rather than printing, so the workbench keeps owning
|
|
15
|
+
* rendering — the same contract every other slash command in `ui.ts` follows.
|
|
16
|
+
*/
|
|
17
|
+
import { activeCompressedRanges } from "./compress-state.js";
|
|
18
|
+
import { getDcpState, readConfigEnabled, resolveDcpEnabled, saveDcpState } from "./state.js";
|
|
19
|
+
/** One `/dcp` subcommand, kept in one table so help and dispatch cannot drift. */
|
|
20
|
+
export const DCP_USAGE = {
|
|
21
|
+
on: "turn the transform on for this session",
|
|
22
|
+
off: "turn it off; the full transcript is sent unchanged",
|
|
23
|
+
status: "the switch, the block count, and the tokens saved",
|
|
24
|
+
blocks: "list the active compressed blocks",
|
|
25
|
+
stats: "prune and compress totals for this session",
|
|
26
|
+
context: "what the next request will carry, and what was saved",
|
|
27
|
+
decompress: "reopen one block by id, e.g. /dcp decompress b2",
|
|
28
|
+
sweep: "drop blocks no longer referenced by the ref state",
|
|
29
|
+
};
|
|
30
|
+
function formatChars(chars) {
|
|
31
|
+
return chars < 1000 ? `${chars}` : `${(chars / 1000).toFixed(1)}k`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Apply a `/dcp` subcommand and return what to show the user.
|
|
35
|
+
*
|
|
36
|
+
* `sessionId` keys the state file; `cwd` is where a project config would live.
|
|
37
|
+
* Both are passed in rather than read from a context so this stays callable
|
|
38
|
+
* from the workbench, where there is no extension context to hand.
|
|
39
|
+
*/
|
|
40
|
+
export function runDcpCommand(args, cwd, sessionId) {
|
|
41
|
+
// A default in the destructuring does NOT fire for an empty string - only for
|
|
42
|
+
// `undefined` - so `/dcp` with no argument produced `""` and reported it as an
|
|
43
|
+
// unknown subcommand. The status view is what a bare `/dcp` must show, and
|
|
44
|
+
// the TUI probe caught it; no unit fixture had tried the bare form.
|
|
45
|
+
const [first] = args.trim().split(/\s+/);
|
|
46
|
+
const sub = first === undefined || first === "" ? "status" : first;
|
|
47
|
+
const state = getDcpState(sessionId);
|
|
48
|
+
switch (sub) {
|
|
49
|
+
case "on":
|
|
50
|
+
state.enabled = true;
|
|
51
|
+
saveDcpState(sessionId);
|
|
52
|
+
return "dcp: on";
|
|
53
|
+
case "off":
|
|
54
|
+
state.enabled = false;
|
|
55
|
+
saveDcpState(sessionId);
|
|
56
|
+
return "dcp: off; the full transcript is sent unchanged";
|
|
57
|
+
case "blocks": {
|
|
58
|
+
const blocks = activeCompressedRanges(state);
|
|
59
|
+
if (blocks.length === 0)
|
|
60
|
+
return "dcp: no active blocks";
|
|
61
|
+
return blocks
|
|
62
|
+
.map((block) => {
|
|
63
|
+
const anchor = String(block.anchorIndex).padStart(4, "0");
|
|
64
|
+
return `b${block.blockId} covers ${block.coveredIndices.length} message(s), anchored at m${anchor}`;
|
|
65
|
+
})
|
|
66
|
+
.join("\n");
|
|
67
|
+
}
|
|
68
|
+
case "status": {
|
|
69
|
+
const resolved = resolveDcpEnabled({ runtime: state.enabled, config: readConfigEnabled(cwd) });
|
|
70
|
+
const blocks = activeCompressedRanges(state);
|
|
71
|
+
const saved = Object.values(state.prune.tools).reduce((sum, chars) => sum + chars, 0);
|
|
72
|
+
return [
|
|
73
|
+
`dcp: ${resolved.enabled ? "on" : "off"} (source: ${resolved.source})`,
|
|
74
|
+
`messages tracked: ${Object.keys(state.messageIds.keyToRef).length}`,
|
|
75
|
+
`active blocks: ${blocks.length}`,
|
|
76
|
+
`pruned tool calls: ${Object.keys(state.prune.tools).length} (${formatChars(saved)} chars)`,
|
|
77
|
+
`compressed tokens total: ${state.stats.totalCompressTokens}`,
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
case "stats": {
|
|
81
|
+
const blocks = state.prune.messages.blocks;
|
|
82
|
+
const active = blocks.filter((block) => block.active).length;
|
|
83
|
+
const prunedTokens = Object.values(state.prune.tools).reduce((sum, chars) => sum + chars, 0);
|
|
84
|
+
return [
|
|
85
|
+
`blocks: ${active} active of ${blocks.length} total`,
|
|
86
|
+
`compressed tokens: ${state.stats.totalCompressTokens}`,
|
|
87
|
+
`pruned tool calls: ${Object.keys(state.prune.tools).length} (${formatChars(prunedTokens)} chars)`,
|
|
88
|
+
`messages tracked: ${Object.keys(state.messageIds.keyToRef).length}`,
|
|
89
|
+
`refs allocated: ${state.messageIds.nextRef - 1}`,
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
case "context": {
|
|
93
|
+
// What the next request carries. This is the question `/dcp` exists to
|
|
94
|
+
// answer: a compression changes the provider payload and nothing on
|
|
95
|
+
// screen, so "how much is being sent" is otherwise unknowable.
|
|
96
|
+
const blocks = activeCompressedRanges(state);
|
|
97
|
+
const covered = new Set();
|
|
98
|
+
for (const block of blocks)
|
|
99
|
+
for (const index of block.coveredIndices)
|
|
100
|
+
covered.add(index);
|
|
101
|
+
const tracked = Object.keys(state.messageIds.keyToRef).length;
|
|
102
|
+
const prunedChars = Object.values(state.prune.tools).reduce((sum, chars) => sum + chars, 0);
|
|
103
|
+
return [
|
|
104
|
+
`messages tracked: ${tracked}`,
|
|
105
|
+
`messages replaced by a block: ${covered.size}`,
|
|
106
|
+
`messages sent: ${Math.max(0, tracked - covered.size)}`,
|
|
107
|
+
`active blocks: ${blocks.length}`,
|
|
108
|
+
`compressed tokens total: ${state.stats.totalCompressTokens}`,
|
|
109
|
+
`pruned tool calls: ${Object.keys(state.prune.tools).length} (${formatChars(prunedChars)} chars)`,
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
case "decompress": {
|
|
113
|
+
// Reopening a block is what makes a compression safe to try: the summary
|
|
114
|
+
// is lossy by construction, and this is the way back. Blocks that a later
|
|
115
|
+
// block consumed are reopened BEFORE their consumer, so the consumer's
|
|
116
|
+
// inherited coverage is released first and the range it covered becomes
|
|
117
|
+
// visible again.
|
|
118
|
+
const target = args.trim().split(/\s+/)[1];
|
|
119
|
+
const match = target === undefined ? null : /^b?(\d+)$/i.exec(target);
|
|
120
|
+
if (!match)
|
|
121
|
+
return "dcp: give a block id, e.g. /dcp decompress b2";
|
|
122
|
+
const blockId = Number(match[1]);
|
|
123
|
+
const blocks = state.prune.messages.blocks;
|
|
124
|
+
const block = blocks.find((candidate) => candidate.blockId === blockId);
|
|
125
|
+
if (!block)
|
|
126
|
+
return `dcp: no block b${blockId}`;
|
|
127
|
+
// The consumer is checked BEFORE `active`. A block a later one consumed is
|
|
128
|
+
// already inactive, so testing `active` first answered "already open" for
|
|
129
|
+
// exactly the case that needs the other message - and the guard against
|
|
130
|
+
// reopening a covered range would never have run.
|
|
131
|
+
const parents = blocks.filter((candidate) => candidate.active && candidate.consumedBlockIds.includes(blockId));
|
|
132
|
+
if (parents.length > 0) {
|
|
133
|
+
const names = parents.map((candidate) => `b${candidate.blockId}`).join(", ");
|
|
134
|
+
return `dcp: b${blockId} was consumed by ${names}; decompress that one first`;
|
|
135
|
+
}
|
|
136
|
+
if (!block.active)
|
|
137
|
+
return `dcp: b${blockId} is already open`;
|
|
138
|
+
block.active = false;
|
|
139
|
+
block.deactivatedByUser = true;
|
|
140
|
+
block.deactivatedAt = Date.now();
|
|
141
|
+
for (const parentBlockId of block.parentBlockIds) {
|
|
142
|
+
const parent = blocks.find((candidate) => candidate.blockId === parentBlockId);
|
|
143
|
+
if (!parent)
|
|
144
|
+
continue;
|
|
145
|
+
parent.consumedBlockIds = parent.consumedBlockIds.filter((id) => id !== blockId);
|
|
146
|
+
}
|
|
147
|
+
saveDcpState(sessionId);
|
|
148
|
+
return `dcp: b${blockId} reopened; ${block.coveredIndices.length} message(s) are back`;
|
|
149
|
+
}
|
|
150
|
+
case "sweep": {
|
|
151
|
+
// A block whose covered refs no longer resolve cannot be rendered, so it
|
|
152
|
+
// contributes nothing but a stale entry in the state file. Sweeping it is
|
|
153
|
+
// safe for the same reason: there is nothing left for it to hide.
|
|
154
|
+
const refs = new Set(Object.keys(state.messageIds.refToKey).map(Number));
|
|
155
|
+
const blocks = state.prune.messages.blocks;
|
|
156
|
+
const before = blocks.length;
|
|
157
|
+
const orphaned = blocks.filter((block) => block.active && !block.coveredIndices.some((index) => refs.has(index)));
|
|
158
|
+
for (const block of orphaned) {
|
|
159
|
+
block.active = false;
|
|
160
|
+
block.deactivatedAt = Date.now();
|
|
161
|
+
}
|
|
162
|
+
state.prune.messages.blocks = blocks.filter((block) => block.active || block.deactivatedByUser);
|
|
163
|
+
const dropped = before - state.prune.messages.blocks.length;
|
|
164
|
+
saveDcpState(sessionId);
|
|
165
|
+
if (orphaned.length === 0)
|
|
166
|
+
return "dcp: nothing to sweep";
|
|
167
|
+
return `dcp: ${orphaned.length} orphaned block(s) closed, ${dropped} dropped from state`;
|
|
168
|
+
}
|
|
169
|
+
default:
|
|
170
|
+
return `dcp: unknown subcommand '${sub}'. try: ${Object.keys(DCP_USAGE).join(", ")}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compressed block state: the stored blocks, id allocation, and the wrapper
|
|
3
|
+
* that turns a summary into a self-contained block.
|
|
4
|
+
*
|
|
5
|
+
* Port of DCP's `lib/compress/state.ts`, trimmed to what the spec (§3.5, §3.6)
|
|
6
|
+
* asks for: no duration tracking, no tool-id bookkeeping, and no Map/Set state —
|
|
7
|
+
* everything here is plain JSON so `SessionState` round-trips through
|
|
8
|
+
* `~/.howtofix/agent/dcp/<session>.json` with a plain `JSON.stringify`/`parse`
|
|
9
|
+
* and no custom replacer. The active set and the anchor index are derived from
|
|
10
|
+
* `blocks` rather than stored, so there is no redundant index to desync.
|
|
11
|
+
*/
|
|
12
|
+
import { formatBlockRef, formatMessageIdTag } from "./ids.js";
|
|
13
|
+
export const COMPRESSED_BLOCK_HEADER = "[Compressed conversation section]";
|
|
14
|
+
/**
|
|
15
|
+
* Token estimate for stored text. DCP counts with a bundled tokenizer; we may
|
|
16
|
+
* not add a dependency, so use the same length/4 fallback DCP itself uses
|
|
17
|
+
* when its tokenizer throws.
|
|
18
|
+
*/
|
|
19
|
+
export function estimateSummaryTokens(text) {
|
|
20
|
+
return Math.round(text.length / 4);
|
|
21
|
+
}
|
|
22
|
+
/** Allocate the next block id, repairing a corrupted counter instead of emitting `b0`. */
|
|
23
|
+
export function allocateBlockId(state) {
|
|
24
|
+
const next = state.prune.messages.nextBlockId;
|
|
25
|
+
if (!Number.isInteger(next) || next < 1) {
|
|
26
|
+
state.prune.messages.nextBlockId = 2;
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
state.prune.messages.nextBlockId = next + 1;
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
/** Allocate the next compress run id; one tool call shares one run id. */
|
|
33
|
+
export function allocateRunId(state) {
|
|
34
|
+
const next = state.prune.messages.nextRunId;
|
|
35
|
+
if (!Number.isInteger(next) || next < 1) {
|
|
36
|
+
state.prune.messages.nextRunId = 2;
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
state.prune.messages.nextRunId = next + 1;
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Wrap a summary so the block is self-contained: the header marks it in
|
|
44
|
+
* context, the trailing tag names the block it belongs to.
|
|
45
|
+
*/
|
|
46
|
+
export function wrapCompressedSummary(blockId, summary, format = "xml") {
|
|
47
|
+
const footer = formatMessageIdTag(formatBlockRef(blockId, format), undefined, format);
|
|
48
|
+
const body = summary.trim();
|
|
49
|
+
if (body.length === 0) {
|
|
50
|
+
return `${COMPRESSED_BLOCK_HEADER}\n${footer}`;
|
|
51
|
+
}
|
|
52
|
+
return `${COMPRESSED_BLOCK_HEADER}\n${body}\n\n${footer}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Active blocks as the replacement view the prompt pass consumes: the anchor
|
|
56
|
+
* where the summary is placed, every ref index it replaces, and the text that
|
|
57
|
+
* replaces them.
|
|
58
|
+
*/
|
|
59
|
+
export function activeCompressedRanges(state) {
|
|
60
|
+
return state.prune.messages.blocks
|
|
61
|
+
.filter((block) => block.active)
|
|
62
|
+
.map((block) => ({
|
|
63
|
+
blockId: block.blockId,
|
|
64
|
+
anchorIndex: block.anchorIndex,
|
|
65
|
+
coveredIndices: block.coveredIndices,
|
|
66
|
+
summary: block.summary,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Attach a new compressed block: store it, fold the consumed blocks into it,
|
|
71
|
+
* and account for the content it replaced.
|
|
72
|
+
*
|
|
73
|
+
* Newness is measured against every block that was active before this call, so
|
|
74
|
+
* content already compressed into a consumed block is never counted twice —
|
|
75
|
+
* the same rule DCP applies through `wasActive`/`isNowActive`.
|
|
76
|
+
*/
|
|
77
|
+
export function applyCompressionState(state, input, selection, anchorIndex, blockId, summary, consumedBlockIds) {
|
|
78
|
+
const messagesState = state.prune.messages;
|
|
79
|
+
const consumed = [...new Set(consumedBlockIds.filter((id) => Number.isInteger(id) && id > 0))];
|
|
80
|
+
const preCovered = new Set();
|
|
81
|
+
for (const block of messagesState.blocks) {
|
|
82
|
+
if (!block.active) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const index of block.coveredIndices) {
|
|
86
|
+
preCovered.add(index);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const covered = new Set(selection.indices);
|
|
90
|
+
for (const consumedBlockId of consumed) {
|
|
91
|
+
const consumedBlock = messagesState.blocks.find((block) => block.blockId === consumedBlockId);
|
|
92
|
+
if (!consumedBlock) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
for (const index of consumedBlock.coveredIndices) {
|
|
96
|
+
covered.add(index);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const coveredIndices = [...covered].sort((left, right) => left - right);
|
|
100
|
+
const newlyCoveredIndices = coveredIndices.filter((index) => !preCovered.has(index));
|
|
101
|
+
let compressedTokens = 0;
|
|
102
|
+
for (const index of newlyCoveredIndices) {
|
|
103
|
+
compressedTokens += selection.tokenByIndex.get(index) ?? 0;
|
|
104
|
+
}
|
|
105
|
+
const block = {
|
|
106
|
+
blockId,
|
|
107
|
+
runId: input.runId,
|
|
108
|
+
active: true,
|
|
109
|
+
deactivatedByUser: false,
|
|
110
|
+
mode: input.mode,
|
|
111
|
+
topic: input.topic,
|
|
112
|
+
startId: input.startId,
|
|
113
|
+
endId: input.endId,
|
|
114
|
+
anchorIndex,
|
|
115
|
+
compressedTokens,
|
|
116
|
+
summaryTokens: estimateSummaryTokens(summary),
|
|
117
|
+
coveredIndices,
|
|
118
|
+
consumedBlockIds: consumed,
|
|
119
|
+
parentBlockIds: [],
|
|
120
|
+
createdAt: Date.now(),
|
|
121
|
+
summary,
|
|
122
|
+
};
|
|
123
|
+
messagesState.blocks.push(block);
|
|
124
|
+
const deactivatedAt = Date.now();
|
|
125
|
+
for (const consumedBlockId of consumed) {
|
|
126
|
+
const consumedBlock = messagesState.blocks.find((block) => block.blockId === consumedBlockId);
|
|
127
|
+
if (!consumedBlock || !consumedBlock.active) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
consumedBlock.active = false;
|
|
131
|
+
consumedBlock.deactivatedAt = deactivatedAt;
|
|
132
|
+
consumedBlock.deactivatedByBlockId = blockId;
|
|
133
|
+
if (!consumedBlock.parentBlockIds.includes(blockId)) {
|
|
134
|
+
consumedBlock.parentBlockIds.push(blockId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
state.stats.totalCompressTokens += compressedTokens;
|
|
138
|
+
return { compressedTokens, coveredIndices, newlyCoveredIndices };
|
|
139
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `compress` tool: validate a batch of ranges, resolve them against the
|
|
3
|
+
* live state and the last-assigned message snapshot, then store one block per
|
|
4
|
+
* range.
|
|
5
|
+
*
|
|
6
|
+
* Port of DCP's `lib/compress/range.ts` + `range-utils.ts` with the spec's
|
|
7
|
+
* stricter validation (§5): DCP silently repairs summaries whose placeholders
|
|
8
|
+
* are invented, duplicated, or missing — we reject them, so the preflight rule
|
|
9
|
+
* in `rangePrompt()` is enforced rather than hoped for. Validation runs in the
|
|
10
|
+
* order the spec fixes (args → overlap → placeholders → resolve) and completes
|
|
11
|
+
* before any mutation, so a rejected call leaves the state untouched.
|
|
12
|
+
*/
|
|
13
|
+
import { Type } from "@netrodev/hxf-ai";
|
|
14
|
+
import { defineTool } from "@netrodev/hxf-coding-agent";
|
|
15
|
+
import { formatBlockRef, formatMessageRef, parseBoundaryId } from "./ids.js";
|
|
16
|
+
import { COMPRESSED_BLOCK_HEADER, estimateSummaryTokens, allocateBlockId, allocateRunId, applyCompressionState, wrapCompressedSummary } from "./compress-state.js";
|
|
17
|
+
/** All `(bN)` placeholders in a summary, in document order. */
|
|
18
|
+
export function parseBlockPlaceholders(summary, format = "xml") {
|
|
19
|
+
const placeholders = [];
|
|
20
|
+
const regex = format === "compact" ? /@b([1-9]\d*)@/gi : /\(b(\d+)\)/gi;
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = regex.exec(summary)) !== null) {
|
|
23
|
+
const blockId = Number.parseInt(match[1], 10);
|
|
24
|
+
if (!Number.isInteger(blockId)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
placeholders.push({
|
|
28
|
+
raw: match[0],
|
|
29
|
+
blockId,
|
|
30
|
+
startIndex: match.index,
|
|
31
|
+
endIndex: match.index + match[0].length,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return placeholders;
|
|
35
|
+
}
|
|
36
|
+
export function validateArgs(args) {
|
|
37
|
+
if (typeof args.topic !== "string" || args.topic.trim().length === 0) {
|
|
38
|
+
throw new Error("topic is required and must be a non-empty string");
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(args.content) || args.content.length === 0) {
|
|
41
|
+
throw new Error("content is required and must be a non-empty array");
|
|
42
|
+
}
|
|
43
|
+
for (let index = 0; index < args.content.length; index++) {
|
|
44
|
+
const entry = args.content[index];
|
|
45
|
+
const prefix = `content[${index}]`;
|
|
46
|
+
if (typeof entry?.startId !== "string" || entry.startId.trim().length === 0) {
|
|
47
|
+
throw new Error(`${prefix}.startId is required and must be a non-empty string`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof entry?.endId !== "string" || entry.endId.trim().length === 0) {
|
|
50
|
+
throw new Error(`${prefix}.endId is required and must be a non-empty string`);
|
|
51
|
+
}
|
|
52
|
+
if (typeof entry?.summary !== "string" || entry.summary.trim().length === 0) {
|
|
53
|
+
throw new Error(`${prefix}.summary is required and must be a non-empty string`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Plan one range: resolve both boundaries to window indices, collect the
|
|
59
|
+
* window, the required blocks, and the token estimates.
|
|
60
|
+
*
|
|
61
|
+
* A message boundary resolves to its own ref index; a block boundary resolves
|
|
62
|
+
* to the block's anchor — the same rule DCP applies in `resolveBoundaryIds`,
|
|
63
|
+
* which is why consuming an end block pulls its full coverage into the new
|
|
64
|
+
* block.
|
|
65
|
+
*/
|
|
66
|
+
function planRange(state, entry, snapshot, format) {
|
|
67
|
+
const present = new Set();
|
|
68
|
+
const tokenByIndex = new Map();
|
|
69
|
+
for (const row of snapshot) {
|
|
70
|
+
present.add(row.index);
|
|
71
|
+
tokenByIndex.set(row.index, row.tokenCount ?? 0);
|
|
72
|
+
}
|
|
73
|
+
const start = boundaryIndex(state, present, entry.startId, "startId", format);
|
|
74
|
+
const end = boundaryIndex(state, present, entry.endId, "endId", format);
|
|
75
|
+
if (start.index > end.index) {
|
|
76
|
+
throw new Error(`startId ${start.ref} appears after endId ${end.ref} in the conversation. Start must come before end.`);
|
|
77
|
+
}
|
|
78
|
+
const indices = [];
|
|
79
|
+
for (const row of snapshot) {
|
|
80
|
+
if (row.index >= start.index && row.index <= end.index) {
|
|
81
|
+
indices.push(row.index);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const window = new Set(indices);
|
|
85
|
+
// Required = active blocks whose anchor sits inside the window; the anchor
|
|
86
|
+
// must also be present, so a block whose anchor fell out of context is not
|
|
87
|
+
// demanded from a model that cannot see it.
|
|
88
|
+
const requiredBlockIds = state.prune.messages.blocks.filter((block) => block.active && window.has(block.anchorIndex)).map((block) => block.blockId);
|
|
89
|
+
return {
|
|
90
|
+
startIdx: start.index,
|
|
91
|
+
endIdx: end.index,
|
|
92
|
+
indices,
|
|
93
|
+
requiredBlockIds,
|
|
94
|
+
tokenByIndex,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function boundaryIndex(state, present, id, role, format) {
|
|
98
|
+
const parsed = parseBoundaryId(id, format);
|
|
99
|
+
if (parsed === null) {
|
|
100
|
+
const messageExample = format === "compact" ? "@4@" : "mNNNN";
|
|
101
|
+
const blockExample = format === "compact" ? "@b1@" : "bN";
|
|
102
|
+
throw new Error(`${role} is invalid. Use an injected message ID (${messageExample}) or block ID (${blockExample}).`);
|
|
103
|
+
}
|
|
104
|
+
let index;
|
|
105
|
+
if (parsed.kind === "message") {
|
|
106
|
+
index = parsed.index;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const block = state.prune.messages.blocks.find((candidate) => candidate.blockId === parsed.blockId && candidate.active);
|
|
110
|
+
index = block ? block.anchorIndex : -1;
|
|
111
|
+
}
|
|
112
|
+
if (index < 0 || !present.has(index)) {
|
|
113
|
+
throw new Error(`${role} ${parsed.ref} is not available in the current conversation context. Choose an injected ID visible in context.`);
|
|
114
|
+
}
|
|
115
|
+
return { ref: parsed.ref, index };
|
|
116
|
+
}
|
|
117
|
+
/** Reject batches whose ranges overlap. Touching at a shared boundary counts as overlap. */
|
|
118
|
+
export function validateNonOverlapping(content, state, snapshot, format = "xml") {
|
|
119
|
+
const plans = content.map((raw, index) => {
|
|
120
|
+
const entry = {
|
|
121
|
+
startId: raw.startId.trim(),
|
|
122
|
+
endId: raw.endId.trim(),
|
|
123
|
+
summary: raw.summary,
|
|
124
|
+
};
|
|
125
|
+
return { index, entry, selection: planRange(state, entry, snapshot, format) };
|
|
126
|
+
});
|
|
127
|
+
const sortedPlans = [...plans].sort((left, right) => left.selection.startIdx - right.selection.startIdx || left.selection.endIdx - right.selection.endIdx || left.index - right.index);
|
|
128
|
+
const issues = [];
|
|
129
|
+
for (let index = 1; index < sortedPlans.length; index++) {
|
|
130
|
+
const previous = sortedPlans[index - 1];
|
|
131
|
+
const current = sortedPlans[index];
|
|
132
|
+
if (!previous || !current) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (current.selection.startIdx > previous.selection.endIdx) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
issues.push(`content[${previous.index}] (${previous.entry.startId}..${previous.entry.endId}) overlaps content[${current.index}] (${current.entry.startId}..${current.entry.endId}). Overlapping ranges cannot be compressed in the same batch.`);
|
|
139
|
+
}
|
|
140
|
+
if (issues.length > 0) {
|
|
141
|
+
throw new Error(issues.length === 1 ? issues[0] : issues.map((issue) => `- ${issue}`).join("\n"));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Enforce the preflight rule from `rangePrompt()`: the placeholders in a
|
|
146
|
+
* summary must be exactly the required set — nothing invented, nothing
|
|
147
|
+
* duplicated, nothing missing. Returns each entry's parsed placeholders in
|
|
148
|
+
* document order, ready for expansion.
|
|
149
|
+
*/
|
|
150
|
+
export function validateSummaryPlaceholders(content, state, snapshot, format = "xml") {
|
|
151
|
+
const activeBlockIds = new Set(state.prune.messages.blocks.filter((block) => block.active).map((block) => block.blockId));
|
|
152
|
+
return content.map((raw, index) => {
|
|
153
|
+
const entry = {
|
|
154
|
+
startId: raw.startId.trim(),
|
|
155
|
+
endId: raw.endId.trim(),
|
|
156
|
+
summary: raw.summary,
|
|
157
|
+
};
|
|
158
|
+
const selection = planRange(state, entry, snapshot, format);
|
|
159
|
+
const required = new Set(selection.requiredBlockIds);
|
|
160
|
+
const parsed = parseBlockPlaceholders(entry.summary, format);
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
const prefix = `content[${index}]`;
|
|
163
|
+
for (const placeholder of parsed) {
|
|
164
|
+
if (seen.has(placeholder.blockId)) {
|
|
165
|
+
throw new Error(`${prefix}.summary duplicates placeholder ${placeholder.raw}`);
|
|
166
|
+
}
|
|
167
|
+
seen.add(placeholder.blockId);
|
|
168
|
+
if (!required.has(placeholder.blockId)) {
|
|
169
|
+
throw new Error(activeBlockIds.has(placeholder.blockId) ? `${prefix}.summary references ${placeholder.raw}, which is outside the selected range` : `${prefix}.summary references ${placeholder.raw}, which is not an active compressed block`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const blockId of selection.requiredBlockIds) {
|
|
173
|
+
if (!seen.has(blockId)) {
|
|
174
|
+
const token = format === "compact" ? `@b${blockId}@` : `(b${blockId})`;
|
|
175
|
+
throw new Error(`${prefix}.summary is missing the required placeholder ${token}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return parsed;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Resolve every entry into a windowed selection; the last step before applying. */
|
|
182
|
+
export function resolveRanges(content, state, snapshot, format = "xml") {
|
|
183
|
+
return content.map((raw, index) => {
|
|
184
|
+
const entry = {
|
|
185
|
+
startId: raw.startId.trim(),
|
|
186
|
+
endId: raw.endId.trim(),
|
|
187
|
+
summary: raw.summary,
|
|
188
|
+
};
|
|
189
|
+
const selection = planRange(state, entry, snapshot, format);
|
|
190
|
+
return { index, entry, selection, anchorIndex: selection.startIdx };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Strip a stored block's header and trailing id tag, leaving its body. */
|
|
194
|
+
function restoreSummary(summary) {
|
|
195
|
+
const headerMatch = summary.match(/^\s*\[Compressed conversation(?: section)?(?: b\d+)?\]/i);
|
|
196
|
+
if (!headerMatch) {
|
|
197
|
+
return summary;
|
|
198
|
+
}
|
|
199
|
+
const afterHeader = summary.slice(headerMatch[0].length).replace(/^(?:\r?\n)+/, "");
|
|
200
|
+
return afterHeader
|
|
201
|
+
.replace(/(?:\r?\n)*<dcp-message-id>b\d+<\/dcp-message-id>\s*$/i, "")
|
|
202
|
+
.replace(/(?:\r?\n)*@b[1-9]\d*@\s*$/i, "")
|
|
203
|
+
.replace(/(?:\r?\n)+$/, "");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Replace each placeholder with the stored block's body so the new block is
|
|
207
|
+
* self-contained, and report which blocks it consumes. Boundary handling needs
|
|
208
|
+
* no append path here: validation guarantees the placeholder set is exactly
|
|
209
|
+
* the required set, so an unreferenced boundary block cannot occur.
|
|
210
|
+
*/
|
|
211
|
+
function expandPlaceholders(summary, parsed, state) {
|
|
212
|
+
if (parsed.length === 0) {
|
|
213
|
+
return { expanded: summary, consumedBlockIds: [] };
|
|
214
|
+
}
|
|
215
|
+
let expanded = "";
|
|
216
|
+
let cursor = 0;
|
|
217
|
+
const consumedBlockIds = [];
|
|
218
|
+
for (const placeholder of parsed) {
|
|
219
|
+
const target = state.prune.messages.blocks.find((block) => block.blockId === placeholder.blockId && block.active);
|
|
220
|
+
if (!target) {
|
|
221
|
+
throw new Error(`Compressed block not found: ${placeholder.raw}`);
|
|
222
|
+
}
|
|
223
|
+
expanded += summary.slice(cursor, placeholder.startIndex);
|
|
224
|
+
expanded += restoreSummary(target.summary);
|
|
225
|
+
cursor = placeholder.endIndex;
|
|
226
|
+
consumedBlockIds.push(placeholder.blockId);
|
|
227
|
+
}
|
|
228
|
+
expanded += summary.slice(cursor);
|
|
229
|
+
return { expanded, consumedBlockIds };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The compress execution shared by every entry point: validate, resolve, then
|
|
233
|
+
* per range allocate a block id, expand placeholders, wrap the summary, and
|
|
234
|
+
* attach the block to its anchor message. Mutates `state` in place; every
|
|
235
|
+
* validation failure throws before the first mutation.
|
|
236
|
+
*/
|
|
237
|
+
export function compressRanges(args, state, snapshot, format = "xml") {
|
|
238
|
+
validateArgs(args);
|
|
239
|
+
validateNonOverlapping(args.content, state, snapshot, format);
|
|
240
|
+
const placeholdersByEntry = validateSummaryPlaceholders(args.content, state, snapshot, format);
|
|
241
|
+
const plans = resolveRanges(args.content, state, snapshot, format);
|
|
242
|
+
const runId = allocateRunId(state);
|
|
243
|
+
const outcomes = [];
|
|
244
|
+
for (const [position, plan] of plans.entries()) {
|
|
245
|
+
const parsed = placeholdersByEntry[position] ?? [];
|
|
246
|
+
const { expanded, consumedBlockIds } = expandPlaceholders(plan.entry.summary, parsed, state);
|
|
247
|
+
const blockId = allocateBlockId(state);
|
|
248
|
+
const wrapped = wrapCompressedSummary(blockId, expanded, format);
|
|
249
|
+
const applied = applyCompressionState(state, {
|
|
250
|
+
topic: args.topic,
|
|
251
|
+
startId: plan.entry.startId,
|
|
252
|
+
endId: plan.entry.endId,
|
|
253
|
+
mode: "range",
|
|
254
|
+
runId,
|
|
255
|
+
}, plan.selection, plan.anchorIndex, blockId, wrapped, consumedBlockIds);
|
|
256
|
+
outcomes.push({
|
|
257
|
+
blockId,
|
|
258
|
+
runId,
|
|
259
|
+
coveredCount: applied.coveredIndices.length,
|
|
260
|
+
compressedTokens: applied.compressedTokens,
|
|
261
|
+
// The same estimator `applyCompressionState` used to store it, so the
|
|
262
|
+
// figure reported here and the figure in the block cannot disagree.
|
|
263
|
+
summaryTokens: estimateSummaryTokens(wrapped),
|
|
264
|
+
expandedSummary: expanded,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return outcomes;
|
|
268
|
+
}
|
|
269
|
+
/** The model-facing tool. The wiring supplies state and the ref snapshot. */
|
|
270
|
+
export function createCompressTool(deps) {
|
|
271
|
+
const format = deps.format ?? "xml";
|
|
272
|
+
const messageExample = formatMessageRef(1, format);
|
|
273
|
+
const blockExample = formatBlockRef(2, format);
|
|
274
|
+
return defineTool({
|
|
275
|
+
name: "compress",
|
|
276
|
+
label: "compress",
|
|
277
|
+
description: "Collapse conversation ranges into stored compressed summaries, each marked " +
|
|
278
|
+
`with "${COMPRESSED_BLOCK_HEADER}". Copy startId and endId from the injected IDs in context ` +
|
|
279
|
+
`(message IDs like ${messageExample}, block IDs like ${blockExample}); startId must come before endId. ` +
|
|
280
|
+
"Ranges in one call must not overlap, and each summary must reference every required " +
|
|
281
|
+
"block placeholder exactly once.",
|
|
282
|
+
promptSnippet: "compress: replace a conversation range with a stored summary",
|
|
283
|
+
parameters: Type.Object({
|
|
284
|
+
topic: Type.String({
|
|
285
|
+
description: "Short label (3-5 words) for display - e.g., 'Auth System Exploration'",
|
|
286
|
+
}),
|
|
287
|
+
content: Type.Array(Type.Object({
|
|
288
|
+
startId: Type.String({
|
|
289
|
+
description: `Message or block ID marking the beginning of range (e.g. ${messageExample}, ${blockExample})`,
|
|
290
|
+
}),
|
|
291
|
+
endId: Type.String({
|
|
292
|
+
description: `Message or block ID marking the end of range (e.g. ${formatMessageRef(12, format)}, ${formatBlockRef(5, format)})`,
|
|
293
|
+
}),
|
|
294
|
+
summary: Type.String({
|
|
295
|
+
description: "Complete technical summary replacing all content in range",
|
|
296
|
+
}),
|
|
297
|
+
}), { description: "One or more ranges to compress, each with start/end boundaries and a summary" }),
|
|
298
|
+
}),
|
|
299
|
+
async execute(_toolCallId, params) {
|
|
300
|
+
try {
|
|
301
|
+
const outcomes = compressRanges(params, deps.state(), deps.messages(), format);
|
|
302
|
+
const blockIds = outcomes.map((outcome) => outcome.blockId);
|
|
303
|
+
const messages = outcomes.reduce((sum, outcome) => sum + outcome.coveredCount, 0);
|
|
304
|
+
const compressedTokens = outcomes.reduce((sum, outcome) => sum + outcome.compressedTokens, 0);
|
|
305
|
+
const summaryTokens = outcomes.reduce((sum, outcome) => sum + outcome.summaryTokens, 0);
|
|
306
|
+
const saved = Math.max(0, compressedTokens - summaryTokens);
|
|
307
|
+
return {
|
|
308
|
+
content: [
|
|
309
|
+
{
|
|
310
|
+
type: "text",
|
|
311
|
+
// The saving belongs in the result the model reads too: it is the
|
|
312
|
+
// reason to compress again rather than avoid the tool.
|
|
313
|
+
text: `Compressed ${messages} messages into ${COMPRESSED_BLOCK_HEADER}` + (compressedTokens > 0 ? ` - ${compressedTokens} tokens replaced by ${summaryTokens}, saving ${saved}.` : "."),
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
details: { blockIds, runId: outcomes[0]?.runId, messages, compressedTokens, summaryTokens },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
321
|
+
return {
|
|
322
|
+
content: [{ type: "text", text: message }],
|
|
323
|
+
details: { error: message },
|
|
324
|
+
isError: true,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|