@oh-my-pi/pi-coding-agent 3.32.0 → 3.34.0
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/CHANGELOG.md +49 -9
- package/README.md +12 -0
- package/docs/custom-tools.md +1 -1
- package/docs/extensions.md +4 -4
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +4 -8
- package/examples/custom-tools/README.md +2 -2
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/todo.ts +1 -1
- package/examples/hooks/custom-compaction.ts +4 -2
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +5 -5
- package/src/capability/ssh.ts +42 -0
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +21 -6
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -1
- package/src/core/file-mentions.ts +147 -5
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/index.ts +11 -0
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +9 -4
- package/src/core/sdk.ts +26 -2
- package/src/core/session-manager.ts +3 -2
- package/src/core/settings-manager.ts +70 -0
- package/src/core/ssh/connection-manager.ts +466 -0
- package/src/core/ssh/ssh-executor.ts +190 -0
- package/src/core/ssh/sshfs-mount.ts +162 -0
- package/src/core/ssh-executor.ts +5 -0
- package/src/core/system-prompt.ts +424 -1
- package/src/core/title-generator.ts +109 -55
- package/src/core/tools/index.test.ts +1 -0
- package/src/core/tools/index.ts +3 -0
- package/src/core/tools/output.ts +37 -2
- package/src/core/tools/read.ts +24 -11
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/ssh.ts +302 -0
- package/src/core/tools/task/index.ts +1 -1
- package/src/core/tools/task/render.ts +10 -16
- package/src/core/tools/task/types.ts +1 -1
- package/src/core/tools/task/worker.ts +1 -1
- package/src/core/voice.ts +1 -1
- package/src/discovery/index.ts +3 -0
- package/src/discovery/ssh.ts +162 -0
- package/src/main.ts +2 -1
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/bash-execution.ts +9 -10
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/components/tree-selector.ts +9 -12
- package/src/modes/interactive/interactive-mode.ts +5 -2
- package/src/modes/interactive/theme/theme.ts +2 -2
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/tools/ssh.md +74 -0
- package/src/utils/image-resize.ts +1 -1
|
@@ -2,17 +2,54 @@
|
|
|
2
2
|
* Generate session titles using a smol, fast model.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Model } from "@
|
|
6
|
-
import { completeSimple } from "@
|
|
5
|
+
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
|
6
|
+
import { completeSimple } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import titleSystemPrompt from "../prompts/title-system.md" with { type: "text" };
|
|
8
8
|
import { logger } from "./logger";
|
|
9
9
|
import type { ModelRegistry } from "./model-registry";
|
|
10
|
-
import {
|
|
10
|
+
import { parseModelString, SMOL_MODEL_PRIORITY } from "./model-resolver";
|
|
11
11
|
|
|
12
12
|
const TITLE_SYSTEM_PROMPT = titleSystemPrompt;
|
|
13
13
|
|
|
14
14
|
const MAX_INPUT_CHARS = 2000;
|
|
15
15
|
|
|
16
|
+
function getTitleModelCandidates(registry: ModelRegistry, savedSmolModel?: string): Model<Api>[] {
|
|
17
|
+
const availableModels = registry.getAvailable();
|
|
18
|
+
if (availableModels.length === 0) return [];
|
|
19
|
+
|
|
20
|
+
const candidates: Model<Api>[] = [];
|
|
21
|
+
const addCandidate = (model?: Model<Api>): void => {
|
|
22
|
+
if (!model) return;
|
|
23
|
+
const exists = candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id);
|
|
24
|
+
if (!exists) {
|
|
25
|
+
candidates.push(model);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (savedSmolModel) {
|
|
30
|
+
const parsed = parseModelString(savedSmolModel);
|
|
31
|
+
if (parsed) {
|
|
32
|
+
const match = availableModels.find((model) => model.provider === parsed.provider && model.id === parsed.id);
|
|
33
|
+
addCandidate(match);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const pattern of SMOL_MODEL_PRIORITY) {
|
|
38
|
+
const needle = pattern.toLowerCase();
|
|
39
|
+
const exactMatch = availableModels.find((model) => model.id.toLowerCase() === needle);
|
|
40
|
+
addCandidate(exactMatch);
|
|
41
|
+
|
|
42
|
+
const fuzzyMatch = availableModels.find((model) => model.id.toLowerCase().includes(needle));
|
|
43
|
+
addCandidate(fuzzyMatch);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const model of availableModels) {
|
|
47
|
+
addCandidate(model);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return candidates;
|
|
51
|
+
}
|
|
52
|
+
|
|
16
53
|
/**
|
|
17
54
|
* Find the best available model for title generation.
|
|
18
55
|
* Uses the configured smol model if set, otherwise auto-discovers using priority chain.
|
|
@@ -20,9 +57,9 @@ const MAX_INPUT_CHARS = 2000;
|
|
|
20
57
|
* @param registry Model registry
|
|
21
58
|
* @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
|
|
22
59
|
*/
|
|
23
|
-
export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<
|
|
24
|
-
const
|
|
25
|
-
return
|
|
60
|
+
export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<Api> | null> {
|
|
61
|
+
const candidates = getTitleModelCandidates(registry, savedSmolModel);
|
|
62
|
+
return candidates[0] ?? null;
|
|
26
63
|
}
|
|
27
64
|
|
|
28
65
|
/**
|
|
@@ -37,68 +74,85 @@ export async function generateSessionTitle(
|
|
|
37
74
|
registry: ModelRegistry,
|
|
38
75
|
savedSmolModel?: string,
|
|
39
76
|
): Promise<string | null> {
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
77
|
+
const candidates = getTitleModelCandidates(registry, savedSmolModel);
|
|
78
|
+
if (candidates.length === 0) {
|
|
42
79
|
logger.debug("title-generator: no smol model found");
|
|
43
80
|
return null;
|
|
44
81
|
}
|
|
45
82
|
|
|
46
|
-
const apiKey = await registry.getApiKey(model);
|
|
47
|
-
if (!apiKey) {
|
|
48
|
-
logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
83
|
// Truncate message if too long
|
|
53
84
|
const truncatedMessage =
|
|
54
85
|
firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
|
|
86
|
+
const userMessage = `<user-message>\n${truncatedMessage}\n</user-message>`;
|
|
55
87
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
62
|
-
logger.debug("title-generator: request", request);
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const response = await completeSimple(
|
|
66
|
-
model,
|
|
67
|
-
{
|
|
68
|
-
systemPrompt: request.systemPrompt,
|
|
69
|
-
messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
apiKey,
|
|
73
|
-
maxTokens: 30,
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
// Extract title from response text content
|
|
78
|
-
let title = "";
|
|
79
|
-
for (const content of response.content) {
|
|
80
|
-
if (content.type === "text") {
|
|
81
|
-
title += content.text;
|
|
82
|
-
}
|
|
88
|
+
for (const model of candidates) {
|
|
89
|
+
const apiKey = await registry.getApiKey(model);
|
|
90
|
+
if (!apiKey) {
|
|
91
|
+
logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
|
|
92
|
+
continue;
|
|
83
93
|
}
|
|
84
|
-
title = title.trim();
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
const request = {
|
|
96
|
+
model: `${model.provider}/${model.id}`,
|
|
97
|
+
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
98
|
+
userMessage,
|
|
99
|
+
maxTokens: 30,
|
|
100
|
+
};
|
|
101
|
+
logger.debug("title-generator: request", request);
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
try {
|
|
104
|
+
const response = await completeSimple(
|
|
105
|
+
model,
|
|
106
|
+
{
|
|
107
|
+
systemPrompt: request.systemPrompt,
|
|
108
|
+
messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
apiKey,
|
|
112
|
+
maxTokens: 30,
|
|
113
|
+
},
|
|
114
|
+
);
|
|
95
115
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
if (response.stopReason === "error") {
|
|
117
|
+
logger.debug("title-generator: response error", {
|
|
118
|
+
model: request.model,
|
|
119
|
+
stopReason: response.stopReason,
|
|
120
|
+
errorMessage: response.errorMessage,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Extract title from response text content
|
|
126
|
+
let title = "";
|
|
127
|
+
for (const content of response.content) {
|
|
128
|
+
if (content.type === "text") {
|
|
129
|
+
title += content.text;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
title = title.trim();
|
|
133
|
+
|
|
134
|
+
logger.debug("title-generator: response", {
|
|
135
|
+
model: request.model,
|
|
136
|
+
title,
|
|
137
|
+
usage: response.usage,
|
|
138
|
+
stopReason: response.stopReason,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!title) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Clean up: remove quotes, trailing punctuation
|
|
146
|
+
return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
|
|
147
|
+
} catch (err) {
|
|
148
|
+
logger.debug("title-generator: error", {
|
|
149
|
+
model: request.model,
|
|
150
|
+
error: err instanceof Error ? err.message : String(err),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
101
153
|
}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
102
156
|
}
|
|
103
157
|
|
|
104
158
|
/**
|
package/src/core/tools/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export { createOutputTool, type OutputToolDetails } from "./output";
|
|
|
26
26
|
export { createReadTool, type ReadToolDetails } from "./read";
|
|
27
27
|
export { reportFindingTool, type SubmitReviewDetails } from "./review";
|
|
28
28
|
export { filterRulebookRules, formatRulesForPrompt, type RulebookToolDetails } from "./rulebook";
|
|
29
|
+
export { createSshTool, type SSHToolDetails } from "./ssh";
|
|
29
30
|
export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
|
|
30
31
|
export type { TruncationResult } from "./truncate";
|
|
31
32
|
export { createWebFetchTool, type WebFetchToolDetails } from "./web-fetch";
|
|
@@ -68,6 +69,7 @@ import { createOutputTool } from "./output";
|
|
|
68
69
|
import { createReadTool } from "./read";
|
|
69
70
|
import { reportFindingTool } from "./review";
|
|
70
71
|
import { createRulebookTool } from "./rulebook";
|
|
72
|
+
import { createSshTool } from "./ssh";
|
|
71
73
|
import { createTaskTool } from "./task/index";
|
|
72
74
|
import { createWebFetchTool } from "./web-fetch";
|
|
73
75
|
import { createWebSearchTool } from "./web-search/index";
|
|
@@ -115,6 +117,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
|
|
|
115
117
|
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
116
118
|
ask: createAskTool,
|
|
117
119
|
bash: createBashTool,
|
|
120
|
+
ssh: createSshTool,
|
|
118
121
|
edit: createEditTool,
|
|
119
122
|
find: createFindTool,
|
|
120
123
|
git: createGitTool,
|
package/src/core/tools/output.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import type { TextContent } from "@mariozechner/pi-ai";
|
|
10
9
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
10
|
+
import type { TextContent } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
12
12
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
13
13
|
import { Type } from "@sinclair/typebox";
|
|
@@ -189,11 +189,46 @@ function parseOutputProvenance(id: string): OutputProvenance | undefined {
|
|
|
189
189
|
function extractPreviewLines(content: string, maxLines: number): string[] {
|
|
190
190
|
const lines = content.split("\n");
|
|
191
191
|
const preview: string[] = [];
|
|
192
|
+
const structuralTokens = new Set(["{", "}", "[", "]"]);
|
|
193
|
+
|
|
194
|
+
const isStructuralLine = (line: string): boolean => {
|
|
195
|
+
const trimmed = line.trim();
|
|
196
|
+
if (!trimmed) return true;
|
|
197
|
+
const cleaned = trimmed.replace(/,+$/, "");
|
|
198
|
+
return structuralTokens.has(cleaned);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const trimmedContent = content.trim();
|
|
202
|
+
const firstMeaningful = lines.find((line) => line.trim());
|
|
203
|
+
if (
|
|
204
|
+
firstMeaningful &&
|
|
205
|
+
isStructuralLine(firstMeaningful) &&
|
|
206
|
+
(trimmedContent.startsWith("{") || trimmedContent.startsWith("[")) &&
|
|
207
|
+
trimmedContent.length <= 200_000
|
|
208
|
+
) {
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(trimmedContent);
|
|
211
|
+
const minified = JSON.stringify(parsed);
|
|
212
|
+
if (minified) return [minified];
|
|
213
|
+
} catch {
|
|
214
|
+
// Fall back to line-based previews.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
for (const line of lines) {
|
|
193
|
-
if (
|
|
219
|
+
if (isStructuralLine(line)) continue;
|
|
194
220
|
preview.push(line);
|
|
195
221
|
if (preview.length >= maxLines) break;
|
|
196
222
|
}
|
|
223
|
+
|
|
224
|
+
if (preview.length === 0) {
|
|
225
|
+
for (const line of lines) {
|
|
226
|
+
if (!line.trim()) continue;
|
|
227
|
+
preview.push(line);
|
|
228
|
+
if (preview.length >= maxLines) break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
return preview;
|
|
198
233
|
}
|
|
199
234
|
|
package/src/core/tools/read.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
import path from "node:path";
|
|
2
|
-
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
|
3
3
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
4
|
+
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
4
5
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
5
6
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
6
7
|
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { CONFIG_DIR_NAME } from "../../config";
|
|
7
9
|
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
|
|
8
10
|
import readDescription from "../../prompts/tools/read.md" with { type: "text" };
|
|
9
11
|
import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
|
|
@@ -27,6 +29,13 @@ import {
|
|
|
27
29
|
// Document types convertible via markitdown
|
|
28
30
|
const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
|
|
29
31
|
|
|
32
|
+
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
|
|
33
|
+
const REMOTE_MOUNT_PREFIX = path.join(homedir(), CONFIG_DIR_NAME, "remote") + path.sep;
|
|
34
|
+
|
|
35
|
+
function isRemoteMountPath(absolutePath: string): boolean {
|
|
36
|
+
return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
// Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
|
|
31
40
|
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
|
32
41
|
const MAX_FUZZY_RESULTS = 5;
|
|
@@ -438,19 +447,23 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
|
|
|
438
447
|
isDirectory = stat.isDirectory();
|
|
439
448
|
} catch (error) {
|
|
440
449
|
if (isNotFoundError(error)) {
|
|
441
|
-
const suggestions = await findReadPathSuggestions(readPath, session.cwd, signal);
|
|
442
450
|
let message = `File not found: ${readPath}`;
|
|
443
451
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
452
|
+
// Skip fuzzy matching for remote mounts (sshfs) to avoid hangs
|
|
453
|
+
if (!isRemoteMountPath(absolutePath)) {
|
|
454
|
+
const suggestions = await findReadPathSuggestions(readPath, session.cwd, signal);
|
|
455
|
+
|
|
456
|
+
if (suggestions?.suggestions.length) {
|
|
457
|
+
const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
|
|
458
|
+
message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
|
|
459
|
+
if (suggestions.truncated) {
|
|
460
|
+
message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
|
|
461
|
+
}
|
|
462
|
+
} else if (suggestions?.error) {
|
|
463
|
+
message += `\n\nFuzzy match failed: ${suggestions.error}`;
|
|
464
|
+
} else if (suggestions?.scopeLabel) {
|
|
465
|
+
message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
|
|
449
466
|
}
|
|
450
|
-
} else if (suggestions?.error) {
|
|
451
|
-
message += `\n\nFuzzy match failed: ${suggestions.error}`;
|
|
452
|
-
} else if (suggestions?.scopeLabel) {
|
|
453
|
-
message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
|
|
454
467
|
}
|
|
455
468
|
|
|
456
469
|
throw new Error(message);
|
|
@@ -17,6 +17,7 @@ import { lspToolRenderer } from "./lsp/render";
|
|
|
17
17
|
import { notebookToolRenderer } from "./notebook";
|
|
18
18
|
import { outputToolRenderer } from "./output";
|
|
19
19
|
import { readToolRenderer } from "./read";
|
|
20
|
+
import { sshToolRenderer } from "./ssh";
|
|
20
21
|
import { taskToolRenderer } from "./task/render";
|
|
21
22
|
import { webFetchToolRenderer } from "./web-fetch";
|
|
22
23
|
import { webSearchToolRenderer } from "./web-search/render";
|
|
@@ -43,6 +44,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
|
|
43
44
|
notebook: notebookToolRenderer as ToolRenderer,
|
|
44
45
|
output: outputToolRenderer as ToolRenderer,
|
|
45
46
|
read: readToolRenderer as ToolRenderer,
|
|
47
|
+
ssh: sshToolRenderer as ToolRenderer,
|
|
46
48
|
task: taskToolRenderer as ToolRenderer,
|
|
47
49
|
web_fetch: webFetchToolRenderer as ToolRenderer,
|
|
48
50
|
web_search: webSearchToolRenderer as ToolRenderer,
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import type { SSHHost } from "../../capability/ssh";
|
|
6
|
+
import { sshCapability } from "../../capability/ssh";
|
|
7
|
+
import { loadSync } from "../../discovery/index";
|
|
8
|
+
import type { Theme } from "../../modes/interactive/theme/theme";
|
|
9
|
+
import sshDescriptionBase from "../../prompts/tools/ssh.md" with { type: "text" };
|
|
10
|
+
import type { RenderResultOptions } from "../custom-tools/types";
|
|
11
|
+
import type { SSHHostInfo } from "../ssh/connection-manager";
|
|
12
|
+
import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
|
|
13
|
+
import { executeSSH } from "../ssh/ssh-executor";
|
|
14
|
+
import type { ToolSession } from "./index";
|
|
15
|
+
import { createToolUIKit } from "./render-utils";
|
|
16
|
+
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
|
|
17
|
+
|
|
18
|
+
const sshSchema = Type.Object({
|
|
19
|
+
host: Type.String({ description: "Host name from ssh.json or .ssh.json" }),
|
|
20
|
+
command: Type.String({ description: "Command to execute on the remote host" }),
|
|
21
|
+
cwd: Type.Optional(Type.String({ description: "Remote working directory (optional)" })),
|
|
22
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export interface SSHToolDetails {
|
|
26
|
+
truncation?: TruncationResult;
|
|
27
|
+
fullOutputPath?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatHostEntry(host: SSHHost): string {
|
|
31
|
+
const info = getHostInfoForHost(host);
|
|
32
|
+
|
|
33
|
+
let shell: string;
|
|
34
|
+
if (!info) {
|
|
35
|
+
shell = "detecting...";
|
|
36
|
+
} else if (info.os === "windows") {
|
|
37
|
+
if (info.compatEnabled) {
|
|
38
|
+
const compatShell = info.compatShell || "bash";
|
|
39
|
+
shell = `windows/${compatShell}`;
|
|
40
|
+
} else if (info.shell === "powershell") {
|
|
41
|
+
shell = "windows/powershell";
|
|
42
|
+
} else {
|
|
43
|
+
shell = "windows/cmd";
|
|
44
|
+
}
|
|
45
|
+
} else if (info.os === "linux") {
|
|
46
|
+
shell = `linux/${info.shell}`;
|
|
47
|
+
} else if (info.os === "macos") {
|
|
48
|
+
shell = `macos/${info.shell}`;
|
|
49
|
+
} else {
|
|
50
|
+
shell = `unknown/${info.shell}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return `- ${host.name} (${host.host}) | ${shell}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatDescription(hosts: SSHHost[]): string {
|
|
57
|
+
if (hosts.length === 0) {
|
|
58
|
+
return sshDescriptionBase;
|
|
59
|
+
}
|
|
60
|
+
const hostList = hosts.map(formatHostEntry).join("\n");
|
|
61
|
+
return `${sshDescriptionBase}\n\nAvailable hosts:\n${hostList}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function quoteRemotePath(value: string): string {
|
|
65
|
+
if (value.length === 0) {
|
|
66
|
+
return "''";
|
|
67
|
+
}
|
|
68
|
+
const escaped = value.replace(/'/g, "'\\''");
|
|
69
|
+
return `'${escaped}'`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function quotePowerShellPath(value: string): string {
|
|
73
|
+
if (value.length === 0) {
|
|
74
|
+
return "''";
|
|
75
|
+
}
|
|
76
|
+
const escaped = value.replace(/'/g, "''");
|
|
77
|
+
return `'${escaped}'`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function quoteCmdPath(value: string): string {
|
|
81
|
+
const escaped = value.replace(/"/g, '""');
|
|
82
|
+
return `"${escaped}"`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildRemoteCommand(command: string, cwd: string | undefined, info: SSHHostInfo): string {
|
|
86
|
+
if (!cwd) return command;
|
|
87
|
+
|
|
88
|
+
if (info.os === "windows" && !info.compatEnabled) {
|
|
89
|
+
if (info.shell === "powershell") {
|
|
90
|
+
return `Set-Location -Path ${quotePowerShellPath(cwd)}; ${command}`;
|
|
91
|
+
}
|
|
92
|
+
return `cd /d ${quoteCmdPath(cwd)} && ${command}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return `cd -- ${quoteRemotePath(cwd)} && ${command}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function loadHosts(session: ToolSession): {
|
|
99
|
+
hostNames: string[];
|
|
100
|
+
hostsByName: Map<string, SSHHost>;
|
|
101
|
+
} {
|
|
102
|
+
const result = loadSync<SSHHost>(sshCapability.id, { cwd: session.cwd });
|
|
103
|
+
const hostsByName = new Map<string, SSHHost>();
|
|
104
|
+
for (const host of result.items) {
|
|
105
|
+
if (!hostsByName.has(host.name)) {
|
|
106
|
+
hostsByName.set(host.name, host);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const hostNames = Array.from(hostsByName.keys()).sort();
|
|
110
|
+
return { hostNames, hostsByName };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createSshTool(session: ToolSession): AgentTool<typeof sshSchema> | null {
|
|
114
|
+
const { hostNames, hostsByName } = loadHosts(session);
|
|
115
|
+
if (hostNames.length === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const allowedHosts = new Set(hostNames);
|
|
120
|
+
|
|
121
|
+
const descriptionHosts = hostNames
|
|
122
|
+
.map((name) => hostsByName.get(name))
|
|
123
|
+
.filter((host): host is SSHHost => host !== undefined);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
name: "ssh",
|
|
127
|
+
label: "SSH",
|
|
128
|
+
description: formatDescription(descriptionHosts),
|
|
129
|
+
parameters: sshSchema,
|
|
130
|
+
execute: async (
|
|
131
|
+
_toolCallId: string,
|
|
132
|
+
{ host, command, cwd, timeout }: { host: string; command: string; cwd?: string; timeout?: number },
|
|
133
|
+
signal?: AbortSignal,
|
|
134
|
+
onUpdate?,
|
|
135
|
+
_ctx?: AgentToolContext,
|
|
136
|
+
) => {
|
|
137
|
+
if (!allowedHosts.has(host)) {
|
|
138
|
+
throw new Error(`Unknown SSH host: ${host}. Available hosts: ${hostNames.join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const hostConfig = hostsByName.get(host);
|
|
142
|
+
if (!hostConfig) {
|
|
143
|
+
throw new Error(`SSH host not loaded: ${host}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hostInfo = await ensureHostInfo(hostConfig);
|
|
147
|
+
const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
|
|
148
|
+
let currentOutput = "";
|
|
149
|
+
|
|
150
|
+
const result = await executeSSH(hostConfig, remoteCommand, {
|
|
151
|
+
timeout: timeout ? timeout * 1000 : undefined,
|
|
152
|
+
signal,
|
|
153
|
+
compatEnabled: hostInfo.compatEnabled,
|
|
154
|
+
onChunk: (chunk) => {
|
|
155
|
+
currentOutput += chunk;
|
|
156
|
+
if (onUpdate) {
|
|
157
|
+
const truncation = truncateTail(currentOutput);
|
|
158
|
+
onUpdate({
|
|
159
|
+
content: [{ type: "text", text: truncation.content || "" }],
|
|
160
|
+
details: {
|
|
161
|
+
truncation: truncation.truncated ? truncation : undefined,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (result.cancelled) {
|
|
169
|
+
throw new Error(result.output || "Command aborted");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const truncation = truncateTail(result.output);
|
|
173
|
+
let outputText = truncation.content || "(no output)";
|
|
174
|
+
|
|
175
|
+
let details: SSHToolDetails | undefined;
|
|
176
|
+
|
|
177
|
+
if (truncation.truncated) {
|
|
178
|
+
details = {
|
|
179
|
+
truncation,
|
|
180
|
+
fullOutputPath: result.fullOutputPath,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
184
|
+
const endLine = truncation.totalLines;
|
|
185
|
+
|
|
186
|
+
if (truncation.lastLinePartial) {
|
|
187
|
+
const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
|
|
188
|
+
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
|
|
189
|
+
} else if (truncation.truncatedBy === "lines") {
|
|
190
|
+
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
|
|
191
|
+
} else {
|
|
192
|
+
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
|
197
|
+
outputText += `\n\nCommand exited with code ${result.exitCode}`;
|
|
198
|
+
throw new Error(outputText);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { content: [{ type: "text", text: outputText }], details };
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// =============================================================================
|
|
207
|
+
// TUI Renderer
|
|
208
|
+
// =============================================================================
|
|
209
|
+
|
|
210
|
+
interface SshRenderArgs {
|
|
211
|
+
host?: string;
|
|
212
|
+
command?: string;
|
|
213
|
+
timeout?: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface SshRenderContext {
|
|
217
|
+
/** Visual lines for truncated output (pre-computed by tool-execution) */
|
|
218
|
+
visualLines?: string[];
|
|
219
|
+
/** Number of lines skipped */
|
|
220
|
+
skippedCount?: number;
|
|
221
|
+
/** Total visual lines */
|
|
222
|
+
totalVisualLines?: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const sshToolRenderer = {
|
|
226
|
+
renderCall(args: SshRenderArgs, uiTheme: Theme): Component {
|
|
227
|
+
const ui = createToolUIKit(uiTheme);
|
|
228
|
+
const host = args.host || uiTheme.format.ellipsis;
|
|
229
|
+
const command = args.command || uiTheme.format.ellipsis;
|
|
230
|
+
const text = ui.title(`[${host}] $ ${command}`);
|
|
231
|
+
return new Text(text, 0, 0);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
renderResult(
|
|
235
|
+
result: {
|
|
236
|
+
content: Array<{ type: string; text?: string }>;
|
|
237
|
+
details?: SSHToolDetails;
|
|
238
|
+
},
|
|
239
|
+
options: RenderResultOptions & { renderContext?: SshRenderContext },
|
|
240
|
+
uiTheme: Theme,
|
|
241
|
+
): Component {
|
|
242
|
+
const ui = createToolUIKit(uiTheme);
|
|
243
|
+
const { expanded, renderContext } = options;
|
|
244
|
+
const details = result.details;
|
|
245
|
+
const lines: string[] = [];
|
|
246
|
+
|
|
247
|
+
const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
|
|
248
|
+
const output = textContent.trim();
|
|
249
|
+
|
|
250
|
+
if (output) {
|
|
251
|
+
if (expanded) {
|
|
252
|
+
const styledOutput = output
|
|
253
|
+
.split("\n")
|
|
254
|
+
.map((line) => uiTheme.fg("toolOutput", line))
|
|
255
|
+
.join("\n");
|
|
256
|
+
lines.push(styledOutput);
|
|
257
|
+
} else if (renderContext?.visualLines) {
|
|
258
|
+
const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
|
|
259
|
+
if (skippedCount > 0) {
|
|
260
|
+
lines.push(
|
|
261
|
+
uiTheme.fg(
|
|
262
|
+
"dim",
|
|
263
|
+
`${uiTheme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
lines.push(...visualLines);
|
|
268
|
+
} else {
|
|
269
|
+
const outputLines = output.split("\n");
|
|
270
|
+
const maxLines = 5;
|
|
271
|
+
const displayLines = outputLines.slice(0, maxLines);
|
|
272
|
+
const remaining = outputLines.length - maxLines;
|
|
273
|
+
|
|
274
|
+
lines.push(...displayLines.map((line) => uiTheme.fg("toolOutput", line)));
|
|
275
|
+
if (remaining > 0) {
|
|
276
|
+
lines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const truncation = details?.truncation;
|
|
282
|
+
const fullOutputPath = details?.fullOutputPath;
|
|
283
|
+
if (truncation?.truncated || fullOutputPath) {
|
|
284
|
+
const warnings: string[] = [];
|
|
285
|
+
if (fullOutputPath) {
|
|
286
|
+
warnings.push(`Full output: ${fullOutputPath}`);
|
|
287
|
+
}
|
|
288
|
+
if (truncation?.truncated) {
|
|
289
|
+
if (truncation.truncatedBy === "lines") {
|
|
290
|
+
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
|
291
|
+
} else {
|
|
292
|
+
warnings.push(
|
|
293
|
+
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
lines.push(uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". "))));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
301
|
+
},
|
|
302
|
+
};
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* - Session artifacts for debugging
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { Usage } from "@mariozechner/pi-ai";
|
|
17
16
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
17
|
+
import type { Usage } from "@oh-my-pi/pi-ai";
|
|
18
18
|
import type { Theme } from "../../../modes/interactive/theme/theme";
|
|
19
19
|
import taskDescriptionTemplate from "../../../prompts/tools/task.md" with { type: "text" };
|
|
20
20
|
import { formatDuration } from "../render-utils";
|