@poncho-ai/cli 0.37.0 → 0.38.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/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +264 -0
- package/dist/{chunk-GUGBKAIM.js → chunk-W7SQVUB4.js} +6166 -4694
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +197 -128
- package/dist/index.js +111 -5
- package/dist/run-interactive-ink-UKPUGCDW.js +679 -0
- package/package.json +4 -4
- package/src/cron-helpers.ts +183 -0
- package/src/http-utils.ts +220 -0
- package/src/index.ts +1071 -4754
- package/src/logger.ts +9 -0
- package/src/mcp-commands.ts +283 -0
- package/src/project-init.ts +150 -0
- package/src/run-commands.ts +145 -0
- package/src/scaffolding.ts +528 -0
- package/src/skills.ts +372 -0
- package/src/templates.ts +563 -0
- package/src/testing.ts +108 -0
- package/src/web-ui-client.ts +845 -94
- package/src/web-ui-styles.ts +269 -1
- package/src/web-ui.ts +23 -0
- package/test/cli.test.ts +52 -1
- package/dist/run-interactive-ink-75GKYSEC.js +0 -2115
- package/test/run-orchestration.test.ts +0 -171
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
executeConversationTurn,
|
|
4
|
+
flushTurnDraft,
|
|
5
|
+
buildAssistantMetadata,
|
|
6
|
+
TOOL_RESULT_ARCHIVE_PARAM,
|
|
7
|
+
type AgentHarness,
|
|
8
|
+
type Conversation,
|
|
9
|
+
type ConversationStore,
|
|
10
|
+
} from "@poncho-ai/harness";
|
|
11
|
+
import type { AgentEvent, Message } from "@poncho-ai/sdk";
|
|
12
|
+
|
|
13
|
+
export const normalizeMessageForClient = (message: Message): Message | null => {
|
|
14
|
+
// Hide tool-role and system-role messages from the web UI — they are
|
|
15
|
+
// internal harness bookkeeping that leaks into conv.messages when
|
|
16
|
+
// _harnessMessages are used as canonical history.
|
|
17
|
+
if (message.role === "tool" || message.role === "system") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (message.role !== "assistant" || typeof message.content !== "string") {
|
|
21
|
+
return message;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(message.content) as Record<string, unknown>;
|
|
25
|
+
const toolCalls = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : undefined;
|
|
26
|
+
if (toolCalls) {
|
|
27
|
+
const text = typeof parsed.text === "string" ? parsed.text : "";
|
|
28
|
+
const meta = { ...(message.metadata ?? {}) } as Record<string, unknown>;
|
|
29
|
+
if (!meta.sections && toolCalls.length > 0) {
|
|
30
|
+
const toolLabels = toolCalls.map((tc: Record<string, unknown>) => {
|
|
31
|
+
const name = typeof tc.name === "string" ? tc.name : "tool";
|
|
32
|
+
return `✓ ${name}`;
|
|
33
|
+
});
|
|
34
|
+
const sections: { type: string; content: string | string[] }[] = [];
|
|
35
|
+
if (toolLabels.length > 0) sections.push({ type: "tools", content: toolLabels });
|
|
36
|
+
if (text) sections.push({ type: "text", content: text });
|
|
37
|
+
meta.sections = sections;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...message,
|
|
41
|
+
content: text,
|
|
42
|
+
metadata: meta as Message["metadata"],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Keep original assistant content when it's plain text or non-JSON.
|
|
47
|
+
}
|
|
48
|
+
return message;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Shared cron helpers ──────────────────────────────────────────
|
|
52
|
+
// Used by both the HTTP /api/cron endpoint and the local-dev scheduler.
|
|
53
|
+
|
|
54
|
+
export type CronRunResult = {
|
|
55
|
+
response: string;
|
|
56
|
+
steps: number;
|
|
57
|
+
assistantMetadata?: Message["metadata"];
|
|
58
|
+
hasContent: boolean;
|
|
59
|
+
contextTokens: number;
|
|
60
|
+
contextWindow: number;
|
|
61
|
+
harnessMessages?: Message[];
|
|
62
|
+
toolResultArchive?: Conversation["_toolResultArchive"];
|
|
63
|
+
latestRunId: string;
|
|
64
|
+
continuation: boolean;
|
|
65
|
+
continuationMessages?: Message[];
|
|
66
|
+
/** Stable id for the user-turn message persisted by buildCronMessages/appendCronTurn. */
|
|
67
|
+
userMessageId: string;
|
|
68
|
+
/** Timestamp shared by user and assistant messages of this turn. */
|
|
69
|
+
turnTimestamp: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const runCronAgent = async (
|
|
73
|
+
harnessRef: AgentHarness,
|
|
74
|
+
task: string,
|
|
75
|
+
conversationId: string,
|
|
76
|
+
historyMessages: Message[],
|
|
77
|
+
toolResultArchive?: Conversation["_toolResultArchive"],
|
|
78
|
+
onEvent?: (event: AgentEvent) => void | Promise<void>,
|
|
79
|
+
parameters?: Record<string, unknown>,
|
|
80
|
+
tenantId?: string | null,
|
|
81
|
+
): Promise<CronRunResult> => {
|
|
82
|
+
const turnTimestamp = Date.now();
|
|
83
|
+
const userMessageId = randomUUID();
|
|
84
|
+
const assistantId = randomUUID();
|
|
85
|
+
// Callers normally build `parameters` via buildTurnParameters() which
|
|
86
|
+
// already merges the tool-result archive. The `toolResultArchive` arg is a
|
|
87
|
+
// fallback for callers that don't (legacy / minimal callers).
|
|
88
|
+
const finalParameters = {
|
|
89
|
+
...(parameters ?? {}),
|
|
90
|
+
__activeConversationId: conversationId,
|
|
91
|
+
[TOOL_RESULT_ARCHIVE_PARAM]:
|
|
92
|
+
parameters?.[TOOL_RESULT_ARCHIVE_PARAM] ?? toolResultArchive ?? {},
|
|
93
|
+
};
|
|
94
|
+
const execution = await executeConversationTurn({
|
|
95
|
+
harness: harnessRef,
|
|
96
|
+
runInput: {
|
|
97
|
+
task,
|
|
98
|
+
conversationId,
|
|
99
|
+
tenantId: tenantId ?? undefined,
|
|
100
|
+
parameters: finalParameters,
|
|
101
|
+
messages: historyMessages,
|
|
102
|
+
},
|
|
103
|
+
onEvent,
|
|
104
|
+
});
|
|
105
|
+
flushTurnDraft(execution.draft);
|
|
106
|
+
const hasContent = execution.draft.assistantResponse.length > 0 || execution.draft.toolTimeline.length > 0;
|
|
107
|
+
const assistantMetadata = buildAssistantMetadata(execution.draft, undefined, {
|
|
108
|
+
id: assistantId,
|
|
109
|
+
timestamp: turnTimestamp,
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
response: execution.draft.assistantResponse,
|
|
113
|
+
steps: execution.runSteps,
|
|
114
|
+
assistantMetadata,
|
|
115
|
+
hasContent,
|
|
116
|
+
contextTokens: execution.runContextTokens,
|
|
117
|
+
contextWindow: execution.runContextWindow,
|
|
118
|
+
harnessMessages: execution.runHarnessMessages,
|
|
119
|
+
toolResultArchive: harnessRef.getToolResultArchive(conversationId),
|
|
120
|
+
latestRunId: execution.latestRunId,
|
|
121
|
+
continuation: execution.runContinuation,
|
|
122
|
+
continuationMessages: execution.runContinuationMessages,
|
|
123
|
+
userMessageId,
|
|
124
|
+
turnTimestamp,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const buildCronMessages = (
|
|
129
|
+
task: string,
|
|
130
|
+
historyMessages: Message[],
|
|
131
|
+
result: CronRunResult,
|
|
132
|
+
): Message[] => [
|
|
133
|
+
...historyMessages,
|
|
134
|
+
{
|
|
135
|
+
role: "user" as const,
|
|
136
|
+
content: task,
|
|
137
|
+
metadata: { id: result.userMessageId, timestamp: result.turnTimestamp },
|
|
138
|
+
},
|
|
139
|
+
...(result.hasContent
|
|
140
|
+
? [{ role: "assistant" as const, content: result.response, metadata: result.assistantMetadata }]
|
|
141
|
+
: []),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
/** Append a cron turn to a freshly-fetched conversation (avoids overwriting concurrent writes). */
|
|
145
|
+
export const appendCronTurn = (conv: Conversation, task: string, result: CronRunResult): void => {
|
|
146
|
+
conv.messages.push(
|
|
147
|
+
{
|
|
148
|
+
role: "user" as const,
|
|
149
|
+
content: task,
|
|
150
|
+
metadata: { id: result.userMessageId, timestamp: result.turnTimestamp },
|
|
151
|
+
},
|
|
152
|
+
...(result.hasContent
|
|
153
|
+
? [{ role: "assistant" as const, content: result.response, metadata: result.assistantMetadata }]
|
|
154
|
+
: []),
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const MAX_PRUNE_PER_RUN = 25;
|
|
159
|
+
|
|
160
|
+
/** Delete old cron conversations beyond `maxRuns`, capped to avoid API storms on catch-up. */
|
|
161
|
+
export const pruneCronConversations = async (
|
|
162
|
+
store: ConversationStore,
|
|
163
|
+
ownerId: string,
|
|
164
|
+
jobName: string,
|
|
165
|
+
maxRuns: number,
|
|
166
|
+
): Promise<number> => {
|
|
167
|
+
const summaries = await store.listSummaries(ownerId);
|
|
168
|
+
const cronPrefix = `[cron] ${jobName} `;
|
|
169
|
+
const cronSummaries = summaries
|
|
170
|
+
.filter((s) => s.title?.startsWith(cronPrefix))
|
|
171
|
+
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
172
|
+
|
|
173
|
+
if (cronSummaries.length <= maxRuns) return 0;
|
|
174
|
+
|
|
175
|
+
const toDelete = cronSummaries.slice(maxRuns, maxRuns + MAX_PRUNE_PER_RUN);
|
|
176
|
+
let deleted = 0;
|
|
177
|
+
for (const s of toDelete) {
|
|
178
|
+
try {
|
|
179
|
+
if (await store.delete(s.conversationId)) deleted++;
|
|
180
|
+
} catch { /* best-effort per entry */ }
|
|
181
|
+
}
|
|
182
|
+
return deleted;
|
|
183
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
3
|
+
import Busboy from "busboy";
|
|
4
|
+
import type { FileInput } from "@poncho-ai/sdk";
|
|
5
|
+
import type { AgentEvent } from "@poncho-ai/sdk";
|
|
6
|
+
|
|
7
|
+
export const writeJson = (response: ServerResponse, statusCode: number, payload: unknown) => {
|
|
8
|
+
response.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
9
|
+
response.end(JSON.stringify(payload));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const writeHtml = (response: ServerResponse, statusCode: number, payload: string) => {
|
|
13
|
+
response.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" });
|
|
14
|
+
response.end(payload);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const EXT_MIME_MAP: Record<string, string> = {
|
|
18
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
|
|
19
|
+
gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
20
|
+
pdf: "application/pdf", mp4: "video/mp4", webm: "video/webm",
|
|
21
|
+
mp3: "audio/mpeg", wav: "audio/wav", txt: "text/plain",
|
|
22
|
+
json: "application/json", csv: "text/csv", html: "text/html",
|
|
23
|
+
};
|
|
24
|
+
export const extToMime = (ext: string): string => EXT_MIME_MAP[ext] ?? "application/octet-stream";
|
|
25
|
+
|
|
26
|
+
export const readRequestBody = async (request: IncomingMessage): Promise<unknown> => {
|
|
27
|
+
const chunks: Buffer[] = [];
|
|
28
|
+
for await (const chunk of request) {
|
|
29
|
+
chunks.push(Buffer.from(chunk));
|
|
30
|
+
}
|
|
31
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
32
|
+
return body.length > 0 ? (JSON.parse(body) as unknown) : {};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const parseTelegramMessageThreadIdFromPlatformThreadId = (
|
|
36
|
+
platformThreadId: string | undefined,
|
|
37
|
+
chatId: string | undefined,
|
|
38
|
+
): number | undefined => {
|
|
39
|
+
if (!platformThreadId || !chatId) return undefined;
|
|
40
|
+
const parts = platformThreadId.split(":");
|
|
41
|
+
if (parts.length !== 3 || parts[0] !== chatId) return undefined;
|
|
42
|
+
const threadId = Number(parts[1]);
|
|
43
|
+
return Number.isInteger(threadId) ? threadId : undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const MAX_UPLOAD_SIZE = 25 * 1024 * 1024; // 25MB per file
|
|
47
|
+
|
|
48
|
+
export interface ParsedMultipart {
|
|
49
|
+
message: string;
|
|
50
|
+
parameters?: Record<string, unknown>;
|
|
51
|
+
files: FileInput[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const parseMultipartRequest = (request: IncomingMessage): Promise<ParsedMultipart> =>
|
|
55
|
+
new Promise((resolve, reject) => {
|
|
56
|
+
const result: ParsedMultipart = { message: "", files: [] };
|
|
57
|
+
const bb = Busboy({
|
|
58
|
+
headers: request.headers,
|
|
59
|
+
limits: { fileSize: MAX_UPLOAD_SIZE },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
bb.on("field", (name: string, value: string) => {
|
|
63
|
+
if (name === "message") result.message = value;
|
|
64
|
+
if (name === "parameters") {
|
|
65
|
+
try {
|
|
66
|
+
result.parameters = JSON.parse(value) as Record<string, unknown>;
|
|
67
|
+
} catch { /* ignore malformed parameters */ }
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
bb.on("file", (_name: string, stream: NodeJS.ReadableStream, info: { filename: string; mimeType: string }) => {
|
|
72
|
+
const chunks: Buffer[] = [];
|
|
73
|
+
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
74
|
+
stream.on("end", () => {
|
|
75
|
+
const buf = Buffer.concat(chunks);
|
|
76
|
+
result.files.push({
|
|
77
|
+
data: buf.toString("base64"),
|
|
78
|
+
mediaType: info.mimeType,
|
|
79
|
+
filename: info.filename,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
bb.on("finish", () => resolve(result));
|
|
85
|
+
bb.on("error", (err: Error) => reject(err));
|
|
86
|
+
request.pipe(bb);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detects the runtime environment from platform-specific or standard environment variables.
|
|
91
|
+
* Priority: PONCHO_ENV > platform detection (Vercel, Railway, etc.) > NODE_ENV > "development"
|
|
92
|
+
*/
|
|
93
|
+
export const resolveHarnessEnvironment = (): "development" | "staging" | "production" => {
|
|
94
|
+
// Check explicit Poncho environment variable first
|
|
95
|
+
if (process.env.PONCHO_ENV) {
|
|
96
|
+
const value = process.env.PONCHO_ENV.toLowerCase();
|
|
97
|
+
if (value === "production" || value === "staging") {
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
return "development";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Detect platform-specific environment variables
|
|
104
|
+
// Vercel
|
|
105
|
+
if (process.env.VERCEL_ENV) {
|
|
106
|
+
const vercelEnv = process.env.VERCEL_ENV.toLowerCase();
|
|
107
|
+
if (vercelEnv === "production") return "production";
|
|
108
|
+
if (vercelEnv === "preview") return "staging";
|
|
109
|
+
return "development";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Railway
|
|
113
|
+
if (process.env.RAILWAY_ENVIRONMENT) {
|
|
114
|
+
const railwayEnv = process.env.RAILWAY_ENVIRONMENT.toLowerCase();
|
|
115
|
+
if (railwayEnv === "production") return "production";
|
|
116
|
+
return "staging";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Render
|
|
120
|
+
if (process.env.RENDER) {
|
|
121
|
+
// Render sets IS_PULL_REQUEST for preview deploys
|
|
122
|
+
if (process.env.IS_PULL_REQUEST === "true") return "staging";
|
|
123
|
+
return "production";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// AWS Lambda
|
|
127
|
+
if (process.env.AWS_EXECUTION_ENV || process.env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
128
|
+
return "production";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Fly.io
|
|
132
|
+
if (process.env.FLY_APP_NAME) {
|
|
133
|
+
return "production";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fall back to NODE_ENV
|
|
137
|
+
if (process.env.NODE_ENV) {
|
|
138
|
+
const value = process.env.NODE_ENV.toLowerCase();
|
|
139
|
+
if (value === "production" || value === "staging") {
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
return "development";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Default to development
|
|
146
|
+
return "development";
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const listenOnAvailablePort = async (
|
|
150
|
+
server: Server,
|
|
151
|
+
preferredPort: number,
|
|
152
|
+
): Promise<number> =>
|
|
153
|
+
await new Promise<number>((resolveListen, rejectListen) => {
|
|
154
|
+
let currentPort = preferredPort;
|
|
155
|
+
|
|
156
|
+
const tryListen = (): void => {
|
|
157
|
+
const onListening = (): void => {
|
|
158
|
+
server.off("error", onError);
|
|
159
|
+
const address = server.address();
|
|
160
|
+
if (address && typeof address === "object" && typeof address.port === "number") {
|
|
161
|
+
resolveListen(address.port);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
resolveListen(currentPort);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const onError = (error: unknown): void => {
|
|
168
|
+
server.off("listening", onListening);
|
|
169
|
+
if (
|
|
170
|
+
typeof error === "object" &&
|
|
171
|
+
error !== null &&
|
|
172
|
+
"code" in error &&
|
|
173
|
+
(error as { code?: string }).code === "EADDRINUSE"
|
|
174
|
+
) {
|
|
175
|
+
currentPort += 1;
|
|
176
|
+
if (currentPort > 65535) {
|
|
177
|
+
rejectListen(
|
|
178
|
+
new Error(
|
|
179
|
+
"No available ports found from the requested port up to 65535.",
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
setImmediate(tryListen);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
rejectListen(error);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
server.once("listening", onListening);
|
|
191
|
+
server.once("error", onError);
|
|
192
|
+
server.listen(currentPort);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
tryListen();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export const readJsonFile = async <T>(path: string): Promise<T | undefined> => {
|
|
199
|
+
try {
|
|
200
|
+
const content = await readFile(path, "utf8");
|
|
201
|
+
return JSON.parse(content) as T;
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const parseParams = (values: string[]): Record<string, string> => {
|
|
208
|
+
const params: Record<string, string> = {};
|
|
209
|
+
for (const value of values) {
|
|
210
|
+
const [key, ...rest] = value.split("=");
|
|
211
|
+
if (!key) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
params[key] = rest.join("=");
|
|
215
|
+
}
|
|
216
|
+
return params;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const formatSseEvent = (event: AgentEvent): string =>
|
|
220
|
+
`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|