@aaroncql/pim-agent 0.0.1
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import ky, { HTTPError, type KyInstance } from "ky";
|
|
2
|
+
|
|
3
|
+
export type McpFetch = (
|
|
4
|
+
input: Parameters<typeof fetch>[0],
|
|
5
|
+
init?: Parameters<typeof fetch>[1]
|
|
6
|
+
) => ReturnType<typeof fetch>;
|
|
7
|
+
|
|
8
|
+
export type McpClientOptions = {
|
|
9
|
+
readonly endpoint: string;
|
|
10
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
11
|
+
readonly fetch?: McpFetch;
|
|
12
|
+
readonly clientName?: string;
|
|
13
|
+
readonly clientVersion?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type CallToolInput = {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type JsonRpcResponse = {
|
|
23
|
+
readonly id?: unknown;
|
|
24
|
+
readonly result?: unknown;
|
|
25
|
+
readonly error?: unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class McpClientError extends Error {
|
|
29
|
+
public constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "McpClientError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class McpClient {
|
|
36
|
+
private static readonly protocolVersion = "2025-06-18";
|
|
37
|
+
|
|
38
|
+
private readonly endpoint: string;
|
|
39
|
+
private readonly extraHeaders: Readonly<Record<string, string>>;
|
|
40
|
+
private readonly clientName: string;
|
|
41
|
+
private readonly clientVersion: string;
|
|
42
|
+
private readonly ky: KyInstance;
|
|
43
|
+
private nextRequestId = 1;
|
|
44
|
+
|
|
45
|
+
public constructor(options: McpClientOptions) {
|
|
46
|
+
this.endpoint = options.endpoint;
|
|
47
|
+
this.extraHeaders = options.headers ?? {};
|
|
48
|
+
this.clientName = options.clientName ?? "pim-agent";
|
|
49
|
+
this.clientVersion = options.clientVersion ?? "0.0.0";
|
|
50
|
+
this.ky = ky.create(
|
|
51
|
+
options.fetch === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { fetch: options.fetch as typeof fetch }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public async callTool(input: CallToolInput): Promise<unknown> {
|
|
58
|
+
let sessionId: string | undefined;
|
|
59
|
+
let activeRequestId: number | undefined;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
activeRequestId = this.nextRequestId++;
|
|
63
|
+
const initialize = await this.sendRpcRequest({
|
|
64
|
+
id: activeRequestId,
|
|
65
|
+
method: "initialize",
|
|
66
|
+
params: {
|
|
67
|
+
protocolVersion: McpClient.protocolVersion,
|
|
68
|
+
clientInfo: {
|
|
69
|
+
name: this.clientName,
|
|
70
|
+
version: this.clientVersion,
|
|
71
|
+
},
|
|
72
|
+
capabilities: {},
|
|
73
|
+
},
|
|
74
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
75
|
+
});
|
|
76
|
+
sessionId = initialize.sessionId;
|
|
77
|
+
|
|
78
|
+
await this.sendNotification({
|
|
79
|
+
sessionId,
|
|
80
|
+
method: "notifications/initialized",
|
|
81
|
+
params: {},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
activeRequestId = this.nextRequestId++;
|
|
85
|
+
const toolCall = await this.sendRpcRequest({
|
|
86
|
+
id: activeRequestId,
|
|
87
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
88
|
+
method: "tools/call",
|
|
89
|
+
params: {
|
|
90
|
+
name: input.name,
|
|
91
|
+
arguments: input.arguments,
|
|
92
|
+
},
|
|
93
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return toolCall.envelope.result;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (input.signal?.aborted) {
|
|
99
|
+
await this.sendCancellation(sessionId, activeRequestId);
|
|
100
|
+
throw new McpClientError("MCP request aborted.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async sendRpcRequest(input: {
|
|
108
|
+
readonly id: number;
|
|
109
|
+
readonly sessionId?: string;
|
|
110
|
+
readonly method: string;
|
|
111
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
112
|
+
readonly signal?: AbortSignal;
|
|
113
|
+
}): Promise<{
|
|
114
|
+
readonly envelope: JsonRpcResponse;
|
|
115
|
+
readonly sessionId: string | undefined;
|
|
116
|
+
}> {
|
|
117
|
+
const response = await this.postJson(
|
|
118
|
+
{
|
|
119
|
+
jsonrpc: "2.0",
|
|
120
|
+
id: input.id,
|
|
121
|
+
method: input.method,
|
|
122
|
+
params: input.params,
|
|
123
|
+
},
|
|
124
|
+
input.sessionId,
|
|
125
|
+
input.signal
|
|
126
|
+
);
|
|
127
|
+
const envelope = await this.readRpcResponse(response, input.id);
|
|
128
|
+
const sessionId =
|
|
129
|
+
input.sessionId ?? response.headers.get("mcp-session-id") ?? undefined;
|
|
130
|
+
|
|
131
|
+
if (asRecord(envelope.error) !== undefined) {
|
|
132
|
+
throw new McpClientError(
|
|
133
|
+
`MCP JSON-RPC error: ${describeRpcError(envelope.error)}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { envelope, sessionId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async sendNotification(input: {
|
|
141
|
+
readonly sessionId: string | undefined;
|
|
142
|
+
readonly method: string;
|
|
143
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
144
|
+
}): Promise<void> {
|
|
145
|
+
await this.postJson(
|
|
146
|
+
{
|
|
147
|
+
jsonrpc: "2.0",
|
|
148
|
+
method: input.method,
|
|
149
|
+
params: input.params,
|
|
150
|
+
},
|
|
151
|
+
input.sessionId
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async sendCancellation(
|
|
156
|
+
sessionId: string | undefined,
|
|
157
|
+
requestId: number | undefined
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
if (sessionId === undefined || requestId === undefined) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await this.sendNotification({
|
|
165
|
+
sessionId,
|
|
166
|
+
method: "notifications/cancelled",
|
|
167
|
+
params: {
|
|
168
|
+
requestId,
|
|
169
|
+
reason: "Tool call aborted.",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
} catch {
|
|
173
|
+
// abort already surfaces; cancel is best-effort
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async postJson(
|
|
178
|
+
body: Readonly<Record<string, unknown>>,
|
|
179
|
+
sessionId?: string,
|
|
180
|
+
signal?: AbortSignal
|
|
181
|
+
): Promise<Response> {
|
|
182
|
+
try {
|
|
183
|
+
return await this.ky(this.endpoint, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: this.createHeaders(sessionId),
|
|
186
|
+
json: body,
|
|
187
|
+
...(signal === undefined ? {} : { signal }),
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (signal?.aborted) {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (error instanceof HTTPError) {
|
|
195
|
+
throw new McpClientError(
|
|
196
|
+
`MCP request failed with HTTP ${error.response.status}: ${excerpt(stringifyErrorData(error.data))}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
throw new McpClientError(`MCP request failed: ${describeThrown(error)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private createHeaders(sessionId: string | undefined): Headers {
|
|
205
|
+
const headers = new Headers({
|
|
206
|
+
accept: "application/json, text/event-stream",
|
|
207
|
+
"mcp-protocol-version": McpClient.protocolVersion,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (sessionId !== undefined) {
|
|
211
|
+
headers.set("mcp-session-id", sessionId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const [name, value] of Object.entries(this.extraHeaders)) {
|
|
215
|
+
headers.set(name, value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return headers;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async readRpcResponse(
|
|
222
|
+
response: Response,
|
|
223
|
+
id: number
|
|
224
|
+
): Promise<JsonRpcResponse> {
|
|
225
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
226
|
+
|
|
227
|
+
if (contentType.includes("text/event-stream")) {
|
|
228
|
+
return this.readSseRpcResponse(response, id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const parsed = parseJson(await response.text());
|
|
232
|
+
const envelope = asRpcResponse(parsed);
|
|
233
|
+
|
|
234
|
+
if (envelope === undefined || envelope.id !== id) {
|
|
235
|
+
throw new McpClientError("MCP returned a malformed JSON-RPC envelope.");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return envelope;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async readSseRpcResponse(
|
|
242
|
+
response: Response,
|
|
243
|
+
id: number
|
|
244
|
+
): Promise<JsonRpcResponse> {
|
|
245
|
+
if (response.body === null) {
|
|
246
|
+
throw new McpClientError("MCP returned an empty SSE response.");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const reader = response.body.getReader();
|
|
250
|
+
const decoder = new TextDecoder();
|
|
251
|
+
let buffer = "";
|
|
252
|
+
let eventName = "message";
|
|
253
|
+
const dataLines: string[] = [];
|
|
254
|
+
|
|
255
|
+
const dispatch = (): JsonRpcResponse | undefined => {
|
|
256
|
+
const data = dataLines.join("\n");
|
|
257
|
+
const currentEventName = eventName;
|
|
258
|
+
|
|
259
|
+
dataLines.length = 0;
|
|
260
|
+
eventName = "message";
|
|
261
|
+
|
|
262
|
+
if (data.length === 0 || currentEventName !== "message") {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const envelope = asRpcResponse(parseJson(data));
|
|
267
|
+
|
|
268
|
+
if (envelope === undefined) {
|
|
269
|
+
throw new McpClientError("MCP returned a malformed JSON-RPC envelope.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return envelope.id === id ? envelope : undefined;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
while (true) {
|
|
277
|
+
const read = await reader.read();
|
|
278
|
+
|
|
279
|
+
buffer += decoder.decode(read.value, { stream: !read.done });
|
|
280
|
+
|
|
281
|
+
while (true) {
|
|
282
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
283
|
+
|
|
284
|
+
if (newlineIndex === -1) {
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const rawLine = buffer.slice(0, newlineIndex);
|
|
289
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
290
|
+
|
|
291
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
292
|
+
|
|
293
|
+
if (line.length === 0) {
|
|
294
|
+
const envelope = dispatch();
|
|
295
|
+
|
|
296
|
+
if (envelope !== undefined) {
|
|
297
|
+
return envelope;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (line.startsWith("event:")) {
|
|
304
|
+
eventName = line.slice("event:".length).trim();
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (line.startsWith("data:")) {
|
|
309
|
+
dataLines.push(line.slice("data:".length).trimStart());
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (read.done) {
|
|
314
|
+
const envelope = dispatch();
|
|
315
|
+
|
|
316
|
+
if (envelope !== undefined) {
|
|
317
|
+
return envelope;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} finally {
|
|
324
|
+
await reader.cancel().catch(() => {});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
throw new McpClientError(
|
|
328
|
+
"MCP SSE response did not include the expected message."
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function asRecord(
|
|
334
|
+
value: unknown
|
|
335
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
336
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return value as Readonly<Record<string, unknown>>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function asRpcResponse(value: unknown): JsonRpcResponse | undefined {
|
|
344
|
+
const record = asRecord(value);
|
|
345
|
+
|
|
346
|
+
if (record === undefined) {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
...(record["id"] === undefined ? {} : { id: record["id"] }),
|
|
352
|
+
...(record["result"] === undefined ? {} : { result: record["result"] }),
|
|
353
|
+
...(record["error"] === undefined ? {} : { error: record["error"] }),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseJson(text: string): unknown {
|
|
358
|
+
try {
|
|
359
|
+
return JSON.parse(text);
|
|
360
|
+
} catch {
|
|
361
|
+
throw new McpClientError("MCP returned invalid JSON.");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function describeRpcError(error: unknown): string {
|
|
366
|
+
const record = asRecord(error);
|
|
367
|
+
const message = record?.["message"];
|
|
368
|
+
|
|
369
|
+
if (typeof message === "string" && message.length > 0) {
|
|
370
|
+
return message;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return JSON.stringify(error);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function describeThrown(error: unknown): string {
|
|
377
|
+
if (error instanceof Error) {
|
|
378
|
+
return error.message;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return String(error);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function stringifyErrorData(data: unknown): string {
|
|
385
|
+
if (typeof data === "string") {
|
|
386
|
+
return data;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (data === undefined) {
|
|
390
|
+
return "";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return JSON.stringify(data);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function excerpt(text: string): string {
|
|
397
|
+
const normalized = text.replace(/\s+/gu, " ").trim();
|
|
398
|
+
|
|
399
|
+
if (normalized.length === 0) {
|
|
400
|
+
return "<empty>";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return normalized.length > 200
|
|
404
|
+
? `${normalized.slice(0, 200)}...`
|
|
405
|
+
: normalized;
|
|
406
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { OutputBudget } from "./OutputBudget";
|
|
3
|
+
|
|
4
|
+
describe("truncateLine", () => {
|
|
5
|
+
test("returns lines under the cap untouched", () => {
|
|
6
|
+
expect(OutputBudget.truncateLine("short")).toBe("short");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("truncates with a signposted suffix once over the cap", () => {
|
|
10
|
+
const line = "x".repeat(OutputBudget.maxLineLength + 50);
|
|
11
|
+
expect(OutputBudget.truncateLine(line)).toBe(
|
|
12
|
+
`${"x".repeat(OutputBudget.maxLineLength)}... (line truncated to ${OutputBudget.maxLineLength} chars)`
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("truncateUtf8", () => {
|
|
18
|
+
test("returns content unchanged when under cap", () => {
|
|
19
|
+
const result = OutputBudget.truncateUtf8("hello", 1024);
|
|
20
|
+
expect(result.body).toBe("hello");
|
|
21
|
+
expect(result.truncated).toBe(false);
|
|
22
|
+
expect(result.totalBytes).toBe(5);
|
|
23
|
+
expect(result.returnedBytes).toBe(5);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("truncates ASCII at exact byte boundary", () => {
|
|
27
|
+
const result = OutputBudget.truncateUtf8("a".repeat(100), 10);
|
|
28
|
+
expect(result.body).toBe("a".repeat(10));
|
|
29
|
+
expect(result.truncated).toBe(true);
|
|
30
|
+
expect(result.returnedBytes).toBe(10);
|
|
31
|
+
expect(result.totalBytes).toBe(100);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("backs off to a UTF-8 boundary mid-codepoint", () => {
|
|
35
|
+
const result = OutputBudget.truncateUtf8("é", 1);
|
|
36
|
+
expect(result.body).toBe("");
|
|
37
|
+
expect(result.returnedBytes).toBe(0);
|
|
38
|
+
expect(result.truncated).toBe(true);
|
|
39
|
+
expect(result.totalBytes).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("preserves complete multi-byte chars", () => {
|
|
43
|
+
const result = OutputBudget.truncateUtf8("héllo", 3);
|
|
44
|
+
expect(result.body).toBe("hé");
|
|
45
|
+
expect(result.returnedBytes).toBe(3);
|
|
46
|
+
expect(result.truncated).toBe(true);
|
|
47
|
+
expect(result.totalBytes).toBe(6);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("backs off across a 4-byte sequence", () => {
|
|
51
|
+
const result = OutputBudget.truncateUtf8("🦀x", 2);
|
|
52
|
+
expect(result.body).toBe("");
|
|
53
|
+
expect(result.returnedBytes).toBe(0);
|
|
54
|
+
expect(result.truncated).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("defaults to OutputBudget.maxBytes when no cap supplied", () => {
|
|
58
|
+
const content = "a".repeat(OutputBudget.maxBytes + 100);
|
|
59
|
+
const result = OutputBudget.truncateUtf8(content);
|
|
60
|
+
expect(result.body.length).toBe(OutputBudget.maxBytes);
|
|
61
|
+
expect(result.truncated).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("applyByteCap", () => {
|
|
66
|
+
test("returns all items when their joined length fits", () => {
|
|
67
|
+
const items = ["alpha", "beta", "gamma"];
|
|
68
|
+
const result = OutputBudget.applyByteCap(items);
|
|
69
|
+
expect(result.visible).toEqual(items);
|
|
70
|
+
expect(result.droppedItems).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("drops trailing items past the byte cap", () => {
|
|
74
|
+
const item = "x".repeat(100);
|
|
75
|
+
const items = Array.from({ length: 20 }, () => item);
|
|
76
|
+
const result = OutputBudget.applyByteCap(items, { maxBytes: 250 });
|
|
77
|
+
// 100 + 1 + 100 = 201 fits, adding another 1 + 100 = 302 does not.
|
|
78
|
+
expect(result.visible).toHaveLength(2);
|
|
79
|
+
expect(result.droppedItems).toBe(18);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("always includes the first item even when it overflows alone", () => {
|
|
83
|
+
const head = "x".repeat(500);
|
|
84
|
+
const result = OutputBudget.applyByteCap([head, "tail"], {
|
|
85
|
+
maxBytes: 100,
|
|
86
|
+
});
|
|
87
|
+
expect(result.visible).toEqual([head]);
|
|
88
|
+
expect(result.droppedItems).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("respects a custom separator", () => {
|
|
92
|
+
// Two 5-byte items with a 10-byte separator: 5 + 10 + 5 = 20.
|
|
93
|
+
const result = OutputBudget.applyByteCap(["alpha", "betas"], {
|
|
94
|
+
maxBytes: 20,
|
|
95
|
+
separator: "----------",
|
|
96
|
+
});
|
|
97
|
+
expect(result.visible).toEqual(["alpha", "betas"]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export class OutputBudget {
|
|
2
|
+
public static readonly maxBytes = 32 * 1024;
|
|
3
|
+
public static readonly maxLineLength = 2000;
|
|
4
|
+
|
|
5
|
+
public static truncateLine(line: string): string {
|
|
6
|
+
if (line.length <= OutputBudget.maxLineLength) {
|
|
7
|
+
return line;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return `${line.slice(0, OutputBudget.maxLineLength)}... (line truncated to ${OutputBudget.maxLineLength} chars)`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public static truncateUtf8(
|
|
14
|
+
content: string,
|
|
15
|
+
maxBytes: number = OutputBudget.maxBytes
|
|
16
|
+
): {
|
|
17
|
+
readonly body: string;
|
|
18
|
+
readonly returnedBytes: number;
|
|
19
|
+
readonly totalBytes: number;
|
|
20
|
+
readonly truncated: boolean;
|
|
21
|
+
} {
|
|
22
|
+
const totalBytes = Buffer.byteLength(content, "utf8");
|
|
23
|
+
|
|
24
|
+
if (totalBytes <= maxBytes) {
|
|
25
|
+
return {
|
|
26
|
+
body: content,
|
|
27
|
+
returnedBytes: totalBytes,
|
|
28
|
+
totalBytes,
|
|
29
|
+
truncated: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const encoded = new TextEncoder().encode(content);
|
|
34
|
+
let cut = maxBytes;
|
|
35
|
+
while (cut > 0 && ((encoded[cut] ?? 0) & 0xc0) === 0x80) {
|
|
36
|
+
cut -= 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const body = new TextDecoder("utf-8").decode(encoded.subarray(0, cut));
|
|
40
|
+
|
|
41
|
+
return { body, returnedBytes: cut, totalBytes, truncated: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public static applyByteCap(
|
|
45
|
+
items: readonly string[],
|
|
46
|
+
options: {
|
|
47
|
+
readonly maxBytes?: number;
|
|
48
|
+
readonly separator?: string;
|
|
49
|
+
} = {}
|
|
50
|
+
): {
|
|
51
|
+
readonly visible: readonly string[];
|
|
52
|
+
readonly droppedItems: number;
|
|
53
|
+
} {
|
|
54
|
+
const maxBytes = options.maxBytes ?? OutputBudget.maxBytes;
|
|
55
|
+
const separator = options.separator ?? "\n";
|
|
56
|
+
const separatorBytes = Buffer.byteLength(separator, "utf8");
|
|
57
|
+
const visible: string[] = [];
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const itemBytes = Buffer.byteLength(item, "utf8");
|
|
62
|
+
const cost =
|
|
63
|
+
visible.length === 0 ? itemBytes : separatorBytes + itemBytes;
|
|
64
|
+
|
|
65
|
+
if (visible.length > 0 && bytes + cost > maxBytes) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
visible.push(item);
|
|
70
|
+
bytes += cost;
|
|
71
|
+
|
|
72
|
+
if (bytes >= maxBytes) {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { visible, droppedItems: items.length - visible.length };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { Paths } from "./Paths";
|
|
5
|
+
|
|
6
|
+
describe("Paths.resolve", () => {
|
|
7
|
+
test("resolves relative paths against base", () => {
|
|
8
|
+
expect(Paths.resolve("foo.txt", "/work")).toBe("/work/foo.txt");
|
|
9
|
+
expect(Paths.resolve("src/foo.ts", "/work")).toBe("/work/src/foo.ts");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("expands ~ to home directory", () => {
|
|
13
|
+
const home = homedir();
|
|
14
|
+
expect(Paths.resolve("~", "/tmp")).toBe(home);
|
|
15
|
+
expect(Paths.resolve("~/.config", "/tmp")).toBe(join(home, ".config"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("preserves absolute paths", () => {
|
|
19
|
+
expect(Paths.resolve("/etc/hosts", "/tmp")).toBe("/etc/hosts");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("Paths.displayRelative", () => {
|
|
24
|
+
test("returns relative path when inside cwd", () => {
|
|
25
|
+
expect(Paths.displayRelative("/work/src/foo.ts", "/work")).toBe(
|
|
26
|
+
"src/foo.ts"
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns absolute path when outside cwd", () => {
|
|
31
|
+
expect(Paths.displayRelative("/etc/hosts", "/work")).toBe("/etc/hosts");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns absolute path when cwd equals path", () => {
|
|
35
|
+
expect(Paths.displayRelative("/work", "/work")).toBe("/work");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("Paths.titleOr", () => {
|
|
40
|
+
test("returns the placeholder when path is undefined", () => {
|
|
41
|
+
expect(Paths.titleOr(undefined, "/work")).toBe("...");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns a path relative to cwd when within cwd", () => {
|
|
45
|
+
expect(Paths.titleOr("/work/src/file.ts", "/work")).toBe("src/file.ts");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns the absolute path when outside cwd", () => {
|
|
49
|
+
expect(Paths.titleOr("/other/file.ts", "/work")).toBe("/other/file.ts");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
export class Paths {
|
|
5
|
+
public static pimHomeDir(): string {
|
|
6
|
+
return Paths.expandHome(process.env.PIM_HOME_DIR ?? "~/.pim");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
public static resolve(value: string, baseDir: string): string {
|
|
10
|
+
const expanded = Paths.expandHome(value);
|
|
11
|
+
return isAbsolute(expanded) ? expanded : resolve(baseDir, expanded);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public static toForwardSlashes(path: string): string {
|
|
15
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public static expandHome(value: string): string {
|
|
19
|
+
if (value === "~") {
|
|
20
|
+
return homedir();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (value.startsWith("~/")) {
|
|
24
|
+
return resolve(homedir(), value.slice(2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public static abbreviateHome(path: string): string {
|
|
31
|
+
const home = homedir();
|
|
32
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public static displayRelative(path: string, cwd: string): string {
|
|
36
|
+
const rel = relative(cwd, path);
|
|
37
|
+
|
|
38
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return rel;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public static titleOr(
|
|
46
|
+
path: string | undefined,
|
|
47
|
+
cwd: string,
|
|
48
|
+
placeholder = "..."
|
|
49
|
+
): string {
|
|
50
|
+
return path ? Paths.displayRelative(path, cwd) : placeholder;
|
|
51
|
+
}
|
|
52
|
+
}
|