openmausbot 0.1.69 → 0.1.71
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/assets/{index-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
- package/dist/assets/index-CYLdYtYp.css +1 -0
- package/dist/assets/index-Di4ovwu5.js +289 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +6 -2
- package/dist-server/drivers/agents-proxy.js +305 -16
- package/dist-server/drivers/pi-mcp-extension.ts +1 -1
- package/dist-server/index.js +3141 -1124
- package/dist-server/local-computer-proxy.js +2 -0
- package/dist-server/local-computer.js +2 -1
- package/dist-server/mcp-gate.js +543 -0
- package/dist-server/mcp-server.js +38 -0
- package/dist-server/openmausbot.js +772 -260
- package/dist-server/pair-cli.js +772 -260
- package/dist-server/prepare-cloudflared.js +20 -3
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +29 -0
- package/dist-server/server/bot-package.js +55 -2
- package/dist-server/server/browser-live.js +12 -7
- package/dist-server/server/config.js +40 -4
- package/dist-server/server/delegations.js +132 -74
- package/dist-server/server/drivers/acp/core.js +2 -0
- package/dist-server/server/drivers/acp/cursor.js +1 -1
- package/dist-server/server/drivers/acp/grok.js +5 -1
- package/dist-server/server/drivers/agents-proxy.js +324 -16
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude-login-auth.js +102 -9
- package/dist-server/server/drivers/claude.js +298 -21
- package/dist-server/server/drivers/codex.js +13 -4
- package/dist-server/server/drivers/pi.js +1 -1
- package/dist-server/server/engine-install.js +137 -0
- package/dist-server/server/env-path.js +12 -0
- package/dist-server/server/harness/bus.js +1 -1
- package/dist-server/server/harness/registry.js +31 -6
- package/dist-server/server/index.js +860 -215
- package/dist-server/server/local-computer.js +1 -1
- package/dist-server/server/mcp-gate-config.js +56 -0
- package/dist-server/server/mcp-gate.js +227 -0
- package/dist-server/server/mcp-trim.js +182 -0
- package/dist-server/server/memory-journal.js +440 -0
- package/dist-server/server/memory-store.js +308 -0
- package/dist-server/server/message-db.js +90 -3
- package/dist-server/server/package-export.js +25 -0
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/peer-provenance.js +6 -2
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/redact.js +15 -0
- package/dist-server/server/resume-recovery.js +46 -0
- package/dist-server/server/routines.js +116 -55
- package/dist-server/server/screen-frame-gate.js +9 -6
- package/dist-server/server/skill-library.js +5 -5
- package/dist-server/server/steer-queue.js +20 -1
- package/dist-server/server/store.js +29 -7
- package/dist-server/server/system-prompt.js +14 -1
- package/dist-server/server/team-backup.js +53 -4
- package/dist-server/server/tool-summary.js +39 -0
- package/dist-server/server/tts/speech-text.js +3 -1
- package/dist-server/server/turn-context.js +18 -0
- package/dist-server/server/workspace.js +321 -22
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/team-backup.js +14 -2
- package/dist-server/vps-container-mcp.js +6 -2
- package/package.json +1 -1
- package/skills/create-verification-skill/SKILL.md +2 -3
- package/dist/assets/index-COp5lMnv.js +0 -279
- package/dist/assets/index-Jk7TIeyW.css +0 -1
|
@@ -273,7 +273,7 @@ export function readCuaConnection({ platform = process.platform, userData = proc
|
|
|
273
273
|
candidates.push(join(home, "Library", "Application Support", directory, "cua-connection.json"));
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
-
for (const file of
|
|
276
|
+
for (const file of new Set(candidates)) {
|
|
277
277
|
try {
|
|
278
278
|
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
279
279
|
if (platform === "linux") {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Turning a bot's own MCP server into a gated one.
|
|
2
|
+
//
|
|
3
|
+
// Shared by every driver that mounts external MCP servers, so the rule about
|
|
4
|
+
// what a tool may put into a model's context is written once. The gate itself
|
|
5
|
+
// is mcp-gate.ts; the policy it applies is mcp-trim.ts.
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { DATA_DIR } from "./config.js";
|
|
8
|
+
import { DEFAULT_RESULT_BUDGET } from "./mcp-trim.js";
|
|
9
|
+
import { SPAWNED_PROXIES } from "./proxy-paths.js";
|
|
10
|
+
/** Characters of a single tool result allowed into context, or 0 to mount
|
|
11
|
+
* bot servers directly as before. `OMB_MCP_RESULT_BUDGET=0` is the escape
|
|
12
|
+
* hatch for a bot that genuinely needs whole payloads in the conversation. */
|
|
13
|
+
export function resultBudget(env = process.env) {
|
|
14
|
+
const raw = env.OMB_MCP_RESULT_BUDGET;
|
|
15
|
+
if (raw === undefined || raw === "")
|
|
16
|
+
return DEFAULT_RESULT_BUDGET;
|
|
17
|
+
const parsed = Number(raw);
|
|
18
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : DEFAULT_RESULT_BUDGET;
|
|
19
|
+
}
|
|
20
|
+
/** Where a thread's oversized results are kept so the bot can read them back.
|
|
21
|
+
* Under the app's data directory, not the user's project folder: these are
|
|
22
|
+
* the harness's spill, and it sweeps them after a day. */
|
|
23
|
+
export function spillDir(threadId) {
|
|
24
|
+
return join(DATA_DIR, "tool-results", threadId.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 80) || "thread");
|
|
25
|
+
}
|
|
26
|
+
/** The gated form of one bot-owned server, or null to mount it unchanged.
|
|
27
|
+
*
|
|
28
|
+
* Only a stdio server can be gated: the gate stands between two processes,
|
|
29
|
+
* and there is no process to stand between for an http/sse entry. Those are
|
|
30
|
+
* mounted as they were — a known gap, not a silent one.
|
|
31
|
+
*
|
|
32
|
+
* The upstream spec travels in the gate's `env`, which means it travels inside
|
|
33
|
+
* the same 0600 MCP config file the driver already writes for exactly this
|
|
34
|
+
* reason: a server's credentials must never reach argv, where `ps` shows them
|
|
35
|
+
* to every process on the machine. */
|
|
36
|
+
export function gateServer(input) {
|
|
37
|
+
const { name, server, budget } = input;
|
|
38
|
+
if (budget <= 0)
|
|
39
|
+
return null;
|
|
40
|
+
if (!server || typeof server !== "object" || Array.isArray(server))
|
|
41
|
+
return null;
|
|
42
|
+
const spec = server;
|
|
43
|
+
if (typeof spec.command !== "string" || !spec.command)
|
|
44
|
+
return null;
|
|
45
|
+
return {
|
|
46
|
+
command: input.execPath ?? process.execPath,
|
|
47
|
+
args: [SPAWNED_PROXIES.mcpGate],
|
|
48
|
+
env: {
|
|
49
|
+
...input.nodeEnv,
|
|
50
|
+
OMB_GATE_NAME: name,
|
|
51
|
+
OMB_GATE_UPSTREAM: JSON.stringify({ command: spec.command, args: spec.args ?? [], env: spec.env ?? {} }),
|
|
52
|
+
OMB_GATE_SPILL_DIR: spillDir(input.threadId),
|
|
53
|
+
OMB_GATE_BUDGET: String(budget),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// A pass-through MCP server that keeps one tool result from eating a
|
|
2
|
+
// conversation.
|
|
3
|
+
//
|
|
4
|
+
// The provider CLI mounts this instead of the bot's real MCP server. Every
|
|
5
|
+
// JSON-RPC frame is relayed in both directions untouched, except the response
|
|
6
|
+
// to a `tools/call`: an oversized result is cut to a budget (mcp-trim.ts), the
|
|
7
|
+
// untrimmed text is written to a file, and the model is told in the result
|
|
8
|
+
// where that file is so it can read or grep the rest with its ordinary tools.
|
|
9
|
+
//
|
|
10
|
+
// Why here and not in the driver: on every vendor-CLI engine the tool call and
|
|
11
|
+
// its result never pass through the harness at all. The CLI runs the server
|
|
12
|
+
// itself and appends the raw answer to the session it owns. Standing between
|
|
13
|
+
// the two processes is the only place the harness can see, or shrink, what a
|
|
14
|
+
// tool puts into the model's context.
|
|
15
|
+
//
|
|
16
|
+
// stdout is the MCP transport. Never log there.
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
import { resolveCliSpawn } from "./env-path.js";
|
|
22
|
+
import { DEFAULT_RESULT_BUDGET, trimResultText, trimStructured } from "./mcp-trim.js";
|
|
23
|
+
import { killCliTree } from "./procs.js";
|
|
24
|
+
const NAME = process.env.OMB_GATE_NAME || "mcp";
|
|
25
|
+
const SPILL_DIR = process.env.OMB_GATE_SPILL_DIR || "";
|
|
26
|
+
const BUDGET = Number(process.env.OMB_GATE_BUDGET) > 0 ? Number(process.env.OMB_GATE_BUDGET) : DEFAULT_RESULT_BUDGET;
|
|
27
|
+
/** Spilled results older than this are swept at startup: they exist for the
|
|
28
|
+
* turn that produced them, not forever. */
|
|
29
|
+
const SPILL_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
30
|
+
/** Whether the model is told where the untrimmed result was saved. Off by
|
|
31
|
+
* default: offering the path measured WORSE than no trimming, because the
|
|
32
|
+
* model reads the file back in. See TrimInput.spillHint. */
|
|
33
|
+
const SPILL_HINT = process.env.OMB_GATE_SPILL_HINT === "1";
|
|
34
|
+
/** The gate's own settings never reach the upstream server's environment. */
|
|
35
|
+
const GATE_ENV_KEYS = ["OMB_GATE_NAME", "OMB_GATE_SPILL_DIR", "OMB_GATE_BUDGET", "OMB_GATE_UPSTREAM", "OMB_GATE_SPILL_HINT"];
|
|
36
|
+
function fail(message) {
|
|
37
|
+
process.stderr.write(`mcp-gate(${NAME}): ${message}\n`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
function upstreamSpec() {
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(process.env.OMB_GATE_UPSTREAM ?? "");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
fail("OMB_GATE_UPSTREAM is not valid JSON");
|
|
47
|
+
}
|
|
48
|
+
const spec = parsed;
|
|
49
|
+
if (!spec || typeof spec !== "object" || typeof spec.command !== "string" || !spec.command) {
|
|
50
|
+
fail("OMB_GATE_UPSTREAM needs a command");
|
|
51
|
+
}
|
|
52
|
+
return spec;
|
|
53
|
+
}
|
|
54
|
+
/** Delete spilled results older than SPILL_MAX_AGE_MS. Best effort: a sweep
|
|
55
|
+
* that fails must never stop the bot's tools from working. */
|
|
56
|
+
function sweepSpill(dir) {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const cutoff = Date.now() - SPILL_MAX_AGE_MS;
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
try {
|
|
67
|
+
const path = join(dir, entry);
|
|
68
|
+
if (statSync(path).mtimeMs < cutoff)
|
|
69
|
+
rmSync(path, { force: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* another gate may be sweeping the same directory */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
let spilled = 0;
|
|
77
|
+
/** Save the untrimmed text and return its path, or undefined when there is
|
|
78
|
+
* nowhere to put it — the trim still happens, the model is just told the rest
|
|
79
|
+
* was discarded rather than where to find it. */
|
|
80
|
+
function spill(tool, text) {
|
|
81
|
+
if (!SPILL_DIR)
|
|
82
|
+
return undefined;
|
|
83
|
+
const safeTool = tool.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 60) || "tool";
|
|
84
|
+
const path = join(SPILL_DIR, `${Date.now()}-${process.pid}-${spilled++}-${safeTool}.json`);
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(SPILL_DIR, { recursive: true, mode: 0o700 });
|
|
87
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
process.stderr.write(`mcp-gate(${NAME}): could not save the full result: ${String(error)}\n`);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Rewrite one `tools/call` result in place. Returns true when anything was
|
|
96
|
+
* actually trimmed, so the caller can report it on stderr. */
|
|
97
|
+
function trimCallResult(result, tool) {
|
|
98
|
+
const content = result.content;
|
|
99
|
+
if (!Array.isArray(content))
|
|
100
|
+
return false;
|
|
101
|
+
// The text blocks are what a provider puts in the model's context, and a
|
|
102
|
+
// server that answers with several is answering with one payload split up,
|
|
103
|
+
// so they share the budget rather than each getting it.
|
|
104
|
+
const textBlocks = content.filter((block) => Boolean(block) && typeof block === "object" && block.type === "text" && typeof block.text === "string");
|
|
105
|
+
const total = textBlocks.reduce((sum, block) => sum + block.text.length, 0);
|
|
106
|
+
if (!textBlocks.length || total <= BUDGET)
|
|
107
|
+
return false;
|
|
108
|
+
const share = Math.floor(BUDGET / textBlocks.length);
|
|
109
|
+
const path = spill(tool, textBlocks.map((block) => block.text).join("\n"));
|
|
110
|
+
let trimmed = false;
|
|
111
|
+
for (const block of textBlocks) {
|
|
112
|
+
const outcome = trimResultText({ text: block.text, budget: share, spillPath: path, spillHint: SPILL_HINT, toolName: tool });
|
|
113
|
+
if (!outcome.trimmed)
|
|
114
|
+
continue;
|
|
115
|
+
block.text = outcome.text;
|
|
116
|
+
trimmed = true;
|
|
117
|
+
}
|
|
118
|
+
// A tool that also answers with structuredContent would otherwise hand the
|
|
119
|
+
// provider a second, full copy of everything just cut. Trim it the same way
|
|
120
|
+
// — structurally only, so it stays valid against the tool's output schema —
|
|
121
|
+
// and leave it untouched when it cannot be cut without mangling it.
|
|
122
|
+
if (trimmed && result.structuredContent && typeof result.structuredContent === "object") {
|
|
123
|
+
const structured = trimStructured(result.structuredContent, BUDGET);
|
|
124
|
+
if (structured)
|
|
125
|
+
result.structuredContent = structured.value;
|
|
126
|
+
}
|
|
127
|
+
return trimmed;
|
|
128
|
+
}
|
|
129
|
+
const spec = upstreamSpec();
|
|
130
|
+
if (SPILL_DIR)
|
|
131
|
+
sweepSpill(SPILL_DIR);
|
|
132
|
+
const childEnv = { ...process.env, ...spec.env };
|
|
133
|
+
for (const key of GATE_ENV_KEYS)
|
|
134
|
+
delete childEnv[key];
|
|
135
|
+
// The CLI used to spawn this server itself, on every platform, so the gate
|
|
136
|
+
// has to spawn it exactly as well. On Windows CreateProcess cannot exec an
|
|
137
|
+
// npm .cmd shim or a node-shebang script, which is what `npx -y mcp-remote`
|
|
138
|
+
// is — resolveCliSpawn rewrites it to the real executable without a shell, so
|
|
139
|
+
// quoting-sensitive JSON argv survives. A shell here would re-interpret the
|
|
140
|
+
// server's own arguments.
|
|
141
|
+
const resolved = resolveCliSpawn(spec.command, spec.args ?? []);
|
|
142
|
+
const child = spawn(resolved.command, resolved.args, {
|
|
143
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
144
|
+
env: childEnv,
|
|
145
|
+
shell: false,
|
|
146
|
+
// a console app spawned from the desktop shell flashes a window otherwise
|
|
147
|
+
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
|
148
|
+
});
|
|
149
|
+
// The gate owns this process. If the gate is killed rather than closed —
|
|
150
|
+
// the CLI reaping its MCP servers at the end of a turn — the real server
|
|
151
|
+
// must not be left behind, and on Windows only taskkill /T reaps a tree.
|
|
152
|
+
let reaping = false;
|
|
153
|
+
const reapChild = () => {
|
|
154
|
+
if (reaping)
|
|
155
|
+
return;
|
|
156
|
+
reaping = true;
|
|
157
|
+
void killCliTree(child);
|
|
158
|
+
};
|
|
159
|
+
process.on("SIGTERM", () => {
|
|
160
|
+
reapChild();
|
|
161
|
+
process.exit(0);
|
|
162
|
+
});
|
|
163
|
+
process.on("SIGINT", () => {
|
|
164
|
+
reapChild();
|
|
165
|
+
process.exit(0);
|
|
166
|
+
});
|
|
167
|
+
process.on("exit", reapChild);
|
|
168
|
+
child.on("error", (error) => {
|
|
169
|
+
process.stderr.write(`mcp-gate(${NAME}): could not start ${resolved.command}: ${String(error)}\n`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
|
172
|
+
child.stderr.pipe(process.stderr);
|
|
173
|
+
/** id -> tool name, for the calls whose answers are still in flight. */
|
|
174
|
+
const pending = new Map();
|
|
175
|
+
// client -> server: verbatim, but remember which ids are tool calls
|
|
176
|
+
createInterface({ input: process.stdin }).on("line", (line) => {
|
|
177
|
+
if (line.trim()) {
|
|
178
|
+
try {
|
|
179
|
+
const message = JSON.parse(line);
|
|
180
|
+
if (message.method === "tools/call" && message.id !== undefined && message.id !== null) {
|
|
181
|
+
const params = message.params;
|
|
182
|
+
pending.set(String(message.id), typeof params?.name === "string" ? params.name : "tool");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* not our business to validate the client's frames */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
child.stdin.write(`${line}\n`);
|
|
190
|
+
});
|
|
191
|
+
process.stdin.on("end", () => child.stdin.end());
|
|
192
|
+
// server -> client: the one direction that gets rewritten
|
|
193
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
194
|
+
if (!line.trim())
|
|
195
|
+
return;
|
|
196
|
+
let message;
|
|
197
|
+
try {
|
|
198
|
+
message = JSON.parse(line);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Not JSON the gate understands. Relay it exactly as it came: a frame the
|
|
202
|
+
// gate cannot read is still the upstream server's answer to give.
|
|
203
|
+
process.stdout.write(`${line}\n`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const id = message.id === undefined || message.id === null ? undefined : String(message.id);
|
|
207
|
+
const tool = id === undefined ? undefined : pending.get(id);
|
|
208
|
+
if (id !== undefined)
|
|
209
|
+
pending.delete(id);
|
|
210
|
+
const result = message.result;
|
|
211
|
+
if (tool && result && typeof result === "object" && !Array.isArray(result)) {
|
|
212
|
+
try {
|
|
213
|
+
if (trimCallResult(result, tool)) {
|
|
214
|
+
process.stderr.write(`mcp-gate(${NAME}): trimmed ${tool} to ${BUDGET} chars\n`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// A result the trimmer chokes on is relayed whole. Costing context is
|
|
219
|
+
// recoverable; dropping a tool answer is not.
|
|
220
|
+
process.stderr.write(`mcp-gate(${NAME}): could not trim ${tool}: ${String(error)}\n`);
|
|
221
|
+
process.stdout.write(`${line}\n`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
226
|
+
});
|
|
227
|
+
child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// How much of one MCP tool result is allowed into a model's context.
|
|
2
|
+
//
|
|
3
|
+
// An MCP server answers for a machine, not for a context window: a single
|
|
4
|
+
// Swiggy product search returns 60-140 KB of JSON, a restaurant menu 70-105 KB.
|
|
5
|
+
// Whatever comes back is appended to the conversation the CLI keeps, and every
|
|
6
|
+
// later model call in that session re-reads it. Four searches in one turn added
|
|
7
|
+
// 67,000 tokens to a food-ordering thread that never needed them again.
|
|
8
|
+
//
|
|
9
|
+
// So the gate keeps a readable prefix and says, in the result itself, exactly
|
|
10
|
+
// what it cut and where the whole thing is. Nothing is lost — the untrimmed
|
|
11
|
+
// text is written to a file the bot can read or grep with its ordinary tools.
|
|
12
|
+
//
|
|
13
|
+
// This module is pure. The process that uses it is mcp-gate.ts.
|
|
14
|
+
/** Characters of a single tool result that may enter the model's context.
|
|
15
|
+
* ~4 chars per token, so ~2k tokens: enough for a page of results, far below
|
|
16
|
+
* the 35k-token searches this exists to stop. */
|
|
17
|
+
export const DEFAULT_RESULT_BUDGET = 8_000;
|
|
18
|
+
/** Never cut below this, whatever the budget says: a result so short that it
|
|
19
|
+
* cannot carry a single record is worse than no trimming at all. */
|
|
20
|
+
const MIN_BUDGET = 512;
|
|
21
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
22
|
+
/** JSON.stringify, or null for a value that cannot be serialized (a cycle). */
|
|
23
|
+
function serialize(value) {
|
|
24
|
+
try {
|
|
25
|
+
const json = JSON.stringify(value);
|
|
26
|
+
return typeof json === "string" ? json : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The longest prefix of `items` whose serialized length fits `budget`.
|
|
33
|
+
* Always returns at least one element when one exists, so a caller can tell
|
|
34
|
+
* "one huge record" apart from "nothing fit" and fall back accordingly. */
|
|
35
|
+
function fitArray(items, budget) {
|
|
36
|
+
const kept = [];
|
|
37
|
+
let used = 2; // the brackets
|
|
38
|
+
for (const item of items) {
|
|
39
|
+
const json = serialize(item);
|
|
40
|
+
if (json === null)
|
|
41
|
+
break;
|
|
42
|
+
const cost = json.length + (kept.length ? 1 : 0); // the comma
|
|
43
|
+
if (kept.length && used + cost > budget)
|
|
44
|
+
break;
|
|
45
|
+
kept.push(item);
|
|
46
|
+
used += cost;
|
|
47
|
+
}
|
|
48
|
+
return kept;
|
|
49
|
+
}
|
|
50
|
+
/** Top-level array fields, in declaration order — where an MCP server puts
|
|
51
|
+
* its bulk. A bare array root is reported as the single field "". */
|
|
52
|
+
function arrayFields(value) {
|
|
53
|
+
if (Array.isArray(value))
|
|
54
|
+
return [{ key: "", items: value }];
|
|
55
|
+
if (!value || typeof value !== "object")
|
|
56
|
+
return [];
|
|
57
|
+
return Object.entries(value)
|
|
58
|
+
.filter((entry) => Array.isArray(entry[1]))
|
|
59
|
+
.map(([key, items]) => ({ key, items }));
|
|
60
|
+
}
|
|
61
|
+
/** Cut on a character boundary, never mid-surrogate-pair — half an emoji is
|
|
62
|
+
* an invalid string that some providers reject outright. */
|
|
63
|
+
function cutAt(text, chars) {
|
|
64
|
+
const cut = text.slice(0, Math.max(0, chars));
|
|
65
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
66
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
67
|
+
}
|
|
68
|
+
function marker(input) {
|
|
69
|
+
const counts = input.dropped
|
|
70
|
+
.filter((d) => d.kept < d.total)
|
|
71
|
+
.map((d) => `${d.key || "items"} ${fmt(d.kept)} of ${fmt(d.total)}`)
|
|
72
|
+
.join(", ");
|
|
73
|
+
const what = input.structural
|
|
74
|
+
? counts ? ` Kept ${counts}.` : ""
|
|
75
|
+
: " Cut mid-text, so what is above may be incomplete JSON.";
|
|
76
|
+
// What a model should do about it: ask the tool a better question. Never
|
|
77
|
+
// "go and read the whole thing", which costs more than not trimming.
|
|
78
|
+
const where = input.spillHint && input.spillPath
|
|
79
|
+
? ` The whole result is at ${JSON.stringify(input.spillPath)}; reading it costs as much as not trimming, so narrow the call first.`
|
|
80
|
+
: " If you need more, call the tool again with a narrower query, a filter, or the next page.";
|
|
81
|
+
return `\n\n[OpenMausBot trimmed this tool result to fit the conversation: ${fmt(input.originalChars)} → ${fmt(input.keptChars)} characters.${what}${where}]`;
|
|
82
|
+
}
|
|
83
|
+
/** The structural cut on its own, for a payload that is already parsed and
|
|
84
|
+
* must stay valid JSON of the same shape (a tool's `structuredContent`).
|
|
85
|
+
* Returns null when the bulk arrays cannot be made to fit — a caller holding
|
|
86
|
+
* a schema-bound value must then leave it alone rather than mangle it. */
|
|
87
|
+
export function trimStructured(parsed, budget) {
|
|
88
|
+
const room = Math.max(MIN_BUDGET, budget);
|
|
89
|
+
const current = serialize(parsed);
|
|
90
|
+
if (current !== null && current.length <= room)
|
|
91
|
+
return null;
|
|
92
|
+
const fields = arrayFields(parsed);
|
|
93
|
+
if (!fields.length)
|
|
94
|
+
return null;
|
|
95
|
+
const skeleton = Array.isArray(parsed)
|
|
96
|
+
? 2
|
|
97
|
+
: (serialize({ ...parsed, ...Object.fromEntries(fields.map((f) => [f.key, []])) })?.length ?? Infinity);
|
|
98
|
+
const share = Math.floor((room - skeleton) / fields.length);
|
|
99
|
+
if (!Number.isFinite(skeleton) || share <= 0)
|
|
100
|
+
return null;
|
|
101
|
+
const dropped = [];
|
|
102
|
+
let value;
|
|
103
|
+
if (Array.isArray(parsed)) {
|
|
104
|
+
const kept = fitArray(parsed, share);
|
|
105
|
+
dropped.push({ key: "", kept: kept.length, total: parsed.length });
|
|
106
|
+
value = kept;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const next = { ...parsed };
|
|
110
|
+
for (const field of fields) {
|
|
111
|
+
const kept = fitArray(field.items, share);
|
|
112
|
+
dropped.push({ key: field.key, kept: kept.length, total: field.items.length });
|
|
113
|
+
next[field.key] = kept;
|
|
114
|
+
}
|
|
115
|
+
value = next;
|
|
116
|
+
}
|
|
117
|
+
if (!dropped.some((d) => d.kept < d.total))
|
|
118
|
+
return null;
|
|
119
|
+
const json = serialize(value);
|
|
120
|
+
return json !== null && json.length <= room ? { value, dropped } : null;
|
|
121
|
+
}
|
|
122
|
+
/** Trim one tool result's text down to the budget, keeping whole records
|
|
123
|
+
* wherever the payload is JSON so the model is never handed a half-object. */
|
|
124
|
+
export function trimResultText(input) {
|
|
125
|
+
const { text } = input;
|
|
126
|
+
const budget = Math.max(MIN_BUDGET, input.budget ?? DEFAULT_RESULT_BUDGET);
|
|
127
|
+
if (text.length <= budget)
|
|
128
|
+
return { text, trimmed: false, originalChars: text.length };
|
|
129
|
+
// Reserve room for the marker so the trimmed result actually lands under
|
|
130
|
+
// the budget instead of just under it plus an explanation.
|
|
131
|
+
const room = Math.max(MIN_BUDGET, budget - 400);
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
parsed = undefined;
|
|
138
|
+
}
|
|
139
|
+
const fields = parsed === undefined ? [] : arrayFields(parsed);
|
|
140
|
+
if (fields.length) {
|
|
141
|
+
// What the payload costs with every bulk array emptied. Anything left is
|
|
142
|
+
// shared out between those arrays, in order, equally.
|
|
143
|
+
const skeleton = Array.isArray(parsed)
|
|
144
|
+
? 2
|
|
145
|
+
: (serialize({ ...parsed, ...Object.fromEntries(fields.map((f) => [f.key, []])) })?.length ?? Infinity);
|
|
146
|
+
const share = Math.floor((room - skeleton) / fields.length);
|
|
147
|
+
if (Number.isFinite(skeleton) && share > 0) {
|
|
148
|
+
const dropped = [];
|
|
149
|
+
let value;
|
|
150
|
+
if (Array.isArray(parsed)) {
|
|
151
|
+
const kept = fitArray(parsed, share);
|
|
152
|
+
dropped.push({ key: "", kept: kept.length, total: parsed.length });
|
|
153
|
+
value = kept;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const next = { ...parsed };
|
|
157
|
+
for (const field of fields) {
|
|
158
|
+
const kept = fitArray(field.items, share);
|
|
159
|
+
dropped.push({ key: field.key, kept: kept.length, total: field.items.length });
|
|
160
|
+
next[field.key] = kept;
|
|
161
|
+
}
|
|
162
|
+
value = next;
|
|
163
|
+
}
|
|
164
|
+
const json = serialize(value);
|
|
165
|
+
// One record can be larger than the whole share; if the structural cut
|
|
166
|
+
// still overflows, the text cut below is the honest answer.
|
|
167
|
+
if (json !== null && json.length <= room) {
|
|
168
|
+
return {
|
|
169
|
+
text: json + marker({ originalChars: text.length, keptChars: json.length, dropped, spillPath: input.spillPath, spillHint: input.spillHint, structural: true }),
|
|
170
|
+
trimmed: true,
|
|
171
|
+
originalChars: text.length,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const cut = cutAt(text, room);
|
|
177
|
+
return {
|
|
178
|
+
text: cut + marker({ originalChars: text.length, keptChars: cut.length, dropped: [], spillPath: input.spillPath, spillHint: input.spillHint, structural: false }),
|
|
179
|
+
trimmed: true,
|
|
180
|
+
originalChars: text.length,
|
|
181
|
+
};
|
|
182
|
+
}
|