pi-workspace 0.1.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/.env.example +1 -0
- package/README.md +87 -0
- package/bin/pi-gui.mjs +127 -0
- package/dist/client/.vite/manifest.json +26 -0
- package/dist/client/assets/favicon-_Gh0MYzg.svg +8 -0
- package/dist/client/assets/icon-BBRFWKI9.svg +30 -0
- package/dist/client/assets/index-D24SA7pm.css +1 -0
- package/dist/client/assets/index-qtlLehV3.js +340 -0
- package/dist/client/index.html +15 -0
- package/dist-server/index.mjs +763 -0
- package/package.json +57 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import FastifyVite from "@fastify/vite";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { AuthStorage, ModelRegistry, SessionManager, SettingsManager, createAgentSession, createExtensionRuntime } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
//#region server/chat-validation.ts
|
|
8
|
+
const supportedImageMimeTypes = [
|
|
9
|
+
"image/png",
|
|
10
|
+
"image/jpeg",
|
|
11
|
+
"image/webp",
|
|
12
|
+
"image/gif"
|
|
13
|
+
];
|
|
14
|
+
function getModelSupportsImages(model) {
|
|
15
|
+
return Array.isArray(model.input) && model.input.includes("image");
|
|
16
|
+
}
|
|
17
|
+
function getPromptOrDefault(prompt, images) {
|
|
18
|
+
return prompt?.trim() || (images.length > 0 ? "Please analyze this image." : "");
|
|
19
|
+
}
|
|
20
|
+
function isRecord$2(value) {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function readStringField$1(value, key) {
|
|
24
|
+
return isRecord$2(value) && typeof value[key] === "string" ? value[key] : void 0;
|
|
25
|
+
}
|
|
26
|
+
function parseImages(value) {
|
|
27
|
+
if (value === void 0) return [];
|
|
28
|
+
if (!Array.isArray(value)) throw new Error("images must be an array");
|
|
29
|
+
if (value.length > 1) throw new Error(`Only 1 image can be uploaded per message`);
|
|
30
|
+
return value.map((image) => {
|
|
31
|
+
if (!isRecord$2(image)) throw new Error("Invalid image attachment");
|
|
32
|
+
const mimeType = readStringField$1(image, "mimeType");
|
|
33
|
+
const data = readStringField$1(image, "data");
|
|
34
|
+
const size = typeof image.size === "number" ? image.size : void 0;
|
|
35
|
+
if (!mimeType || !supportedImageMimeTypes.includes(mimeType)) throw new Error("Unsupported image type. Upload PNG, JPEG, WebP, or GIF.");
|
|
36
|
+
if (!data || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) throw new Error("Invalid image data");
|
|
37
|
+
const byteLength = Buffer.byteLength(data, "base64");
|
|
38
|
+
if (byteLength === 0 || byteLength > 5242880) throw new Error("Image must be smaller than 5 MB");
|
|
39
|
+
if (size && size > 5242880) throw new Error("Image must be smaller than 5 MB");
|
|
40
|
+
return {
|
|
41
|
+
type: "image",
|
|
42
|
+
mimeType,
|
|
43
|
+
data
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region server/pi-sessions.ts
|
|
49
|
+
const MAX_FIRST_MESSAGE_LENGTH = 120;
|
|
50
|
+
const MAX_TOOL_CONTENT_LENGTH = 12e3;
|
|
51
|
+
function truncate(text, maxLength) {
|
|
52
|
+
if (text.length <= maxLength) return text;
|
|
53
|
+
return text.slice(0, maxLength) + "…";
|
|
54
|
+
}
|
|
55
|
+
/** Truncate a session first message for display, appending "…" when cut. */
|
|
56
|
+
function truncateFirstMessage(text) {
|
|
57
|
+
return truncate(text, MAX_FIRST_MESSAGE_LENGTH);
|
|
58
|
+
}
|
|
59
|
+
function extractProjectName(cwd) {
|
|
60
|
+
if (!cwd) return "(unknown)";
|
|
61
|
+
const parts = cwd.replace(/\/$/, "").split("/");
|
|
62
|
+
return parts[parts.length - 1] || "(unknown)";
|
|
63
|
+
}
|
|
64
|
+
function toIsoString(date) {
|
|
65
|
+
return date.toISOString();
|
|
66
|
+
}
|
|
67
|
+
function isRecord$1(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
function readString(value, key) {
|
|
71
|
+
return isRecord$1(value) && typeof value[key] === "string" ? value[key] : void 0;
|
|
72
|
+
}
|
|
73
|
+
function toTimestamp(value, fallback) {
|
|
74
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
75
|
+
const parsed = Date.parse(fallback);
|
|
76
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
77
|
+
}
|
|
78
|
+
function stringifyContent(value) {
|
|
79
|
+
return truncate(value, MAX_TOOL_CONTENT_LENGTH);
|
|
80
|
+
}
|
|
81
|
+
function isTextContent(value) {
|
|
82
|
+
return isRecord$1(value) && value.type === "text" && typeof value.text === "string";
|
|
83
|
+
}
|
|
84
|
+
function isImageContent(value) {
|
|
85
|
+
return isRecord$1(value) && value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string";
|
|
86
|
+
}
|
|
87
|
+
function isThinkingContent(value) {
|
|
88
|
+
return isRecord$1(value) && value.type === "thinking" && typeof value.thinking === "string";
|
|
89
|
+
}
|
|
90
|
+
function isToolCallContent(value) {
|
|
91
|
+
return isRecord$1(value) && value.type === "toolCall" && typeof value.id === "string" && typeof value.name === "string";
|
|
92
|
+
}
|
|
93
|
+
function normalizeRichContent(content, entryId) {
|
|
94
|
+
if (typeof content === "string") return {
|
|
95
|
+
text: content,
|
|
96
|
+
images: [],
|
|
97
|
+
toolCalls: []
|
|
98
|
+
};
|
|
99
|
+
if (!Array.isArray(content)) return {
|
|
100
|
+
text: "",
|
|
101
|
+
images: [],
|
|
102
|
+
toolCalls: []
|
|
103
|
+
};
|
|
104
|
+
const textParts = [];
|
|
105
|
+
const images = [];
|
|
106
|
+
const toolCalls = [];
|
|
107
|
+
let imageIndex = 0;
|
|
108
|
+
for (const item of content) {
|
|
109
|
+
if (isTextContent(item)) {
|
|
110
|
+
textParts.push(item.text);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (isImageContent(item)) {
|
|
114
|
+
imageIndex += 1;
|
|
115
|
+
images.push({
|
|
116
|
+
id: `${entryId}-image-${imageIndex - 1}`,
|
|
117
|
+
data: item.data,
|
|
118
|
+
mimeType: item.mimeType,
|
|
119
|
+
name: `Pi session image ${imageIndex}`
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (isToolCallContent(item)) {
|
|
124
|
+
toolCalls.push(item);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (isThinkingContent(item)) continue;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
text: textParts.join("\n\n").trim(),
|
|
131
|
+
images,
|
|
132
|
+
toolCalls
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function formatToolArguments(argumentsValue) {
|
|
136
|
+
if (typeof argumentsValue === "string") return stringifyContent(argumentsValue);
|
|
137
|
+
try {
|
|
138
|
+
return stringifyContent(JSON.stringify(argumentsValue ?? {}, null, 2));
|
|
139
|
+
} catch {
|
|
140
|
+
return "{}";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function normalizeContentToText(content) {
|
|
144
|
+
if (typeof content === "string") return content;
|
|
145
|
+
if (!Array.isArray(content)) return "";
|
|
146
|
+
return content.filter((item) => isTextContent(item)).map((item) => item.text).join("\n\n").trim();
|
|
147
|
+
}
|
|
148
|
+
function toCustomSummaryTitle(customType) {
|
|
149
|
+
if (!customType) return "Custom Note";
|
|
150
|
+
const normalized = customType.replace(/[-_]+/g, " ").trim().replace(/\b\w/g, (match) => match.toUpperCase());
|
|
151
|
+
return normalized ? `Custom ${normalized}` : "Custom Note";
|
|
152
|
+
}
|
|
153
|
+
function normalizeMessageEntry(entry) {
|
|
154
|
+
const message = entry.message;
|
|
155
|
+
if (!message || typeof message.role !== "string") return [];
|
|
156
|
+
const timestamp = toTimestamp(message.timestamp, entry.timestamp);
|
|
157
|
+
if (message.role === "user") {
|
|
158
|
+
const normalized = normalizeRichContent(message.content, entry.id);
|
|
159
|
+
return [{
|
|
160
|
+
id: entry.id,
|
|
161
|
+
role: "user",
|
|
162
|
+
content: normalized.text,
|
|
163
|
+
images: normalized.images.length > 0 ? normalized.images : void 0,
|
|
164
|
+
timestamp
|
|
165
|
+
}];
|
|
166
|
+
}
|
|
167
|
+
if (message.role === "assistant") {
|
|
168
|
+
const normalized = normalizeRichContent(message.content, entry.id);
|
|
169
|
+
const result = [];
|
|
170
|
+
if (normalized.text) result.push({
|
|
171
|
+
id: entry.id,
|
|
172
|
+
role: "assistant",
|
|
173
|
+
content: normalized.text,
|
|
174
|
+
provider: message.provider,
|
|
175
|
+
model: message.model,
|
|
176
|
+
timestamp
|
|
177
|
+
});
|
|
178
|
+
for (const toolCall of normalized.toolCalls) result.push({
|
|
179
|
+
id: `${entry.id}:${toolCall.id}`,
|
|
180
|
+
role: "tool",
|
|
181
|
+
toolName: toolCall.name,
|
|
182
|
+
content: formatToolArguments(toolCall.arguments),
|
|
183
|
+
isError: false,
|
|
184
|
+
expandable: true,
|
|
185
|
+
timestamp
|
|
186
|
+
});
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
if (message.role === "toolResult") return [{
|
|
190
|
+
id: entry.id,
|
|
191
|
+
role: "tool",
|
|
192
|
+
toolName: message.toolName || "tool",
|
|
193
|
+
content: stringifyContent(normalizeContentToText(message.content)),
|
|
194
|
+
isError: Boolean(message.isError),
|
|
195
|
+
expandable: true,
|
|
196
|
+
timestamp
|
|
197
|
+
}];
|
|
198
|
+
if (message.role === "bashExecution") {
|
|
199
|
+
const lines = [`$ ${message.command || ""}`];
|
|
200
|
+
if (message.output) lines.push(message.output);
|
|
201
|
+
return [{
|
|
202
|
+
id: entry.id,
|
|
203
|
+
role: "tool",
|
|
204
|
+
toolName: "bash",
|
|
205
|
+
content: stringifyContent(lines.join("\n").trim()),
|
|
206
|
+
isError: typeof message.exitCode === "number" ? message.exitCode !== 0 : false,
|
|
207
|
+
expandable: true,
|
|
208
|
+
timestamp
|
|
209
|
+
}];
|
|
210
|
+
}
|
|
211
|
+
if (message.role === "custom") {
|
|
212
|
+
if (!message.display) return [];
|
|
213
|
+
return [{
|
|
214
|
+
id: entry.id,
|
|
215
|
+
role: "summary",
|
|
216
|
+
summaryType: "custom",
|
|
217
|
+
title: toCustomSummaryTitle(message.customType),
|
|
218
|
+
content: normalizeContentToText(message.content) || "",
|
|
219
|
+
timestamp
|
|
220
|
+
}];
|
|
221
|
+
}
|
|
222
|
+
if (message.role === "branchSummary") return [{
|
|
223
|
+
id: entry.id,
|
|
224
|
+
role: "summary",
|
|
225
|
+
summaryType: "branch",
|
|
226
|
+
title: "Branch Summary",
|
|
227
|
+
content: readString(message, "summary") || "",
|
|
228
|
+
timestamp
|
|
229
|
+
}];
|
|
230
|
+
if (message.role === "compactionSummary") return [{
|
|
231
|
+
id: entry.id,
|
|
232
|
+
role: "summary",
|
|
233
|
+
summaryType: "compaction",
|
|
234
|
+
title: "Compaction Summary",
|
|
235
|
+
content: readString(message, "summary") || "",
|
|
236
|
+
timestamp
|
|
237
|
+
}];
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
function normalizeSummaryEntry(entry) {
|
|
241
|
+
const timestamp = toTimestamp(void 0, entry.timestamp);
|
|
242
|
+
const summary = entry.summary || "";
|
|
243
|
+
if (entry.type === "compaction") return [{
|
|
244
|
+
id: entry.id,
|
|
245
|
+
role: "summary",
|
|
246
|
+
summaryType: "compaction",
|
|
247
|
+
title: "Compaction Summary",
|
|
248
|
+
content: summary,
|
|
249
|
+
timestamp
|
|
250
|
+
}];
|
|
251
|
+
if (entry.type === "branch_summary") return [{
|
|
252
|
+
id: entry.id,
|
|
253
|
+
role: "summary",
|
|
254
|
+
summaryType: "branch",
|
|
255
|
+
title: "Branch Summary",
|
|
256
|
+
content: summary,
|
|
257
|
+
timestamp
|
|
258
|
+
}];
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
function groupSessionsByProject(rawSessions) {
|
|
262
|
+
const groups = /* @__PURE__ */ new Map();
|
|
263
|
+
for (const session of rawSessions) {
|
|
264
|
+
const key = session.cwd || "";
|
|
265
|
+
if (!groups.has(key)) groups.set(key, {
|
|
266
|
+
path: session.cwd,
|
|
267
|
+
sessions: []
|
|
268
|
+
});
|
|
269
|
+
groups.get(key).sessions.push(session);
|
|
270
|
+
}
|
|
271
|
+
const projects = [];
|
|
272
|
+
for (const [cwd, group] of groups) {
|
|
273
|
+
group.sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
274
|
+
projects.push({
|
|
275
|
+
name: extractProjectName(cwd),
|
|
276
|
+
path: group.path,
|
|
277
|
+
sessions: group.sessions.map((s) => ({
|
|
278
|
+
id: s.id,
|
|
279
|
+
firstMessage: truncateFirstMessage(s.firstMessage),
|
|
280
|
+
messageCount: s.messageCount,
|
|
281
|
+
created: toIsoString(s.created),
|
|
282
|
+
modified: toIsoString(s.modified)
|
|
283
|
+
}))
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
287
|
+
return projects;
|
|
288
|
+
}
|
|
289
|
+
function findSessionById(rawSessions, sessionId) {
|
|
290
|
+
return rawSessions.find((session) => session.id === sessionId) || null;
|
|
291
|
+
}
|
|
292
|
+
function normalizeBranchEntries(entries) {
|
|
293
|
+
return entries.flatMap((entry) => {
|
|
294
|
+
if (entry.type === "message") return normalizeMessageEntry(entry);
|
|
295
|
+
return normalizeSummaryEntry(entry);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
function inferBranchModel(entries) {
|
|
299
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
300
|
+
const entry = entries[index];
|
|
301
|
+
if (entry.type === "model_change" && typeof readString(entry, "provider") === "string" && typeof readString(entry, "modelId") === "string") return {
|
|
302
|
+
provider: readString(entry, "provider"),
|
|
303
|
+
modelId: readString(entry, "modelId")
|
|
304
|
+
};
|
|
305
|
+
if (entry.type !== "message") continue;
|
|
306
|
+
const message = entry.message;
|
|
307
|
+
if (message?.role === "assistant" && typeof message.provider === "string" && typeof message.model === "string") return {
|
|
308
|
+
provider: message.provider,
|
|
309
|
+
modelId: message.model
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
function buildPiSessionDetail(session, entries) {
|
|
315
|
+
return {
|
|
316
|
+
session: {
|
|
317
|
+
id: session.id,
|
|
318
|
+
name: session.name?.trim() || session.firstMessage.trim() || "Pi session",
|
|
319
|
+
cwd: session.cwd,
|
|
320
|
+
projectName: extractProjectName(session.cwd),
|
|
321
|
+
created: toIsoString(session.created),
|
|
322
|
+
modified: toIsoString(session.modified)
|
|
323
|
+
},
|
|
324
|
+
messages: normalizeBranchEntries(entries)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async function loadPiSessionDetailById(sessionId) {
|
|
328
|
+
const match = findSessionById(await SessionManager.listAll(), sessionId);
|
|
329
|
+
if (!match) return null;
|
|
330
|
+
return buildPiSessionDetail(match, SessionManager.open(match.path).getBranch());
|
|
331
|
+
}
|
|
332
|
+
async function loadPiSessionContextById(sessionId) {
|
|
333
|
+
const match = findSessionById(await SessionManager.listAll(), sessionId);
|
|
334
|
+
if (!match) return null;
|
|
335
|
+
const sessionManager = SessionManager.open(match.path);
|
|
336
|
+
const entries = sessionManager.getBranch();
|
|
337
|
+
return {
|
|
338
|
+
session: match,
|
|
339
|
+
sessionManager,
|
|
340
|
+
entries,
|
|
341
|
+
model: inferBranchModel(entries)
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region server/index.ts
|
|
346
|
+
const commandCodeProviderBaseUrl = process.env.COMMANDCODE_API_BASE || "https://api.commandcode.ai/provider";
|
|
347
|
+
const commandCodeModelsUrl = process.env.COMMANDCODE_MODELS_URL || "https://api.commandcode.ai/provider/v1/models";
|
|
348
|
+
const commandCodeOpenAiBaseUrl = `${commandCodeProviderBaseUrl.replace(/\/$/, "")}/v1`;
|
|
349
|
+
const commandCodeAnthropicBaseUrl = commandCodeProviderBaseUrl.replace(/\/$/, "");
|
|
350
|
+
const commandCodeDefaultMaxTokens = 65536;
|
|
351
|
+
const port = Number(process.env.PORT || 8787);
|
|
352
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
353
|
+
const piSessions = /* @__PURE__ */ new Map();
|
|
354
|
+
function sendEvent(res, event) {
|
|
355
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
356
|
+
}
|
|
357
|
+
function buildResourceLoader(systemPrompt) {
|
|
358
|
+
return {
|
|
359
|
+
getExtensions: () => ({
|
|
360
|
+
extensions: [],
|
|
361
|
+
errors: [],
|
|
362
|
+
runtime: createExtensionRuntime()
|
|
363
|
+
}),
|
|
364
|
+
getSkills: () => ({
|
|
365
|
+
skills: [],
|
|
366
|
+
diagnostics: []
|
|
367
|
+
}),
|
|
368
|
+
getPrompts: () => ({
|
|
369
|
+
prompts: [],
|
|
370
|
+
diagnostics: []
|
|
371
|
+
}),
|
|
372
|
+
getThemes: () => ({
|
|
373
|
+
themes: [],
|
|
374
|
+
diagnostics: []
|
|
375
|
+
}),
|
|
376
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
377
|
+
getSystemPrompt: () => systemPrompt,
|
|
378
|
+
getAppendSystemPrompt: () => [],
|
|
379
|
+
extendResources: () => {},
|
|
380
|
+
reload: async () => {}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function configureRuntimeAuth(authStorage) {
|
|
384
|
+
const commandCodeApiKey = readCommandCodeApiKey();
|
|
385
|
+
if (commandCodeApiKey) authStorage.setRuntimeApiKey("commandcode", commandCodeApiKey);
|
|
386
|
+
}
|
|
387
|
+
function isRecord(value) {
|
|
388
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
389
|
+
}
|
|
390
|
+
function readStringField(value, key) {
|
|
391
|
+
return isRecord(value) && typeof value[key] === "string" ? value[key] : void 0;
|
|
392
|
+
}
|
|
393
|
+
function readCommandCodeCredential(value) {
|
|
394
|
+
if (!isRecord(value)) return void 0;
|
|
395
|
+
if (value.type === "api") return readStringField(value, "key");
|
|
396
|
+
if (value.type === "oauth") return readStringField(value, "access");
|
|
397
|
+
return readStringField(value, "key") || readStringField(value, "access");
|
|
398
|
+
}
|
|
399
|
+
function readCommandCodeApiKey() {
|
|
400
|
+
const authPaths = [path.join(homedir(), ".commandcode", "auth.json"), path.join(homedir(), ".pi", "agent", "auth.json")];
|
|
401
|
+
for (const authPath of authPaths) try {
|
|
402
|
+
if (!existsSync(authPath)) continue;
|
|
403
|
+
const parsed = JSON.parse(readFileSync(authPath, "utf-8"));
|
|
404
|
+
if (!isRecord(parsed)) continue;
|
|
405
|
+
const apiKey = readStringField(parsed, "apiKey") || readStringField(parsed, "commandcode") || readCommandCodeCredential(parsed.commandcode) || readCommandCodeCredential(parsed["command-code"]);
|
|
406
|
+
if (apiKey) return apiKey;
|
|
407
|
+
} catch {}
|
|
408
|
+
}
|
|
409
|
+
function getMissingAuthMessage(provider) {
|
|
410
|
+
if (provider === "commandcode") return "Missing local Command Code auth. Sign in with Command Code CLI or add ~/.commandcode/auth.json.";
|
|
411
|
+
return `Missing local ${provider} auth. Sign in with Pi CLI or add credentials to ~/.pi/agent/auth.json.`;
|
|
412
|
+
}
|
|
413
|
+
function parseCommandCodeModels(value) {
|
|
414
|
+
if (!isRecord(value) || value.object !== "list" || !Array.isArray(value.data)) throw new Error("Unexpected Command Code model list response.");
|
|
415
|
+
return value.data.filter((model) => {
|
|
416
|
+
if (!isRecord(model)) return false;
|
|
417
|
+
return typeof model.id === "string" && typeof model.name === "string" && typeof model.context_length === "number";
|
|
418
|
+
}).map((model) => {
|
|
419
|
+
const isClaude = model.id.toLowerCase().startsWith("claude");
|
|
420
|
+
return {
|
|
421
|
+
id: model.id,
|
|
422
|
+
name: `${model.name} (Command Code)`,
|
|
423
|
+
api: isClaude ? "anthropic-messages" : "openai-completions",
|
|
424
|
+
baseUrl: isClaude ? commandCodeAnthropicBaseUrl : commandCodeOpenAiBaseUrl,
|
|
425
|
+
reasoning: true,
|
|
426
|
+
input: ["text"],
|
|
427
|
+
cost: {
|
|
428
|
+
input: 0,
|
|
429
|
+
output: 0,
|
|
430
|
+
cacheRead: 0,
|
|
431
|
+
cacheWrite: 0
|
|
432
|
+
},
|
|
433
|
+
contextWindow: model.context_length,
|
|
434
|
+
maxTokens: Math.min(model.context_length, commandCodeDefaultMaxTokens),
|
|
435
|
+
compat: isClaude ? void 0 : {
|
|
436
|
+
supportsDeveloperRole: false,
|
|
437
|
+
supportsReasoningEffort: false,
|
|
438
|
+
maxTokensField: "max_tokens"
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
async function registerCommandCodeProvider(authStorage, modelRegistry) {
|
|
444
|
+
if (!authStorage.hasAuth("commandcode")) return;
|
|
445
|
+
const response = await fetch(commandCodeModelsUrl, { headers: { accept: "application/json" } });
|
|
446
|
+
if (!response.ok) throw new Error(`Failed to fetch Command Code models: ${response.status} ${response.statusText}`);
|
|
447
|
+
const models = parseCommandCodeModels(await response.json());
|
|
448
|
+
modelRegistry.registerProvider("commandcode", {
|
|
449
|
+
name: "Command Code",
|
|
450
|
+
baseUrl: commandCodeOpenAiBaseUrl,
|
|
451
|
+
apiKey: "local-commandcode-auth",
|
|
452
|
+
authHeader: true,
|
|
453
|
+
api: "openai-completions",
|
|
454
|
+
headers: { "x-cli-environment": "production" },
|
|
455
|
+
models
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
async function createLocalModelRegistry() {
|
|
459
|
+
const authStorage = AuthStorage.create();
|
|
460
|
+
configureRuntimeAuth(authStorage);
|
|
461
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
462
|
+
await registerCommandCodeProvider(authStorage, modelRegistry);
|
|
463
|
+
return {
|
|
464
|
+
authStorage,
|
|
465
|
+
modelRegistry
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
async function getOrCreateSession(sessionId, provider, modelId, systemPrompt) {
|
|
469
|
+
const existing = sessions.get(sessionId);
|
|
470
|
+
if (existing && existing.provider === provider && existing.model === modelId && existing.systemPrompt === systemPrompt) return existing.session;
|
|
471
|
+
existing?.session.dispose();
|
|
472
|
+
const { authStorage, modelRegistry } = await createLocalModelRegistry();
|
|
473
|
+
const model = modelRegistry.find(provider, modelId);
|
|
474
|
+
if (!model) throw new Error(`Unknown model: ${provider}/${modelId}`);
|
|
475
|
+
if (!modelRegistry.hasConfiguredAuth(model)) throw new Error(getMissingAuthMessage(provider));
|
|
476
|
+
const { session } = await createAgentSession({
|
|
477
|
+
cwd: process.cwd(),
|
|
478
|
+
agentDir: path.join(process.cwd(), ".my-pi-agent"),
|
|
479
|
+
authStorage,
|
|
480
|
+
modelRegistry,
|
|
481
|
+
model,
|
|
482
|
+
thinkingLevel: "off",
|
|
483
|
+
noTools: "all",
|
|
484
|
+
resourceLoader: buildResourceLoader(systemPrompt),
|
|
485
|
+
sessionManager: SessionManager.inMemory(process.cwd()),
|
|
486
|
+
settingsManager: SettingsManager.inMemory({
|
|
487
|
+
compaction: { enabled: false },
|
|
488
|
+
retry: {
|
|
489
|
+
enabled: true,
|
|
490
|
+
maxRetries: 1
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
});
|
|
494
|
+
sessions.set(sessionId, {
|
|
495
|
+
session,
|
|
496
|
+
provider,
|
|
497
|
+
model: modelId,
|
|
498
|
+
systemPrompt
|
|
499
|
+
});
|
|
500
|
+
return session;
|
|
501
|
+
}
|
|
502
|
+
async function createPersistedPiSession(piSessionId, provider, modelId) {
|
|
503
|
+
const cached = piSessions.get(piSessionId);
|
|
504
|
+
if (cached) return cached;
|
|
505
|
+
const context = await loadPiSessionContextById(piSessionId);
|
|
506
|
+
if (!context) return null;
|
|
507
|
+
const { authStorage, modelRegistry } = await createLocalModelRegistry();
|
|
508
|
+
const resolvedProvider = provider || context.model?.provider;
|
|
509
|
+
const resolvedModelId = modelId || context.model?.modelId;
|
|
510
|
+
const model = resolvedProvider && resolvedModelId ? modelRegistry.find(resolvedProvider, resolvedModelId) : void 0;
|
|
511
|
+
if (model && !modelRegistry.hasConfiguredAuth(model)) throw new Error(getMissingAuthMessage(model.provider));
|
|
512
|
+
const { session } = await createAgentSession({
|
|
513
|
+
cwd: context.session.cwd,
|
|
514
|
+
authStorage,
|
|
515
|
+
modelRegistry,
|
|
516
|
+
model,
|
|
517
|
+
thinkingLevel: "off",
|
|
518
|
+
sessionManager: context.sessionManager,
|
|
519
|
+
settingsManager: SettingsManager.inMemory({
|
|
520
|
+
compaction: { enabled: false },
|
|
521
|
+
retry: {
|
|
522
|
+
enabled: true,
|
|
523
|
+
maxRetries: 1
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
});
|
|
527
|
+
const record = {
|
|
528
|
+
session,
|
|
529
|
+
provider: model?.provider || resolvedProvider || "unknown",
|
|
530
|
+
modelId: model?.id || resolvedModelId || "unknown"
|
|
531
|
+
};
|
|
532
|
+
piSessions.set(piSessionId, record);
|
|
533
|
+
return record;
|
|
534
|
+
}
|
|
535
|
+
async function buildServer() {
|
|
536
|
+
const server = Fastify({ bodyLimit: 8 * 1024 * 1024 });
|
|
537
|
+
await server.register(FastifyVite, {
|
|
538
|
+
root: path.resolve(import.meta.dirname, ".."),
|
|
539
|
+
dev: process.argv.includes("--dev"),
|
|
540
|
+
spa: true
|
|
541
|
+
});
|
|
542
|
+
server.get("/api/health", async (_request, _reply) => {
|
|
543
|
+
return { ok: true };
|
|
544
|
+
});
|
|
545
|
+
server.post("/api/pi-sessions", async (request, reply) => {
|
|
546
|
+
const { cwd } = request.body;
|
|
547
|
+
if (!cwd?.trim()) {
|
|
548
|
+
reply.code(400);
|
|
549
|
+
return { error: "cwd is required" };
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
SessionManager.create(cwd.trim())._rewriteFile();
|
|
553
|
+
return { projects: groupSessionsByProject(await SessionManager.listAll()) };
|
|
554
|
+
} catch (error) {
|
|
555
|
+
reply.code(500);
|
|
556
|
+
return { error: error instanceof Error ? error.message : "Failed to create Pi session" };
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
server.get("/api/pi-sessions", async (_request, reply) => {
|
|
560
|
+
try {
|
|
561
|
+
return { projects: groupSessionsByProject(await SessionManager.listAll()) };
|
|
562
|
+
} catch (error) {
|
|
563
|
+
reply.code(500);
|
|
564
|
+
return { error: error instanceof Error ? error.message : "Failed to list Pi sessions" };
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
server.get("/api/pi-sessions/:sessionId", async (request, reply) => {
|
|
568
|
+
const { sessionId } = request.params;
|
|
569
|
+
if (!sessionId?.trim()) {
|
|
570
|
+
reply.code(400);
|
|
571
|
+
return { error: "sessionId is required" };
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const detail = await loadPiSessionDetailById(sessionId);
|
|
575
|
+
if (!detail) {
|
|
576
|
+
reply.code(404);
|
|
577
|
+
return { error: "Pi session not found" };
|
|
578
|
+
}
|
|
579
|
+
return detail;
|
|
580
|
+
} catch (error) {
|
|
581
|
+
reply.code(500);
|
|
582
|
+
return { error: error instanceof Error ? error.message : "Failed to load Pi session" };
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
server.get("/api/models", async (_request, reply) => {
|
|
586
|
+
try {
|
|
587
|
+
const { modelRegistry } = await createLocalModelRegistry();
|
|
588
|
+
return { models: modelRegistry.getAvailable().map((model) => ({
|
|
589
|
+
provider: model.provider,
|
|
590
|
+
model: model.id,
|
|
591
|
+
label: `${model.name || model.id} (${model.provider})`,
|
|
592
|
+
supportsImages: getModelSupportsImages(model)
|
|
593
|
+
})) };
|
|
594
|
+
} catch (error) {
|
|
595
|
+
reply.code(500);
|
|
596
|
+
return { error: error instanceof Error ? error.message : "Failed to load models" };
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
server.post("/api/chat", async (request, reply) => {
|
|
600
|
+
const body = request.body;
|
|
601
|
+
const requestedProvider = body.provider;
|
|
602
|
+
const requestedModelId = body.model;
|
|
603
|
+
let images;
|
|
604
|
+
try {
|
|
605
|
+
images = parseImages(body.images);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
reply.code(400);
|
|
608
|
+
return { error: error instanceof Error ? error.message : "Invalid image attachment" };
|
|
609
|
+
}
|
|
610
|
+
const prompt = getPromptOrDefault(body.prompt, images);
|
|
611
|
+
const sessionId = request.headers["x-session-id"] || "default";
|
|
612
|
+
const piSessionId = request.headers["x-pi-session-id"];
|
|
613
|
+
const systemPrompt = body.systemPrompt?.trim() || "You are My Pi, a concise online agent assistant. Ask clarifying questions when requirements are incomplete.";
|
|
614
|
+
if (!prompt) {
|
|
615
|
+
reply.code(400);
|
|
616
|
+
return { error: "prompt is required" };
|
|
617
|
+
}
|
|
618
|
+
if (images.length > 0) try {
|
|
619
|
+
if (piSessionId) {
|
|
620
|
+
const persistedSession = await createPersistedPiSession(piSessionId, requestedProvider, requestedModelId);
|
|
621
|
+
persistedSession?.session.dispose();
|
|
622
|
+
if (!persistedSession) {
|
|
623
|
+
reply.code(404);
|
|
624
|
+
return { error: "Pi session not found" };
|
|
625
|
+
}
|
|
626
|
+
if (persistedSession.provider !== "unknown" && persistedSession.modelId !== "unknown") {
|
|
627
|
+
const { modelRegistry } = await createLocalModelRegistry();
|
|
628
|
+
const model = modelRegistry.find(persistedSession.provider, persistedSession.modelId);
|
|
629
|
+
if (!model || !getModelSupportsImages(model)) {
|
|
630
|
+
reply.code(400);
|
|
631
|
+
return { error: `Model ${persistedSession.provider}/${persistedSession.modelId} does not support image input` };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
const provider = requestedProvider || "openai";
|
|
636
|
+
const modelId = requestedModelId || "gpt-4o-mini";
|
|
637
|
+
const { modelRegistry } = await createLocalModelRegistry();
|
|
638
|
+
const model = modelRegistry.find(provider, modelId);
|
|
639
|
+
if (!model || !getModelSupportsImages(model)) {
|
|
640
|
+
reply.code(400);
|
|
641
|
+
return { error: `Model ${provider}/${modelId} does not support image input` };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch (error) {
|
|
645
|
+
reply.code(500);
|
|
646
|
+
return { error: error instanceof Error ? error.message : "Failed to validate image model support" };
|
|
647
|
+
}
|
|
648
|
+
reply.hijack();
|
|
649
|
+
const raw = reply.raw;
|
|
650
|
+
raw.writeHead(200, {
|
|
651
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
652
|
+
"Cache-Control": "no-cache, no-transform",
|
|
653
|
+
Connection: "keep-alive"
|
|
654
|
+
});
|
|
655
|
+
try {
|
|
656
|
+
const persistedSession = piSessionId ? await createPersistedPiSession(piSessionId, requestedProvider, requestedModelId) : null;
|
|
657
|
+
if (piSessionId && !persistedSession) {
|
|
658
|
+
sendEvent(raw, {
|
|
659
|
+
type: "error",
|
|
660
|
+
error: "Pi session not found"
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const provider = persistedSession?.provider || requestedProvider || "openai";
|
|
665
|
+
const modelId = persistedSession?.modelId || requestedModelId || "gpt-4o-mini";
|
|
666
|
+
const session = persistedSession?.session || await getOrCreateSession(sessionId, provider, modelId, systemPrompt);
|
|
667
|
+
let finalText = "";
|
|
668
|
+
const unsubscribe = session.subscribe((event) => {
|
|
669
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
670
|
+
finalText += event.assistantMessageEvent.delta;
|
|
671
|
+
sendEvent(raw, {
|
|
672
|
+
type: "delta",
|
|
673
|
+
delta: event.assistantMessageEvent.delta
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "thinking_delta") sendEvent(raw, {
|
|
677
|
+
type: "thinking",
|
|
678
|
+
delta: event.assistantMessageEvent.delta
|
|
679
|
+
});
|
|
680
|
+
if (event.type === "tool_execution_start") sendEvent(raw, {
|
|
681
|
+
type: "tool_start",
|
|
682
|
+
toolName: event.toolName,
|
|
683
|
+
toolCallId: event.toolCallId,
|
|
684
|
+
args: typeof event.args === "string" ? event.args : JSON.stringify(event.args ?? {})
|
|
685
|
+
});
|
|
686
|
+
if (event.type === "tool_execution_update") sendEvent(raw, {
|
|
687
|
+
type: "tool_delta",
|
|
688
|
+
toolName: event.toolName,
|
|
689
|
+
toolCallId: event.toolCallId,
|
|
690
|
+
delta: typeof event.partialResult === "string" ? event.partialResult : JSON.stringify(event.partialResult ?? "")
|
|
691
|
+
});
|
|
692
|
+
if (event.type === "tool_execution_end") sendEvent(raw, {
|
|
693
|
+
type: "tool_end",
|
|
694
|
+
toolName: event.toolName,
|
|
695
|
+
toolCallId: event.toolCallId,
|
|
696
|
+
content: typeof event.result === "string" ? event.result : JSON.stringify(event.result ?? ""),
|
|
697
|
+
isError: event.isError
|
|
698
|
+
});
|
|
699
|
+
if (event.type === "agent_end") sendEvent(raw, {
|
|
700
|
+
type: "done",
|
|
701
|
+
message: {
|
|
702
|
+
role: "assistant",
|
|
703
|
+
content: finalText,
|
|
704
|
+
provider,
|
|
705
|
+
model: modelId,
|
|
706
|
+
timestamp: Date.now()
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
try {
|
|
711
|
+
await session.prompt(prompt, images.length > 0 ? { images } : void 0);
|
|
712
|
+
} finally {
|
|
713
|
+
unsubscribe();
|
|
714
|
+
if (!piSessionId) persistedSession?.session.dispose();
|
|
715
|
+
}
|
|
716
|
+
} catch (error) {
|
|
717
|
+
sendEvent(raw, {
|
|
718
|
+
type: "error",
|
|
719
|
+
error: error instanceof Error ? error.message : "Unexpected server error"
|
|
720
|
+
});
|
|
721
|
+
} finally {
|
|
722
|
+
raw.end();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
server.setNotFoundHandler((_request, reply) => {
|
|
726
|
+
return reply.html();
|
|
727
|
+
});
|
|
728
|
+
await server.vite.ready();
|
|
729
|
+
return server;
|
|
730
|
+
}
|
|
731
|
+
async function startWithRetry(fastify, retries) {
|
|
732
|
+
for (let attempt = 1; attempt <= retries; attempt++) try {
|
|
733
|
+
const address = await new Promise((resolve, reject) => {
|
|
734
|
+
fastify.listen({
|
|
735
|
+
port,
|
|
736
|
+
host: "127.0.0.1"
|
|
737
|
+
}, (err, addr) => {
|
|
738
|
+
if (err) reject(err);
|
|
739
|
+
else resolve(addr);
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
console.log(`My Pi server listening on ${address}`);
|
|
743
|
+
return;
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (error instanceof Error && error.code === "EADDRINUSE" && attempt < retries) {
|
|
746
|
+
console.log(`Port ${port} is in use, retrying in 1s (attempt ${attempt}/${retries - 1})…`);
|
|
747
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
748
|
+
} else {
|
|
749
|
+
console.error("Failed to start server:", error);
|
|
750
|
+
process.exit(1);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const server = await buildServer();
|
|
755
|
+
process.on("SIGTERM", async () => {
|
|
756
|
+
try {
|
|
757
|
+
await server.close();
|
|
758
|
+
} catch {}
|
|
759
|
+
process.exit(0);
|
|
760
|
+
});
|
|
761
|
+
await startWithRetry(server, 5);
|
|
762
|
+
//#endregion
|
|
763
|
+
export {};
|