@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.50
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -25
- package/assets/pugi-prozr2-mascot.ansi +9 -0
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash-classifier.js +400 -4
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +112 -3
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/hooks.js +118 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/sandbox.js +40 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +98 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +860 -211
- package/dist/core/engine/prompts.js +88 -2
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +1045 -36
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/path-security.js +284 -2
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +1897 -37
- package/dist/core/repl/slash-commands.js +430 -15
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +80 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/core/worktree-manager/cleanup.js +123 -0
- package/dist/core/worktree-manager/manager.js +303 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +3241 -343
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +242 -11
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +412 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/commands/worktrees.js +155 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/bash.js +203 -4
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/powershell.js +268 -0
- package/dist/tools/registry.js +51 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +82 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +218 -3
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +313 -35
- package/dist/tui/repl-splash-art.js +1 -1
- package/dist/tui/repl-splash-mascot.js +32 -8
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +85 -5
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/thinking-spinner.js +123 -0
- package/dist/tui/tool-stream-pane.js +52 -3
- package/dist/tui/update-banner.js +27 -2
- package/dist/tui/vim-input.js +267 -0
- package/dist/tui/welcome-banner.js +107 -0
- package/dist/tui/welcome-data.js +293 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +12 -6
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
package/dist/tools/file-tools.js
CHANGED
|
@@ -1,9 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-tools - Pugi CLI file/bash/glob/grep tool surface.
|
|
3
|
+
*
|
|
4
|
+
* Workspace-binding contract (CEO red-alert 2026-05-27 follow-up):
|
|
5
|
+
*
|
|
6
|
+
* Every tool dispatch path threads `ctx.root` from the operator's
|
|
7
|
+
* `process.cwd()` through `EngineTask.workspaceRoot` ->
|
|
8
|
+
* `native-pugi.run()` -> `toolCtx.root` -> here. Tools call
|
|
9
|
+
* `resolveWorkspacePath(ctx.root, path)` for every on-disk operation
|
|
10
|
+
* so a dispatched specialist (e.g. Hiroshi writing tic-tac-toe HTML)
|
|
11
|
+
* produces files in the OPERATOR'S cwd, never in a server-side temp
|
|
12
|
+
* space. The path-security gate refuses traversal (`../etc/passwd`,
|
|
13
|
+
* URL-encoded variants, symlink escapes at the target).
|
|
14
|
+
*
|
|
15
|
+
* Wiring chain:
|
|
16
|
+
* 1. runtime/cli.ts: workspaceRoot = process.cwd()
|
|
17
|
+
* 2. EngineTask.workspaceRoot threads through to native-pugi.run().
|
|
18
|
+
* 3. native-pugi: const root = task.workspaceRoot
|
|
19
|
+
* 4. tool-bridge: passes ctx.root to file-tools / bash.
|
|
20
|
+
* 5. file-tools: resolveWorkspacePath(ctx.root, path).
|
|
21
|
+
*
|
|
22
|
+
* The contract is locked by `test/tools-write-to-workspace.spec.ts`
|
|
23
|
+
* (6 cases covering relative + nested + absolute paths + traversal
|
|
24
|
+
* refusal). If any layer of the chain regressed silently, dispatched
|
|
25
|
+
* files would land in `/tmp` instead of the operator's repo, which
|
|
26
|
+
* is the same failure surface as the menu-mode anti-pattern the
|
|
27
|
+
* sibling commits close.
|
|
28
|
+
*/
|
|
1
29
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
31
|
import { dirname, isAbsolute, relative } from 'node:path';
|
|
4
32
|
import { globSync } from 'node:fs';
|
|
5
33
|
import { decidePermission } from '../core/permission.js';
|
|
6
|
-
import { createReadRecord, hashContent } from '../core/file-cache.js';
|
|
34
|
+
import { StaleReadError, createReadRecord, hashContent, } from '../core/file-cache.js';
|
|
7
35
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
8
36
|
import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
|
|
9
37
|
/**
|
|
@@ -19,6 +47,11 @@ export class OperatorAbortedError extends Error {
|
|
|
19
47
|
this.name = 'OperatorAbortedError';
|
|
20
48
|
}
|
|
21
49
|
}
|
|
50
|
+
// Re-export StaleReadError so tool-bridge / test consumers can import
|
|
51
|
+
// the typed error from a single file-tools surface alongside
|
|
52
|
+
// OperatorAbortedError. Same shape as the existing OperatorAbortedError
|
|
53
|
+
// re-surface pattern.
|
|
54
|
+
export { StaleReadError } from '../core/file-cache.js';
|
|
22
55
|
/**
|
|
23
56
|
* α6.9 WriteGate: refuse the tool dispatch when the active
|
|
24
57
|
* cancellation token has aborted. Idempotent (the token's `isAborted`
|
|
@@ -124,10 +157,37 @@ export function writeTool(ctx, path, content) {
|
|
|
124
157
|
throw error;
|
|
125
158
|
}
|
|
126
159
|
const existed = existsSync(resolved);
|
|
127
|
-
|
|
160
|
+
// Leak L1 stale-read gate for writeTool's update-existing path. The
|
|
161
|
+
// model uses writeTool for two distinct intents:
|
|
162
|
+
//
|
|
163
|
+
// - create-new: path does not exist on disk. There is no prior
|
|
164
|
+
// read to validate against; skip the gate. This is the
|
|
165
|
+
// intentional escape hatch the leak spec also calls out.
|
|
166
|
+
// - overwrite-existing: path exists. Without the gate the model
|
|
167
|
+
// could blind-clobber an externally-modified file, losing the
|
|
168
|
+
// concurrent change silently. Force the model to re-read first.
|
|
169
|
+
//
|
|
170
|
+
// We deliberately apply the SAME stale-validation primitive editTool
|
|
171
|
+
// uses so the two write surfaces stay symmetric and a future fix to
|
|
172
|
+
// either one cannot accidentally weaken the other.
|
|
173
|
+
let before;
|
|
174
|
+
if (existed) {
|
|
175
|
+
before = readFileSync(resolved, 'utf8');
|
|
176
|
+
const currentStat = statSync(resolved);
|
|
177
|
+
const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
|
|
178
|
+
if (validation.stale) {
|
|
179
|
+
const reason = `stale_read: write ${path} refused — ${validation.detail}`;
|
|
180
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
181
|
+
throw new StaleReadError(path, validation.reason, validation.detail);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
128
184
|
const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
|
|
129
185
|
writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
|
|
130
186
|
renameSync(tmp, resolved);
|
|
187
|
+
// Refresh the cache with the post-write content so the model can
|
|
188
|
+
// chain a follow-up read+edit on the same file without an extra
|
|
189
|
+
// round-trip. Same pattern editTool uses below.
|
|
190
|
+
ctx.readCache.set(createReadRecord(ctx.root, path, content, 'read_tool'));
|
|
131
191
|
recordFileMutation(ctx.session, {
|
|
132
192
|
toolCallId,
|
|
133
193
|
path,
|
|
@@ -154,10 +214,6 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
154
214
|
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
155
215
|
throw new Error(reason);
|
|
156
216
|
}
|
|
157
|
-
const readRecord = ctx.readCache.get(ctx.root, path);
|
|
158
|
-
if (!readRecord) {
|
|
159
|
-
throw new Error(`Cannot edit ${path}: file must be read first`);
|
|
160
|
-
}
|
|
161
217
|
let resolved;
|
|
162
218
|
try {
|
|
163
219
|
resolved = permissionGatedResolve(ctx, path, 'edit', 'edit');
|
|
@@ -167,16 +223,31 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
167
223
|
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
168
224
|
throw error;
|
|
169
225
|
}
|
|
226
|
+
// Leak L1 stale-read gate. Validate the model's read-time view of
|
|
227
|
+
// the file against the on-disk state BEFORE applying the mutation.
|
|
228
|
+
// We read disk content once and feed it to the validator so a single
|
|
229
|
+
// syscall covers both the gate decision AND the oldString/newString
|
|
230
|
+
// replacement below.
|
|
170
231
|
const before = readFileSync(resolved, 'utf8');
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
232
|
+
const currentStat = statSync(resolved);
|
|
233
|
+
const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
|
|
234
|
+
if (validation.stale) {
|
|
235
|
+
const reason = `stale_read: edit ${path} refused — ${validation.detail}`;
|
|
236
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
237
|
+
throw new StaleReadError(path, validation.reason, validation.detail);
|
|
174
238
|
}
|
|
239
|
+
const currentHash = hashContent(before);
|
|
175
240
|
const matches = before.split(oldString).length - 1;
|
|
176
|
-
if (matches === 0)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
throw new Error(
|
|
241
|
+
if (matches === 0) {
|
|
242
|
+
const reason = `Cannot edit ${path}: oldString not found`;
|
|
243
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
244
|
+
throw new Error(reason);
|
|
245
|
+
}
|
|
246
|
+
if (matches > 1) {
|
|
247
|
+
const reason = `Cannot edit ${path}: oldString is not unique`;
|
|
248
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
249
|
+
throw new Error(reason);
|
|
250
|
+
}
|
|
180
251
|
const after = before.replace(oldString, newString);
|
|
181
252
|
const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
|
|
182
253
|
writeFileSync(tmp, after, { encoding: 'utf8', mode: 0o600 });
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { gateOnCancellation, OperatorAbortedError } from './file-tools.js';
|
|
2
|
+
import { recordToolCall, recordToolResult } from '../core/session.js';
|
|
3
|
+
/** Cap for any single LSP tool's payload size. Keeps model context lean. */
|
|
4
|
+
const LSP_PAYLOAD_CAP_BYTES = 8 * 1024;
|
|
5
|
+
export async function lspHover(ctx, lang, file, line, col) {
|
|
6
|
+
const toolCallId = recordToolCall(ctx.session, 'lsp_hover', `${lang}:${file}:${line}:${col}`);
|
|
7
|
+
return guard(ctx, 'lsp_hover', toolCallId, async () => {
|
|
8
|
+
const client = ctx.lspClients?.get(lang);
|
|
9
|
+
if (!client)
|
|
10
|
+
return unavailable(lang);
|
|
11
|
+
const result = await client.hover(file, { line, character: col }, ctx.cancellation);
|
|
12
|
+
if (!result.ok)
|
|
13
|
+
return failure(result);
|
|
14
|
+
if (!result.value) {
|
|
15
|
+
return { ok: true, value: { content: '' } };
|
|
16
|
+
}
|
|
17
|
+
const content = truncate(result.value.content);
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
value: {
|
|
21
|
+
content: content.text,
|
|
22
|
+
...(result.value.range ? { range: result.value.range } : {}),
|
|
23
|
+
},
|
|
24
|
+
...(content.truncated ? { truncated: true } : {}),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export async function lspDefinition(ctx, lang, file, line, col) {
|
|
29
|
+
const toolCallId = recordToolCall(ctx.session, 'lsp_definition', `${lang}:${file}:${line}:${col}`);
|
|
30
|
+
return guard(ctx, 'lsp_definition', toolCallId, async () => {
|
|
31
|
+
const client = ctx.lspClients?.get(lang);
|
|
32
|
+
if (!client)
|
|
33
|
+
return unavailable(lang);
|
|
34
|
+
const result = await client.definition(file, { line, character: col }, ctx.cancellation);
|
|
35
|
+
if (!result.ok)
|
|
36
|
+
return failure(result);
|
|
37
|
+
const capped = capLocations(result.value);
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
value: capped.value,
|
|
41
|
+
...(capped.truncated ? { truncated: true } : {}),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export async function lspReferences(ctx, lang, file, line, col) {
|
|
46
|
+
const toolCallId = recordToolCall(ctx.session, 'lsp_references', `${lang}:${file}:${line}:${col}`);
|
|
47
|
+
return guard(ctx, 'lsp_references', toolCallId, async () => {
|
|
48
|
+
const client = ctx.lspClients?.get(lang);
|
|
49
|
+
if (!client)
|
|
50
|
+
return unavailable(lang);
|
|
51
|
+
const result = await client.references(file, { line, character: col }, ctx.cancellation);
|
|
52
|
+
if (!result.ok)
|
|
53
|
+
return failure(result);
|
|
54
|
+
const capped = capLocations(result.value);
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
value: capped.value,
|
|
58
|
+
...(capped.truncated ? { truncated: true } : {}),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export async function lspDiagnostics(ctx, lang, file) {
|
|
63
|
+
const toolCallId = recordToolCall(ctx.session, 'lsp_diagnostics', `${lang}:${file}`);
|
|
64
|
+
return guard(ctx, 'lsp_diagnostics', toolCallId, async () => {
|
|
65
|
+
const client = ctx.lspClients?.get(lang);
|
|
66
|
+
if (!client)
|
|
67
|
+
return unavailable(lang);
|
|
68
|
+
const result = await client.diagnostics(file, ctx.cancellation);
|
|
69
|
+
if (!result.ok)
|
|
70
|
+
return failure(result);
|
|
71
|
+
const capped = capDiagnostics(result.value);
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
value: capped.value,
|
|
75
|
+
...(capped.truncated ? { truncated: true } : {}),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function guard(ctx, toolName, toolCallId, op) {
|
|
80
|
+
try {
|
|
81
|
+
gateOnCancellation(ctx, toolName);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof OperatorAbortedError) {
|
|
85
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', error.message);
|
|
86
|
+
return { ok: false, reason: 'operator_aborted', detail: error.message };
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const result = await op();
|
|
92
|
+
if (result.ok) {
|
|
93
|
+
recordToolResult(ctx.session, toolCallId, 'success', summarize(result.value));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
recordToolResult(ctx.session, toolCallId, 'error', `${result.reason ?? 'error'}: ${result.detail ?? ''}`);
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
recordToolResult(ctx.session, toolCallId, 'error', message);
|
|
103
|
+
return { ok: false, reason: 'lsp_error', detail: message };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function unavailable(lang) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: 'lsp_unavailable',
|
|
110
|
+
detail: `no LSP server started for ${lang}. Install the server and re-run ` +
|
|
111
|
+
`with --lsp ${lang}, or fall back to grep.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function failure(result) {
|
|
115
|
+
if (result.ok) {
|
|
116
|
+
// Shouldn't be hit — caller checks first.
|
|
117
|
+
return { ok: true, value: result.value };
|
|
118
|
+
}
|
|
119
|
+
return { ok: false, reason: result.reason, detail: result.detail };
|
|
120
|
+
}
|
|
121
|
+
function summarize(value) {
|
|
122
|
+
if (value === null || value === undefined)
|
|
123
|
+
return 'no result';
|
|
124
|
+
if (Array.isArray(value))
|
|
125
|
+
return `${value.length} items`;
|
|
126
|
+
if (typeof value === 'object')
|
|
127
|
+
return Object.keys(value).join(',');
|
|
128
|
+
return String(value);
|
|
129
|
+
}
|
|
130
|
+
function truncate(text) {
|
|
131
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
132
|
+
if (bytes <= LSP_PAYLOAD_CAP_BYTES)
|
|
133
|
+
return { text, truncated: false };
|
|
134
|
+
// Truncate to the cap byte boundary. We don't try to honor codepoint
|
|
135
|
+
// alignment — UTF-8 surrogate splits show up as a single ? at the
|
|
136
|
+
// boundary, which is acceptable for a debug surface; the dispatcher
|
|
137
|
+
// is the trust boundary for "this is what the model will see".
|
|
138
|
+
const buf = Buffer.from(text, 'utf8').subarray(0, LSP_PAYLOAD_CAP_BYTES);
|
|
139
|
+
return { text: `${buf.toString('utf8')}\n... [truncated]`, truncated: true };
|
|
140
|
+
}
|
|
141
|
+
function capLocations(locations) {
|
|
142
|
+
// Cap at 200 locations OR the byte cap, whichever hits first. The
|
|
143
|
+
// 200 number is the operator-facing "this is a hot symbol" threshold —
|
|
144
|
+
// a richer surface (paginated `pugi lsp references --offset N`) is
|
|
145
|
+
// open backlog.
|
|
146
|
+
const COUNT_CAP = 200;
|
|
147
|
+
if (locations.length === 0)
|
|
148
|
+
return { value: locations, truncated: false };
|
|
149
|
+
const trimmed = locations.slice(0, COUNT_CAP);
|
|
150
|
+
const serialized = JSON.stringify(trimmed);
|
|
151
|
+
if (Buffer.byteLength(serialized, 'utf8') <= LSP_PAYLOAD_CAP_BYTES && trimmed.length === locations.length) {
|
|
152
|
+
return { value: trimmed, truncated: false };
|
|
153
|
+
}
|
|
154
|
+
// Trim by halves until we fit the byte cap. Worst case ~10 iterations
|
|
155
|
+
// for the 200 max, fine for an interactive tool.
|
|
156
|
+
let upper = trimmed.length;
|
|
157
|
+
while (upper > 1) {
|
|
158
|
+
const half = Math.floor(upper / 2);
|
|
159
|
+
const sub = trimmed.slice(0, half);
|
|
160
|
+
if (Buffer.byteLength(JSON.stringify(sub), 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
|
|
161
|
+
return { value: sub, truncated: true };
|
|
162
|
+
}
|
|
163
|
+
upper = half;
|
|
164
|
+
}
|
|
165
|
+
return { value: trimmed.slice(0, 1), truncated: true };
|
|
166
|
+
}
|
|
167
|
+
function capDiagnostics(items) {
|
|
168
|
+
if (items.length === 0)
|
|
169
|
+
return { value: items, truncated: false };
|
|
170
|
+
const serialized = JSON.stringify(items);
|
|
171
|
+
if (Buffer.byteLength(serialized, 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
|
|
172
|
+
return { value: items, truncated: false };
|
|
173
|
+
}
|
|
174
|
+
// Diagnostics are sorted error-first in LSP convention; trim from the
|
|
175
|
+
// tail so we keep the highest-severity items.
|
|
176
|
+
let upper = items.length;
|
|
177
|
+
while (upper > 1) {
|
|
178
|
+
const half = Math.floor(upper / 2);
|
|
179
|
+
const sub = items.slice(0, half);
|
|
180
|
+
if (Buffer.byteLength(JSON.stringify(sub), 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
|
|
181
|
+
return { value: sub, truncated: true };
|
|
182
|
+
}
|
|
183
|
+
upper = half;
|
|
184
|
+
}
|
|
185
|
+
return { value: items.slice(0, 1), truncated: true };
|
|
186
|
+
}
|
|
187
|
+
/** Test-only surface so specs can poke truncation directly. */
|
|
188
|
+
export const __test__ = { truncate, capLocations, capDiagnostics, LSP_PAYLOAD_CAP_BYTES };
|
|
189
|
+
//# sourceMappingURL=lsp-tools.js.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { callTool } from '../core/mcp/client.js';
|
|
2
|
+
import { getMcpPermission, setMcpPermission, } from '../core/mcp/permission.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tool dispatcher for MCP-invoked tools (β4 M1 + M3 + M5).
|
|
5
|
+
*
|
|
6
|
+
* Tool names use the `mcp__<server>__<tool>` namespace (double-underscore
|
|
7
|
+
* separator, mirroring Claude Code's MCP envelope). The triple-underscore
|
|
8
|
+
* forms (`mcp__server__tool__sub`) collapse into the third segment when
|
|
9
|
+
* the upstream server itself uses underscores in its tool names — `split`
|
|
10
|
+
* on the first two `__` only, so any further `__` in the tool name part
|
|
11
|
+
* survive intact (e.g. `mcp__github__create_issue` -> server=`github`,
|
|
12
|
+
* tool=`create_issue`).
|
|
13
|
+
*
|
|
14
|
+
* Why double-underscore: native Pugi tools use single-token names
|
|
15
|
+
* (`read`, `grep`, `edit`, `bash`). The double-underscore prefix
|
|
16
|
+
* unambiguously segregates the MCP namespace from native names without
|
|
17
|
+
* needing per-name regex matching at every dispatch site.
|
|
18
|
+
*
|
|
19
|
+
* Permission flow:
|
|
20
|
+
* 1. Server trust gate (handled at registry-load time). If a server is
|
|
21
|
+
* not `trusted`, its tools never reach the engine loop.
|
|
22
|
+
* 2. Per-(server, tool) permission cache (`./mcp/permission.ts`).
|
|
23
|
+
* Unset on first dispatch -> caller must prompt. Cached `allow_always`
|
|
24
|
+
* auto-passes; cached `deny` auto-refuses.
|
|
25
|
+
*
|
|
26
|
+
* This module is the bridge — it parses the namespaced name, finds the
|
|
27
|
+
* live connection in the registry, consults the cache, and (when
|
|
28
|
+
* approved) routes through `client.callTool`. Prompting is the executor's
|
|
29
|
+
* responsibility; this module exposes the cache lookup + dispatch
|
|
30
|
+
* primitives so the executor stays small.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Prefix every MCP tool name carries on the engine-loop wire.
|
|
34
|
+
*/
|
|
35
|
+
export const MCP_TOOL_PREFIX = 'mcp__';
|
|
36
|
+
/**
|
|
37
|
+
* Parse `mcp__<server>__<tool>` into `{ serverName, toolName }`. Returns
|
|
38
|
+
* null when the input does not match the namespace — callers use this as
|
|
39
|
+
* the "is this an MCP tool?" predicate.
|
|
40
|
+
*
|
|
41
|
+
* Server names cannot contain `__` by registry validation (they are JSON
|
|
42
|
+
* object keys); tool names CAN (e.g. `create_issue` has a single `_` but
|
|
43
|
+
* `read_directory` has none, so the only ambiguity is when an upstream
|
|
44
|
+
* tool uses double-underscore in its slug — extremely rare, but if it
|
|
45
|
+
* happens the second `__` boundary still parses correctly because we
|
|
46
|
+
* split on the FIRST occurrence after the prefix).
|
|
47
|
+
*/
|
|
48
|
+
export function parseMcpToolName(name) {
|
|
49
|
+
if (!name.startsWith(MCP_TOOL_PREFIX))
|
|
50
|
+
return null;
|
|
51
|
+
const tail = name.slice(MCP_TOOL_PREFIX.length);
|
|
52
|
+
const sep = tail.indexOf('__');
|
|
53
|
+
if (sep === -1)
|
|
54
|
+
return null;
|
|
55
|
+
const serverName = tail.slice(0, sep);
|
|
56
|
+
const toolName = tail.slice(sep + 2);
|
|
57
|
+
if (serverName.length === 0 || toolName.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return { serverName, toolName };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the namespaced tool name from a server + tool pair. Inverse of
|
|
63
|
+
* `parseMcpToolName`. Used by `buildMcpToolDefs` to emit the schema.
|
|
64
|
+
*/
|
|
65
|
+
export function buildMcpToolName(serverName, toolName) {
|
|
66
|
+
return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build engine-loop tool definitions from every trusted server's
|
|
70
|
+
* surfaced tools. Empty array when no MCP servers are trusted — the
|
|
71
|
+
* schema builder can call this unconditionally without checking first.
|
|
72
|
+
*/
|
|
73
|
+
export function buildMcpToolDefs(registry) {
|
|
74
|
+
if (!registry)
|
|
75
|
+
return [];
|
|
76
|
+
const defs = [];
|
|
77
|
+
for (const state of registry.servers.values()) {
|
|
78
|
+
if (state.trust !== 'trusted')
|
|
79
|
+
continue;
|
|
80
|
+
for (const tool of state.surfacedTools) {
|
|
81
|
+
defs.push({
|
|
82
|
+
name: buildMcpToolName(state.name, tool.name),
|
|
83
|
+
description: descriptionFor(state.name, tool),
|
|
84
|
+
// The upstream server returns its own JSON Schema in `inputSchema`.
|
|
85
|
+
// We surface it verbatim — the loop client passes it through to
|
|
86
|
+
// the model, and the model emits arguments matching the upstream
|
|
87
|
+
// shape. Default to `{ type: 'object' }` when missing so the
|
|
88
|
+
// OpenAI-shaped tool envelope still validates.
|
|
89
|
+
parameters: tool.inputSchema ?? { type: 'object' },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Sort stable so the schema bundle hash (used for caching/audit) is
|
|
94
|
+
// deterministic regardless of Map iteration order.
|
|
95
|
+
return defs.sort((a, b) => a.name.localeCompare(b.name));
|
|
96
|
+
}
|
|
97
|
+
function descriptionFor(serverName, tool) {
|
|
98
|
+
const base = tool.description?.trim() ?? `MCP tool ${tool.name} on server ${serverName}.`;
|
|
99
|
+
return `[MCP:${serverName}] ${base}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Look up the live connection + tool metadata for a parsed MCP tool name.
|
|
103
|
+
* Returns null when the server is not trusted, not connected, or does
|
|
104
|
+
* not expose the named tool. Callers MUST handle null — never throw,
|
|
105
|
+
* because the model may emit stale tool names after a server restart.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveMcpTool(registry, parsed) {
|
|
108
|
+
if (!registry)
|
|
109
|
+
return null;
|
|
110
|
+
const state = registry.servers.get(parsed.serverName);
|
|
111
|
+
if (!state || state.trust !== 'trusted' || !state.connection)
|
|
112
|
+
return null;
|
|
113
|
+
const tool = state.surfacedTools.find((t) => t.name === parsed.toolName);
|
|
114
|
+
if (!tool)
|
|
115
|
+
return null;
|
|
116
|
+
return { state, connection: state.connection, tool };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The default prompt — used when no interactive bridge is wired (CI,
|
|
120
|
+
* non-TTY pipes). Returns `deny` so an unattended run never silently
|
|
121
|
+
* fires an MCP call the operator never approved. The deny is NOT
|
|
122
|
+
* persisted, so the next run with a wired prompt still has a chance to
|
|
123
|
+
* approve.
|
|
124
|
+
*/
|
|
125
|
+
export const defaultNonInteractiveMcpPrompt = async () => 'unset';
|
|
126
|
+
/**
|
|
127
|
+
* Dispatch one MCP tool call. The flow:
|
|
128
|
+
*
|
|
129
|
+
* 1. Parse the namespaced tool name. Return error string when
|
|
130
|
+
* malformed — the model sees the error and can self-correct.
|
|
131
|
+
* 2. Resolve the live connection. Return error when the server is not
|
|
132
|
+
* trusted/connected or the tool is unknown.
|
|
133
|
+
* 3. Consult the permission cache. `deny` short-circuits. `allow_always`
|
|
134
|
+
* proceeds. `unset` invokes the prompt; the operator's verdict is
|
|
135
|
+
* persisted (allow_always/deny) or used one-shot (allow_once).
|
|
136
|
+
* 4. Parse the arguments string. Bad JSON -> error string.
|
|
137
|
+
* 5. Call `client.callTool` and stringify the content for the model.
|
|
138
|
+
*
|
|
139
|
+
* Throws ONLY on unrecoverable transport failures (e.g. the connection
|
|
140
|
+
* died mid-call). Tool-level errors from the upstream server are
|
|
141
|
+
* surfaced as `[MCP error] <message>` strings so the model can recover.
|
|
142
|
+
*/
|
|
143
|
+
export async function dispatchMcpTool(input) {
|
|
144
|
+
const parsed = parseMcpToolName(input.name);
|
|
145
|
+
if (!parsed) {
|
|
146
|
+
return `[MCP dispatch error] tool name "${input.name}" does not match the ${MCP_TOOL_PREFIX}<server>__<tool> namespace`;
|
|
147
|
+
}
|
|
148
|
+
const resolved = resolveMcpTool(input.registry, parsed);
|
|
149
|
+
if (!resolved) {
|
|
150
|
+
return `[MCP dispatch error] no trusted+connected server "${parsed.serverName}" exposes a tool named "${parsed.toolName}"`;
|
|
151
|
+
}
|
|
152
|
+
let args;
|
|
153
|
+
try {
|
|
154
|
+
args = parseArgumentsRaw(input.argumentsRaw);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return `[MCP dispatch error] invalid JSON in arguments for ${input.name}: ${error instanceof Error ? error.message : String(error)}`;
|
|
158
|
+
}
|
|
159
|
+
// Permission gate.
|
|
160
|
+
const cached = getMcpPermission(parsed.serverName, parsed.toolName);
|
|
161
|
+
let effective = cached;
|
|
162
|
+
if (cached === 'unset') {
|
|
163
|
+
const verdict = await input.prompt({
|
|
164
|
+
serverName: parsed.serverName,
|
|
165
|
+
toolName: parsed.toolName,
|
|
166
|
+
toolDescription: resolved.tool.description ?? '',
|
|
167
|
+
callArguments: args,
|
|
168
|
+
});
|
|
169
|
+
effective = verdict;
|
|
170
|
+
if (verdict === 'allow_always' || verdict === 'deny') {
|
|
171
|
+
setMcpPermission(parsed.serverName, parsed.toolName, verdict, resolveDecidedBy(input.decidedBy));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (effective === 'deny') {
|
|
175
|
+
return `[MCP refused] operator denied ${parsed.serverName}:${parsed.toolName}`;
|
|
176
|
+
}
|
|
177
|
+
if (effective !== 'allow_once' && effective !== 'allow_always') {
|
|
178
|
+
// Includes `unset` returned by the non-interactive default prompt.
|
|
179
|
+
return `[MCP refused] no operator approval for ${parsed.serverName}:${parsed.toolName} (run from a TTY to approve)`;
|
|
180
|
+
}
|
|
181
|
+
// Dispatch.
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = await callTool(resolved.connection, parsed.toolName, args, {
|
|
185
|
+
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
// Transport-level failure (timeout, child died mid-call). Surface
|
|
190
|
+
// as a recoverable string so the model can degrade gracefully.
|
|
191
|
+
return `[MCP transport error] ${parsed.serverName}:${parsed.toolName}: ${error instanceof Error ? error.message : String(error)}`;
|
|
192
|
+
}
|
|
193
|
+
return renderMcpToolResult(result.content, result.isError, parsed);
|
|
194
|
+
}
|
|
195
|
+
function parseArgumentsRaw(raw) {
|
|
196
|
+
if (!raw || raw.trim() === '')
|
|
197
|
+
return {};
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
200
|
+
throw new Error('arguments must be a JSON object');
|
|
201
|
+
}
|
|
202
|
+
return parsed;
|
|
203
|
+
}
|
|
204
|
+
function resolveDecidedBy(override) {
|
|
205
|
+
return (override?.trim() ||
|
|
206
|
+
process.env.PUGI_TRUSTED_BY?.trim() ||
|
|
207
|
+
process.env.USER?.trim() ||
|
|
208
|
+
process.env.USERNAME?.trim() ||
|
|
209
|
+
'cli');
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Project the MCP `content` payload into a single text string the model
|
|
213
|
+
* can ingest. MCP servers reply with `content: [{ type: 'text', text }]`
|
|
214
|
+
* by convention; we concatenate every `type: text` chunk and surface a
|
|
215
|
+
* `[MCP non-text content]` marker for other content kinds (images,
|
|
216
|
+
* resource references) which are not yet wired into Pugi's loop.
|
|
217
|
+
*
|
|
218
|
+
* `isError: true` from the upstream maps to a `[MCP error] ...` prefix
|
|
219
|
+
* so the model knows the call failed at the server, not at the
|
|
220
|
+
* transport.
|
|
221
|
+
*/
|
|
222
|
+
export function renderMcpToolResult(content, isError, parsed) {
|
|
223
|
+
const text = projectTextContent(content);
|
|
224
|
+
const prefix = isError ? `[MCP error ${parsed.serverName}:${parsed.toolName}] ` : '';
|
|
225
|
+
if (text === null) {
|
|
226
|
+
// Fallback to a JSON dump so the model sees SOMETHING — better than
|
|
227
|
+
// an opaque empty string when the upstream uses image / resource
|
|
228
|
+
// content kinds.
|
|
229
|
+
try {
|
|
230
|
+
return `${prefix}${JSON.stringify(content)}`;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return `${prefix}[MCP non-serialisable content]`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return `${prefix}${text}`;
|
|
237
|
+
}
|
|
238
|
+
function projectTextContent(content) {
|
|
239
|
+
if (content === null || content === undefined)
|
|
240
|
+
return '';
|
|
241
|
+
if (typeof content === 'string')
|
|
242
|
+
return content;
|
|
243
|
+
if (!Array.isArray(content))
|
|
244
|
+
return null;
|
|
245
|
+
const parts = [];
|
|
246
|
+
for (const entry of content) {
|
|
247
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
248
|
+
const obj = entry;
|
|
249
|
+
if (obj.type === 'text' && typeof obj.text === 'string') {
|
|
250
|
+
parts.push(obj.text);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Non-text chunk — record a marker so the model knows something was
|
|
255
|
+
// dropped from the response.
|
|
256
|
+
parts.push('[MCP non-text content chunk]');
|
|
257
|
+
}
|
|
258
|
+
return parts.join('\n');
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=mcp-tool.js.map
|