@poncho-ai/harness 0.2.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.
@@ -0,0 +1,44 @@
1
+ import type { Message, ToolDefinition } from "@poncho-ai/sdk";
2
+ import type { LatitudeCapture } from "./latitude-capture.js";
3
+
4
+ export interface ModelResponse {
5
+ text: string;
6
+ toolCalls: Array<{
7
+ id: string;
8
+ name: string;
9
+ input: Record<string, unknown>;
10
+ }>;
11
+ usage: {
12
+ input: number;
13
+ output: number;
14
+ };
15
+ rawContent: unknown[];
16
+ }
17
+
18
+ export interface ModelCallInput {
19
+ systemPrompt: string;
20
+ messages: Message[];
21
+ tools: ToolDefinition[];
22
+ modelName: string;
23
+ temperature?: number;
24
+ maxTokens?: number;
25
+ }
26
+
27
+ export interface ModelClientOptions {
28
+ latitudeCapture?: LatitudeCapture;
29
+ }
30
+
31
+ export type ModelStreamEvent =
32
+ | {
33
+ type: "chunk";
34
+ content: string;
35
+ }
36
+ | {
37
+ type: "final";
38
+ response: ModelResponse;
39
+ };
40
+
41
+ export interface ModelClient {
42
+ generate(input: ModelCallInput): Promise<ModelResponse>;
43
+ generateStream?(input: ModelCallInput): AsyncGenerator<ModelStreamEvent>;
44
+ }
@@ -0,0 +1,14 @@
1
+ import type { ModelClient, ModelClientOptions } from "./model-client.js";
2
+ import { AnthropicModelClient } from "./anthropic-client.js";
3
+ import { OpenAiModelClient } from "./openai-client.js";
4
+
5
+ export const createModelClient = (
6
+ provider?: string,
7
+ options?: ModelClientOptions,
8
+ ): ModelClient => {
9
+ const normalized = (provider ?? "anthropic").toLowerCase();
10
+ if (normalized === "openai") {
11
+ return new OpenAiModelClient(undefined, options);
12
+ }
13
+ return new AnthropicModelClient(undefined, options);
14
+ };
@@ -0,0 +1,169 @@
1
+ import OpenAI from "openai";
2
+ import type { Message } from "@poncho-ai/sdk";
3
+ import type {
4
+ ModelCallInput,
5
+ ModelClient,
6
+ ModelClientOptions,
7
+ ModelResponse,
8
+ ModelStreamEvent,
9
+ } from "./model-client.js";
10
+
11
+ type OpenAIMessage = {
12
+ role: "system" | "user" | "assistant";
13
+ content: string;
14
+ };
15
+
16
+ const toOpenAiMessages = (systemPrompt: string, messages: Message[]): OpenAIMessage[] => {
17
+ const mapped: OpenAIMessage[] = [{ role: "system", content: systemPrompt }];
18
+ for (const message of messages) {
19
+ if (message.role === "system") {
20
+ continue;
21
+ }
22
+ if (message.role === "tool") {
23
+ mapped.push({
24
+ role: "user",
25
+ content: `Tool result context: ${message.content}`,
26
+ });
27
+ continue;
28
+ }
29
+ mapped.push({ role: message.role, content: message.content });
30
+ }
31
+ return mapped;
32
+ };
33
+
34
+ export class OpenAiModelClient implements ModelClient {
35
+ private readonly client: OpenAI;
36
+ private readonly latitudeCapture;
37
+
38
+ constructor(apiKey?: string, options?: ModelClientOptions) {
39
+ this.client = new OpenAI({
40
+ apiKey: apiKey ?? process.env.OPENAI_API_KEY ?? "missing-openai-key",
41
+ });
42
+ this.latitudeCapture = options?.latitudeCapture;
43
+ }
44
+
45
+ async *generateStream(input: ModelCallInput): AsyncGenerator<ModelStreamEvent> {
46
+ const stream = await (this.latitudeCapture?.capture(async () =>
47
+ this.client.chat.completions.create({
48
+ model: input.modelName,
49
+ temperature: input.temperature ?? 0.2,
50
+ max_tokens: input.maxTokens ?? 1024,
51
+ messages: toOpenAiMessages(input.systemPrompt, input.messages),
52
+ tools: input.tools.map((tool) => ({
53
+ type: "function",
54
+ function: {
55
+ name: tool.name,
56
+ description: tool.description,
57
+ parameters: tool.inputSchema as Record<string, unknown>,
58
+ },
59
+ })),
60
+ tool_choice: "auto",
61
+ stream: true,
62
+ stream_options: { include_usage: true },
63
+ }),
64
+ ) ??
65
+ this.client.chat.completions.create({
66
+ model: input.modelName,
67
+ temperature: input.temperature ?? 0.2,
68
+ max_tokens: input.maxTokens ?? 1024,
69
+ messages: toOpenAiMessages(input.systemPrompt, input.messages),
70
+ tools: input.tools.map((tool) => ({
71
+ type: "function",
72
+ function: {
73
+ name: tool.name,
74
+ description: tool.description,
75
+ parameters: tool.inputSchema as Record<string, unknown>,
76
+ },
77
+ })),
78
+ tool_choice: "auto",
79
+ stream: true,
80
+ stream_options: { include_usage: true },
81
+ }));
82
+
83
+ let text = "";
84
+ let inputTokens = 0;
85
+ let outputTokens = 0;
86
+ const toolCallsByIndex = new Map<
87
+ number,
88
+ { id: string; name: string; argumentsJson: string }
89
+ >();
90
+
91
+ for await (const chunk of stream) {
92
+ if (chunk.usage) {
93
+ inputTokens = chunk.usage.prompt_tokens ?? inputTokens;
94
+ outputTokens = chunk.usage.completion_tokens ?? outputTokens;
95
+ }
96
+
97
+ const delta = chunk.choices[0]?.delta;
98
+ if (delta?.content) {
99
+ text += delta.content;
100
+ yield { type: "chunk", content: delta.content };
101
+ }
102
+
103
+ for (const toolCall of delta?.tool_calls ?? []) {
104
+ const index = toolCall.index ?? 0;
105
+ const current = toolCallsByIndex.get(index) ?? {
106
+ id: toolCall.id ?? `tool_call_${index}`,
107
+ name: "",
108
+ argumentsJson: "",
109
+ };
110
+
111
+ if (toolCall.id) {
112
+ current.id = toolCall.id;
113
+ }
114
+ if (toolCall.function?.name) {
115
+ current.name = toolCall.function.name;
116
+ }
117
+ if (toolCall.function?.arguments) {
118
+ current.argumentsJson += toolCall.function.arguments;
119
+ }
120
+
121
+ toolCallsByIndex.set(index, current);
122
+ }
123
+ }
124
+
125
+ const toolCalls: ModelResponse["toolCalls"] = Array.from(toolCallsByIndex.values())
126
+ .filter((call) => call.name.length > 0)
127
+ .map((call) => {
128
+ let parsedInput: Record<string, unknown> = {};
129
+ if (call.argumentsJson.trim().length > 0) {
130
+ try {
131
+ parsedInput = JSON.parse(call.argumentsJson) as Record<string, unknown>;
132
+ } catch {
133
+ parsedInput = { raw: call.argumentsJson };
134
+ }
135
+ }
136
+ return {
137
+ id: call.id,
138
+ name: call.name,
139
+ input: parsedInput,
140
+ };
141
+ });
142
+
143
+ yield {
144
+ type: "final",
145
+ response: {
146
+ text,
147
+ toolCalls,
148
+ usage: {
149
+ input: inputTokens,
150
+ output: outputTokens,
151
+ },
152
+ rawContent: [],
153
+ },
154
+ };
155
+ }
156
+
157
+ async generate(input: ModelCallInput): Promise<ModelResponse> {
158
+ let finalResponse: ModelResponse | undefined;
159
+ for await (const event of this.generateStream(input)) {
160
+ if (event.type === "final") {
161
+ finalResponse = event.response;
162
+ }
163
+ }
164
+ if (!finalResponse) {
165
+ throw new Error("OpenAI response ended without final payload");
166
+ }
167
+ return finalResponse;
168
+ }
169
+ }
@@ -0,0 +1,259 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { dirname, resolve, normalize } from "node:path";
3
+ import YAML from "yaml";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Skill directory scanning — default directories and ecosystem compatibility
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Default directories to scan for skills, relative to the project root.
11
+ * Additional directories can be added via `skillPaths` in poncho.config.js.
12
+ */
13
+ const DEFAULT_SKILL_DIRS: string[] = ["skills"];
14
+
15
+ /**
16
+ * Resolve the full list of skill directories to scan.
17
+ * Merges the defaults with any extra paths provided via config.
18
+ */
19
+ export const resolveSkillDirs = (
20
+ workingDir: string,
21
+ extraPaths?: string[],
22
+ ): string[] => {
23
+ const dirs = [...DEFAULT_SKILL_DIRS];
24
+ if (extraPaths) {
25
+ for (const p of extraPaths) {
26
+ if (!dirs.includes(p)) {
27
+ dirs.push(p);
28
+ }
29
+ }
30
+ }
31
+ return dirs.map((d) => resolve(workingDir, d));
32
+ };
33
+
34
+ export interface SkillMetadata {
35
+ /** Unique skill name from frontmatter. */
36
+ name: string;
37
+ /** What the skill does and when to use it. */
38
+ description: string;
39
+ /** Tool hints declared in frontmatter (spec `allowed-tools` or legacy `tools`). */
40
+ tools: string[];
41
+ /** Absolute path to the skill directory. */
42
+ skillDir: string;
43
+ /** Absolute path to the SKILL.md file. */
44
+ skillPath: string;
45
+ }
46
+
47
+ /**
48
+ * @deprecated Use {@link SkillMetadata} instead. Kept for backward compatibility.
49
+ */
50
+ export type SkillContextEntry = SkillMetadata & {
51
+ instructions: string;
52
+ };
53
+
54
+ const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
55
+
56
+ const asRecord = (value: unknown): Record<string, unknown> =>
57
+ typeof value === "object" && value !== null
58
+ ? (value as Record<string, unknown>)
59
+ : {};
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Frontmatter parsing (metadata only — no body content)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const parseSkillFrontmatter = (
66
+ content: string,
67
+ ): { name: string; description: string; tools: string[] } | undefined => {
68
+ const match = content.match(FRONTMATTER_PATTERN);
69
+ if (!match) {
70
+ return undefined;
71
+ }
72
+
73
+ const parsedYaml = YAML.parse(match[1]) ?? {};
74
+ const parsed = asRecord(parsedYaml);
75
+ const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
76
+ if (!name) {
77
+ return undefined;
78
+ }
79
+
80
+ const description =
81
+ typeof parsed.description === "string" ? parsed.description.trim() : "";
82
+
83
+ const allowedToolsValue = parsed["allowed-tools"];
84
+ const allowedTools =
85
+ typeof allowedToolsValue === "string"
86
+ ? allowedToolsValue
87
+ .split(/\s+/)
88
+ .map((tool) => tool.trim())
89
+ .filter((tool) => tool.length > 0)
90
+ : [];
91
+
92
+ const legacyToolsValue = parsed.tools;
93
+ const legacyTools = Array.isArray(legacyToolsValue)
94
+ ? legacyToolsValue.filter((tool): tool is string => typeof tool === "string")
95
+ : [];
96
+
97
+ const tools = allowedTools.length > 0 ? allowedTools : legacyTools;
98
+
99
+ return { name, description, tools };
100
+ };
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Discovery — find all SKILL.md files recursively
104
+ // ---------------------------------------------------------------------------
105
+
106
+ const collectSkillManifests = async (directory: string): Promise<string[]> => {
107
+ const entries = await readdir(directory, { withFileTypes: true });
108
+ const files: string[] = [];
109
+
110
+ for (const entry of entries) {
111
+ const fullPath = resolve(directory, entry.name);
112
+ if (entry.isDirectory()) {
113
+ files.push(...(await collectSkillManifests(fullPath)));
114
+ continue;
115
+ }
116
+ if (entry.isFile() && entry.name.toLowerCase() === "skill.md") {
117
+ files.push(fullPath);
118
+ }
119
+ }
120
+
121
+ return files;
122
+ };
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Public: load only metadata at startup (name + description per skill)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export const loadSkillMetadata = async (
129
+ workingDir: string,
130
+ extraSkillPaths?: string[],
131
+ ): Promise<SkillMetadata[]> => {
132
+ const skillDirs = resolveSkillDirs(workingDir, extraSkillPaths);
133
+ const allManifests: string[] = [];
134
+
135
+ for (const dir of skillDirs) {
136
+ try {
137
+ allManifests.push(...(await collectSkillManifests(dir)));
138
+ } catch {
139
+ // Directory doesn't exist or isn't readable — skip silently.
140
+ }
141
+ }
142
+
143
+ const skills: SkillMetadata[] = [];
144
+ const seen = new Set<string>();
145
+
146
+ for (const manifest of allManifests) {
147
+ try {
148
+ const content = await readFile(manifest, "utf8");
149
+ const parsed = parseSkillFrontmatter(content);
150
+ if (parsed && !seen.has(parsed.name)) {
151
+ seen.add(parsed.name);
152
+ skills.push({
153
+ ...parsed,
154
+ skillDir: dirname(manifest),
155
+ skillPath: manifest,
156
+ });
157
+ }
158
+ } catch {
159
+ // Ignore unreadable skill manifests.
160
+ }
161
+ }
162
+ return skills;
163
+ };
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Public: build the <available_skills> XML injected into the system prompt
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export const buildSkillContextWindow = (skills: SkillMetadata[]): string => {
170
+ if (skills.length === 0) {
171
+ return "";
172
+ }
173
+
174
+ const xmlSkills = skills
175
+ .map((skill) => {
176
+ const lines = [
177
+ " <skill>",
178
+ ` <name>${escapeXml(skill.name)}</name>`,
179
+ ];
180
+ if (skill.description) {
181
+ lines.push(
182
+ ` <description>${escapeXml(skill.description)}</description>`,
183
+ );
184
+ }
185
+ lines.push(" </skill>");
186
+ return lines.join("\n");
187
+ })
188
+ .join("\n");
189
+
190
+ return `<available_skills description="Skills the agent can use. Use the activate_skill tool to load full instructions for a skill when a user's request matches its description.">
191
+ ${xmlSkills}
192
+ </available_skills>`;
193
+ };
194
+
195
+ const escapeXml = (value: string): string =>
196
+ value
197
+ .replace(/&/g, "&amp;")
198
+ .replace(/</g, "&lt;")
199
+ .replace(/>/g, "&gt;")
200
+ .replace(/"/g, "&quot;")
201
+ .replace(/'/g, "&apos;");
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Public: on-demand activation — load the full SKILL.md body
205
+ // ---------------------------------------------------------------------------
206
+
207
+ export const loadSkillInstructions = async (
208
+ skill: SkillMetadata,
209
+ ): Promise<string> => {
210
+ const content = await readFile(skill.skillPath, "utf8");
211
+ const match = content.match(FRONTMATTER_PATTERN);
212
+ return match ? match[2].trim() : content.trim();
213
+ };
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Public: on-demand resource reading from a skill directory
217
+ // ---------------------------------------------------------------------------
218
+
219
+ export const readSkillResource = async (
220
+ skill: SkillMetadata,
221
+ relativePath: string,
222
+ ): Promise<string> => {
223
+ const normalized = normalize(relativePath);
224
+ if (normalized.startsWith("..") || normalized.startsWith("/")) {
225
+ throw new Error("Path must be relative and within the skill directory");
226
+ }
227
+ const fullPath = resolve(skill.skillDir, normalized);
228
+ // Ensure the resolved path is still inside the skill directory
229
+ if (!fullPath.startsWith(skill.skillDir)) {
230
+ throw new Error("Path escapes the skill directory");
231
+ }
232
+ return await readFile(fullPath, "utf8");
233
+ };
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Backward-compat: loadSkillContext (returns full entries with instructions)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ const MAX_INSTRUCTIONS_PER_SKILL = 1200;
240
+
241
+ export const loadSkillContext = async (
242
+ workingDir: string,
243
+ ): Promise<SkillContextEntry[]> => {
244
+ const metadata = await loadSkillMetadata(workingDir);
245
+ const entries: SkillContextEntry[] = [];
246
+ for (const skill of metadata) {
247
+ try {
248
+ const instructions = await loadSkillInstructions(skill);
249
+ const trimmed =
250
+ instructions.length > MAX_INSTRUCTIONS_PER_SKILL
251
+ ? `${instructions.slice(0, MAX_INSTRUCTIONS_PER_SKILL)}...`
252
+ : instructions;
253
+ entries.push({ ...skill, instructions: trimmed });
254
+ } catch {
255
+ entries.push({ ...skill, instructions: "" });
256
+ }
257
+ }
258
+ return entries;
259
+ };