@ai-sdk/harness-pi 0.0.0 → 1.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/LICENSE +13 -0
- package/README.md +52 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2178 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -1
- package/src/index.ts +11 -0
- package/src/pi-auth.ts +168 -0
- package/src/pi-events.ts +137 -0
- package/src/pi-harness.ts +145 -0
- package/src/pi-model-resolver.ts +52 -0
- package/src/pi-paths.ts +136 -0
- package/src/pi-remote-ops.ts +293 -0
- package/src/pi-resume-state.ts +81 -0
- package/src/pi-session.ts +1179 -0
- package/src/pi-skills.ts +69 -0
- package/src/pi-translate.ts +331 -0
- package/src/pi-typebox-adapter.ts +11 -0
- package/src/pi-utils.ts +91 -0
- package/src/pi-workspace-mirror.ts +276 -0
- package/src/pi-workspace-vfs.ts +437 -0
package/src/pi-skills.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { HarnessV1Skill } from '@ai-sdk/harness';
|
|
3
|
+
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
4
|
+
import { renderPiSkillFile, safePiMetadataSegment } from './pi-utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Materialize Pi skills as files under
|
|
8
|
+
* `$HOME/.agents/skills/<name>/SKILL.md` inside the sandbox.
|
|
9
|
+
*/
|
|
10
|
+
export async function writePiSkills(args: {
|
|
11
|
+
readonly sandbox: Experimental_SandboxSession;
|
|
12
|
+
readonly sandboxHomeDir: string;
|
|
13
|
+
readonly skills: ReadonlyArray<HarnessV1Skill>;
|
|
14
|
+
readonly abortSignal?: AbortSignal;
|
|
15
|
+
}): Promise<void> {
|
|
16
|
+
for (const skill of args.skills) {
|
|
17
|
+
safePiMetadataSegment(skill.name, 'skill');
|
|
18
|
+
for (const file of skill.files ?? []) {
|
|
19
|
+
safePiSkillFilePath({ skillName: skill.name, filePath: file.path });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const skill of args.skills) {
|
|
24
|
+
const name = safePiMetadataSegment(skill.name, 'skill');
|
|
25
|
+
const sandboxSkillDir = path.posix.join(
|
|
26
|
+
args.sandboxHomeDir,
|
|
27
|
+
'.agents',
|
|
28
|
+
'skills',
|
|
29
|
+
name,
|
|
30
|
+
);
|
|
31
|
+
const content = renderPiSkillFile(skill);
|
|
32
|
+
|
|
33
|
+
await args.sandbox.writeTextFile({
|
|
34
|
+
path: path.posix.join(sandboxSkillDir, 'SKILL.md'),
|
|
35
|
+
content,
|
|
36
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (const file of skill.files ?? []) {
|
|
40
|
+
const filePath = safePiSkillFilePath({
|
|
41
|
+
skillName: skill.name,
|
|
42
|
+
filePath: file.path,
|
|
43
|
+
});
|
|
44
|
+
await args.sandbox.writeTextFile({
|
|
45
|
+
path: path.posix.join(sandboxSkillDir, filePath),
|
|
46
|
+
content: file.content,
|
|
47
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function safePiSkillFilePath({
|
|
54
|
+
skillName,
|
|
55
|
+
filePath,
|
|
56
|
+
}: {
|
|
57
|
+
skillName: string;
|
|
58
|
+
filePath: string;
|
|
59
|
+
}): string {
|
|
60
|
+
const normalized = path.posix.normalize(filePath);
|
|
61
|
+
if (
|
|
62
|
+
normalized === '.' ||
|
|
63
|
+
normalized.startsWith('../') ||
|
|
64
|
+
path.posix.isAbsolute(normalized)
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`Invalid Pi skill file path for ${skillName}: ${filePath}`);
|
|
67
|
+
}
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import type { HarnessV1StreamPart } from '@ai-sdk/harness';
|
|
3
|
+
import { extractAssistantText, type PiSessionEvent } from './pi-events';
|
|
4
|
+
import { serializeToolOutput } from './pi-utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translator state shared across all events of a single turn. Reset at the
|
|
8
|
+
* start of every `doPromptTurn`. Callers update the same instance and read it to
|
|
9
|
+
* decide when a turn has settled into a steady state (e.g. for gap-filling).
|
|
10
|
+
*/
|
|
11
|
+
export interface PiTranslatorState {
|
|
12
|
+
/**
|
|
13
|
+
* True once a `turn_start` or assistant `message_start` event has been
|
|
14
|
+
* observed. Suppresses spurious deltas that arrive before the turn opens.
|
|
15
|
+
*/
|
|
16
|
+
promptStarted: boolean;
|
|
17
|
+
/** Accumulated assistant text from `text_delta` events. */
|
|
18
|
+
streamedAssistantText: string;
|
|
19
|
+
/** Stream-part id for the active text block; synthesized on first delta. */
|
|
20
|
+
currentTextId: string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Stream-part id for the active reasoning block; synthesized lazily on
|
|
23
|
+
* first `thinking_delta`.
|
|
24
|
+
*/
|
|
25
|
+
currentReasoningId: string | undefined;
|
|
26
|
+
/** Whether a `reasoning-start` event has already been emitted. */
|
|
27
|
+
reasoningStarted: boolean;
|
|
28
|
+
/** Tool-call id → tool name (used to fill in `toolName` on results). */
|
|
29
|
+
observedToolNames: Map<string, string>;
|
|
30
|
+
/**
|
|
31
|
+
* Tool-call id → the exact output value the host submitted for a
|
|
32
|
+
* user-registered (host-executed) tool. Pi only echoes the tool result back
|
|
33
|
+
* as serialized text (the tool handler stringifies the output before handing
|
|
34
|
+
* it to the runtime so the model can read it), which would otherwise reach
|
|
35
|
+
* consumers as a string and lose the original object structure. Keeping the
|
|
36
|
+
* submitted value here lets the result projection surface the original object
|
|
37
|
+
* — matching the other adapters — while the model still receives the text.
|
|
38
|
+
* Populated by the session's `submitToolResult`; consumed (and cleared) when
|
|
39
|
+
* the matching `tool_result`/`tool_execution_end` event is translated.
|
|
40
|
+
*/
|
|
41
|
+
hostToolResults: Map<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Names of tools that Pi executes natively (read/write/edit/bash/grep/
|
|
44
|
+
* find/ls). `tool-call` events for these get `providerExecuted: true`
|
|
45
|
+
* so the harness host doesn't try to dispatch them. User-registered
|
|
46
|
+
* tools are not in this set.
|
|
47
|
+
*/
|
|
48
|
+
readonly builtinToolNames: ReadonlySet<string>;
|
|
49
|
+
/**
|
|
50
|
+
* Map of native tool name → common name. `find` → `glob`, etc. Pi emits
|
|
51
|
+
* native names on its events; the wire `toolName` is the common name when
|
|
52
|
+
* one exists.
|
|
53
|
+
*/
|
|
54
|
+
readonly nativeToCommonNameMap: ReadonlyMap<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PiTranslatorStateOptions {
|
|
58
|
+
readonly builtinToolNames?: ReadonlyArray<string>;
|
|
59
|
+
readonly nativeToCommon?:
|
|
60
|
+
| ReadonlyMap<string, string>
|
|
61
|
+
| Record<string, string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createPiTranslatorState(
|
|
65
|
+
options: PiTranslatorStateOptions = {},
|
|
66
|
+
): PiTranslatorState {
|
|
67
|
+
const map =
|
|
68
|
+
options.nativeToCommon instanceof Map
|
|
69
|
+
? options.nativeToCommon
|
|
70
|
+
: new Map(Object.entries(options.nativeToCommon ?? {}));
|
|
71
|
+
return {
|
|
72
|
+
promptStarted: false,
|
|
73
|
+
streamedAssistantText: '',
|
|
74
|
+
currentTextId: undefined,
|
|
75
|
+
currentReasoningId: undefined,
|
|
76
|
+
reasoningStarted: false,
|
|
77
|
+
observedToolNames: new Map(),
|
|
78
|
+
hostToolResults: new Map(),
|
|
79
|
+
builtinToolNames: new Set(options.builtinToolNames ?? []),
|
|
80
|
+
nativeToCommonNameMap: map,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function newId(): string {
|
|
85
|
+
return randomBytes(8).toString('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pi's `tool_execution_end` event payload (`result`) is a Pi `AgentToolResult`
|
|
90
|
+
* envelope `{ content: (TextContent | ImageContent)[], details, terminate? }`.
|
|
91
|
+
* The `tool_result` event uses a flat shape with `content` and `details` at
|
|
92
|
+
* the top level. In both cases we extract just the text payload (joined when
|
|
93
|
+
* multiple text parts are present) so the AI SDK consumer sees the raw
|
|
94
|
+
* string the tool produced.
|
|
95
|
+
*/
|
|
96
|
+
function unwrapPiToolResult(event: PiSessionEvent): never {
|
|
97
|
+
const candidates: unknown[] = [];
|
|
98
|
+
const result = event.result as unknown;
|
|
99
|
+
if (result && typeof result === 'object') {
|
|
100
|
+
const inner = (result as { content?: unknown }).content;
|
|
101
|
+
if (Array.isArray(inner)) candidates.push(inner);
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(event.content)) candidates.push(event.content);
|
|
104
|
+
|
|
105
|
+
for (const content of candidates) {
|
|
106
|
+
if (!Array.isArray(content)) continue;
|
|
107
|
+
const text = content
|
|
108
|
+
.filter(
|
|
109
|
+
(p): p is { type: 'text'; text: string } =>
|
|
110
|
+
!!p &&
|
|
111
|
+
typeof p === 'object' &&
|
|
112
|
+
(p as { type?: unknown }).type === 'text' &&
|
|
113
|
+
typeof (p as { text?: unknown }).text === 'string',
|
|
114
|
+
)
|
|
115
|
+
.map(p => p.text)
|
|
116
|
+
.join('');
|
|
117
|
+
if (text) return text as never;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof event.result === 'string') return event.result as never;
|
|
121
|
+
if (typeof event.content === 'string') return event.content as never;
|
|
122
|
+
return (event.result ?? event.content ?? null) as never;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveToolName(
|
|
126
|
+
state: PiTranslatorState,
|
|
127
|
+
nativeName: string,
|
|
128
|
+
): { wire: string; native: string } {
|
|
129
|
+
const common = state.nativeToCommonNameMap.get(nativeName);
|
|
130
|
+
return { wire: common ?? nativeName, native: nativeName };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Translate a single Pi `session.subscribe` event into zero or more
|
|
135
|
+
* `HarnessV1StreamPart`s, updating the translator state in place. Returns
|
|
136
|
+
* an empty array for events that produce no output (e.g. events emitted
|
|
137
|
+
* before `turn_start`).
|
|
138
|
+
*
|
|
139
|
+
* The translator does NOT emit `stream-start`/`finish` — those are
|
|
140
|
+
* lifecycle signals owned by the session layer.
|
|
141
|
+
*/
|
|
142
|
+
export function translatePiEvent(
|
|
143
|
+
event: PiSessionEvent,
|
|
144
|
+
state: PiTranslatorState,
|
|
145
|
+
): HarnessV1StreamPart[] {
|
|
146
|
+
switch (event.type) {
|
|
147
|
+
case 'turn_start':
|
|
148
|
+
case 'message_start': {
|
|
149
|
+
if (
|
|
150
|
+
event.type === 'message_start' &&
|
|
151
|
+
event.message?.role !== 'assistant'
|
|
152
|
+
) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
state.promptStarted = true;
|
|
156
|
+
state.streamedAssistantText = '';
|
|
157
|
+
state.currentTextId = undefined;
|
|
158
|
+
state.currentReasoningId = undefined;
|
|
159
|
+
state.reasoningStarted = false;
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'message_update': {
|
|
164
|
+
if (!state.promptStarted) return [];
|
|
165
|
+
const update = event.assistantMessageEvent;
|
|
166
|
+
if (!update) return [];
|
|
167
|
+
if (update.type === 'text_delta' && typeof update.delta === 'string') {
|
|
168
|
+
const parts: HarnessV1StreamPart[] = [];
|
|
169
|
+
// If reasoning was active, close it before opening the text block so
|
|
170
|
+
// consumers can reset block-scoped formatting (ANSI colors, etc.)
|
|
171
|
+
// between sections.
|
|
172
|
+
if (state.reasoningStarted && state.currentReasoningId) {
|
|
173
|
+
parts.push({
|
|
174
|
+
type: 'reasoning-end',
|
|
175
|
+
id: state.currentReasoningId,
|
|
176
|
+
});
|
|
177
|
+
state.reasoningStarted = false;
|
|
178
|
+
state.currentReasoningId = undefined;
|
|
179
|
+
}
|
|
180
|
+
if (!state.currentTextId) {
|
|
181
|
+
state.currentTextId = newId();
|
|
182
|
+
parts.push({ type: 'text-start', id: state.currentTextId });
|
|
183
|
+
}
|
|
184
|
+
state.streamedAssistantText += update.delta;
|
|
185
|
+
parts.push({
|
|
186
|
+
type: 'text-delta',
|
|
187
|
+
id: state.currentTextId,
|
|
188
|
+
delta: update.delta,
|
|
189
|
+
});
|
|
190
|
+
return parts;
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
update.type === 'thinking_delta' &&
|
|
194
|
+
typeof update.delta === 'string'
|
|
195
|
+
) {
|
|
196
|
+
const parts: HarnessV1StreamPart[] = [];
|
|
197
|
+
// Symmetric to the text branch: close any open text block before
|
|
198
|
+
// starting a fresh reasoning block.
|
|
199
|
+
if (state.currentTextId) {
|
|
200
|
+
parts.push({ type: 'text-end', id: state.currentTextId });
|
|
201
|
+
state.currentTextId = undefined;
|
|
202
|
+
}
|
|
203
|
+
if (!state.currentReasoningId) {
|
|
204
|
+
state.currentReasoningId = newId();
|
|
205
|
+
}
|
|
206
|
+
if (!state.reasoningStarted) {
|
|
207
|
+
state.reasoningStarted = true;
|
|
208
|
+
parts.push({ type: 'reasoning-start', id: state.currentReasoningId });
|
|
209
|
+
}
|
|
210
|
+
parts.push({
|
|
211
|
+
type: 'reasoning-delta',
|
|
212
|
+
id: state.currentReasoningId,
|
|
213
|
+
delta: update.delta,
|
|
214
|
+
});
|
|
215
|
+
return parts;
|
|
216
|
+
}
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case 'message_end':
|
|
221
|
+
case 'turn_end': {
|
|
222
|
+
if (!state.promptStarted) return [];
|
|
223
|
+
const parts: HarnessV1StreamPart[] = [];
|
|
224
|
+
const fullText = extractAssistantText(event.message);
|
|
225
|
+
if (
|
|
226
|
+
state.currentTextId &&
|
|
227
|
+
fullText.startsWith(state.streamedAssistantText) &&
|
|
228
|
+
fullText.length > state.streamedAssistantText.length
|
|
229
|
+
) {
|
|
230
|
+
const missing = fullText.slice(state.streamedAssistantText.length);
|
|
231
|
+
state.streamedAssistantText = fullText;
|
|
232
|
+
parts.push({
|
|
233
|
+
type: 'text-delta',
|
|
234
|
+
id: state.currentTextId,
|
|
235
|
+
delta: missing,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (state.currentTextId) {
|
|
239
|
+
parts.push({ type: 'text-end', id: state.currentTextId });
|
|
240
|
+
state.currentTextId = undefined;
|
|
241
|
+
}
|
|
242
|
+
if (state.reasoningStarted && state.currentReasoningId) {
|
|
243
|
+
parts.push({ type: 'reasoning-end', id: state.currentReasoningId });
|
|
244
|
+
state.reasoningStarted = false;
|
|
245
|
+
state.currentReasoningId = undefined;
|
|
246
|
+
}
|
|
247
|
+
return parts;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'tool_execution_start': {
|
|
251
|
+
if (!event.toolCallId || !event.toolName) return [];
|
|
252
|
+
const { wire, native } = resolveToolName(state, event.toolName);
|
|
253
|
+
state.observedToolNames.set(event.toolCallId, wire);
|
|
254
|
+
const providerExecuted = state.builtinToolNames.has(native);
|
|
255
|
+
const input = serializeToolOutput(event.args ?? event.input ?? {});
|
|
256
|
+
return [
|
|
257
|
+
{
|
|
258
|
+
type: 'tool-call',
|
|
259
|
+
toolCallId: event.toolCallId,
|
|
260
|
+
toolName: wire,
|
|
261
|
+
input,
|
|
262
|
+
...(wire !== native ? { nativeName: native } : {}),
|
|
263
|
+
...(providerExecuted ? { providerExecuted: true } : {}),
|
|
264
|
+
} as HarnessV1StreamPart,
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'tool_execution_end':
|
|
269
|
+
case 'tool_result': {
|
|
270
|
+
if (!event.toolCallId) return [];
|
|
271
|
+
const recordedName = state.observedToolNames.get(event.toolCallId);
|
|
272
|
+
const nativeName = event.toolName;
|
|
273
|
+
const wire =
|
|
274
|
+
recordedName ??
|
|
275
|
+
(nativeName ? resolveToolName(state, nativeName).wire : undefined);
|
|
276
|
+
if (!wire) return [];
|
|
277
|
+
/*
|
|
278
|
+
* Prefer the exact value the host submitted for user-registered tools
|
|
279
|
+
* (see `hostToolResults`). Built-in tools, whose results Pi produces and
|
|
280
|
+
* reports as text, are not in the map and fall back to unwrapping the
|
|
281
|
+
* event's text payload.
|
|
282
|
+
*/
|
|
283
|
+
const result = state.hostToolResults.has(event.toolCallId)
|
|
284
|
+
? ((state.hostToolResults.get(event.toolCallId) ?? null) as Extract<
|
|
285
|
+
HarnessV1StreamPart,
|
|
286
|
+
{ type: 'tool-result' }
|
|
287
|
+
>['result'])
|
|
288
|
+
: unwrapPiToolResult(event);
|
|
289
|
+
state.hostToolResults.delete(event.toolCallId);
|
|
290
|
+
return [
|
|
291
|
+
{
|
|
292
|
+
type: 'tool-result',
|
|
293
|
+
toolCallId: event.toolCallId,
|
|
294
|
+
toolName: wire,
|
|
295
|
+
result,
|
|
296
|
+
...(event.isError ? { isError: true } : {}),
|
|
297
|
+
} as HarnessV1StreamPart,
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
case 'compaction_end': {
|
|
302
|
+
/*
|
|
303
|
+
* Pi performs the compaction itself; we observe its result. Skip aborted
|
|
304
|
+
* or result-less compactions (nothing happened). A result with no summary
|
|
305
|
+
* still represents a real compaction, so emit it with a placeholder
|
|
306
|
+
* rather than dropping the event. `reason` is `'manual'` for an explicit
|
|
307
|
+
* `session.compact()` call, `'threshold'`/`'overflow'` for Pi's automatic
|
|
308
|
+
* compaction — both map to `'auto'` on the wire. Pi reports `tokensBefore`
|
|
309
|
+
* but not `tokensAfter`.
|
|
310
|
+
*/
|
|
311
|
+
if (event.aborted) return [];
|
|
312
|
+
const result = event.result;
|
|
313
|
+
if (!result || typeof result !== 'object') return [];
|
|
314
|
+
const rawSummary = (result as { summary?: unknown }).summary;
|
|
315
|
+
const summary =
|
|
316
|
+
typeof rawSummary === 'string' ? rawSummary : '(no summary provided)';
|
|
317
|
+
const tokensBefore = (result as { tokensBefore?: unknown }).tokensBefore;
|
|
318
|
+
return [
|
|
319
|
+
{
|
|
320
|
+
type: 'compaction',
|
|
321
|
+
trigger: event.reason === 'manual' ? 'manual' : 'auto',
|
|
322
|
+
summary,
|
|
323
|
+
...(typeof tokensBefore === 'number' ? { tokensBefore } : {}),
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
default:
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Type, type TSchema } from 'typebox';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a JSON Schema 7 fragment so Pi's `defineTool({ parameters })` accepts
|
|
5
|
+
* it. Pi v0.77 uses TypeBox at the type level but treats `parameters` as
|
|
6
|
+
* opaque schema metadata at runtime, so `Type.Unsafe(jsonSchema)` is
|
|
7
|
+
* sufficient — no per-property structural conversion is needed.
|
|
8
|
+
*/
|
|
9
|
+
export function toolSpecToTypeBoxParameters(jsonSchema: unknown): TSchema {
|
|
10
|
+
return Type.Unsafe(jsonSchema as object);
|
|
11
|
+
}
|
package/src/pi-utils.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HarnessCapabilityUnsupportedError,
|
|
3
|
+
type HarnessV1Prompt,
|
|
4
|
+
type HarnessV1Skill,
|
|
5
|
+
} from '@ai-sdk/harness';
|
|
6
|
+
|
|
7
|
+
const HARNESS_ID = 'pi';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a single user text string from a `HarnessV1Prompt`. Pi's
|
|
11
|
+
* `session.prompt(text)` accepts a string; multimodal user content
|
|
12
|
+
* is not supported in this foundational version.
|
|
13
|
+
*/
|
|
14
|
+
export function extractUserText(prompt: HarnessV1Prompt): string {
|
|
15
|
+
if (typeof prompt === 'string') {
|
|
16
|
+
return prompt;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { content } = prompt;
|
|
20
|
+
if (typeof content === 'string') {
|
|
21
|
+
return content;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
for (const part of content) {
|
|
26
|
+
if (part.type !== 'text') {
|
|
27
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
28
|
+
message: `pi: only text user-message parts are supported; got '${part.type}'.`,
|
|
29
|
+
harnessId: HARNESS_ID,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
parts.push(part.text);
|
|
33
|
+
}
|
|
34
|
+
return parts.join('\n\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* Frame session instructions and the user's text so the runtime treats the
|
|
39
|
+
* instructions as system-provided operating guidance, not something the user
|
|
40
|
+
* wrote. Without the wrapper the agent can echo the prepended text back as if
|
|
41
|
+
* the user had asked for it, which is confusing since the user never typed it.
|
|
42
|
+
* Applied only to the first user message of a fresh session.
|
|
43
|
+
*/
|
|
44
|
+
export function frameInstructions(
|
|
45
|
+
instructions: string,
|
|
46
|
+
userText: string,
|
|
47
|
+
): string {
|
|
48
|
+
return (
|
|
49
|
+
'<session-instructions>\n' +
|
|
50
|
+
'The block below is operating guidance from the system, not a message from the user — follow it, but do not mention it or attribute it to the user.\n\n' +
|
|
51
|
+
`${instructions}\n` +
|
|
52
|
+
'</session-instructions>\n\n' +
|
|
53
|
+
`<user-message>\n${userText}\n</user-message>`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** POSIX shell single-quote escape. */
|
|
58
|
+
export function shellQuote(value: string): string {
|
|
59
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Serialize a tool output to the string Pi feeds back to the model. */
|
|
63
|
+
export function serializeToolOutput(output: unknown): string {
|
|
64
|
+
if (typeof output === 'string') {
|
|
65
|
+
return output;
|
|
66
|
+
}
|
|
67
|
+
const serialized = JSON.stringify(output);
|
|
68
|
+
return serialized ?? 'null';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getErrorText(error: unknown): string {
|
|
72
|
+
return error instanceof Error ? error.message : String(error);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate that a name is safe to use as a filesystem path segment under
|
|
77
|
+
* `.pi/skills/<name>/` or `.pi/agents/<name>.md`. Refuses anything that
|
|
78
|
+
* could be interpreted as a path traversal or contains shell-sensitive
|
|
79
|
+
* characters.
|
|
80
|
+
*/
|
|
81
|
+
export function safePiMetadataSegment(name: string, label: string): string {
|
|
82
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
|
|
83
|
+
throw new Error(`Invalid Pi ${label} name: ${name}`);
|
|
84
|
+
}
|
|
85
|
+
return name;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Frontmatter renderer for `.pi/skills/<name>/SKILL.md`. */
|
|
89
|
+
export function renderPiSkillFile(skill: HarnessV1Skill): string {
|
|
90
|
+
return `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
|
|
91
|
+
}
|