portable-agent-layer 0.64.0 → 0.65.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.
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readdirSync } from "node:fs";
7
7
  import { relative, resolve } from "node:path";
8
8
  import AdmZip from "adm-zip";
9
+ import { ensureRegistered } from "./machine";
9
10
  import { palHome } from "./paths";
10
11
 
11
12
  /**
@@ -59,9 +60,41 @@ export function collectExportFiles(): string[] {
59
60
  return files;
60
61
  }
61
62
 
62
- /** Zip the given files and write to outputPath. Returns file count. */
63
+ /** Archive metadata naming the machine that produced it. */
64
+ export const MANIFEST_NAME = "export-manifest.json";
65
+
66
+ export interface ExportManifest {
67
+ machineId: string;
68
+ label: string;
69
+ os: string;
70
+ exportedAt: string;
71
+ fileCount: number;
72
+ }
73
+
74
+ export function buildManifest(
75
+ identity: { id: string; label: string; os: string },
76
+ fileCount: number
77
+ ): ExportManifest {
78
+ return {
79
+ machineId: identity.id,
80
+ label: identity.label,
81
+ os: identity.os,
82
+ exportedAt: new Date().toISOString(),
83
+ fileCount,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Zip the given files and write to outputPath. Returns file count.
89
+ *
90
+ * The archive declares its source machine in a manifest. Registry entries under
91
+ * memory/machines/ travel with the corpus, so the manifest exists to say which
92
+ * machine produced THIS archive — after a merge an archive can carry entries
93
+ * for several machines.
94
+ */
63
95
  export function exportZip(outputPath: string): number {
64
96
  const root = palHome();
97
+ const identity = ensureRegistered(root);
65
98
  const files = collectExportFiles();
66
99
  if (files.length === 0) return 0;
67
100
 
@@ -71,6 +104,10 @@ export function exportZip(outputPath: string): number {
71
104
  const dir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
72
105
  zip.addLocalFile(fullPath, dir);
73
106
  }
107
+ zip.addFile(
108
+ MANIFEST_NAME,
109
+ Buffer.from(`${JSON.stringify(buildManifest(identity, files.length), null, 2)}\n`)
110
+ );
74
111
 
75
112
  zip.writeZip(outputPath);
76
113
  return files.length;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Import merge — fold an export archive into an existing PAL home without
3
+ * destroying local records.
4
+ *
5
+ * `extractAllTo(home, true)` overwrites every colliding path, so importing
6
+ * machine A onto machine B silently discards B's side of every append-only log.
7
+ * This module replaces that with a per-type policy:
8
+ *
9
+ * *.jsonl union of both sides, deduplicated by exact line
10
+ * new files written as-is
11
+ * identical no-op
12
+ * diverged local kept in place, incoming quarantined under backups/
13
+ * denylisted never written (machine identity, rebuildable indexes)
14
+ *
15
+ * Every policy is idempotent: re-importing the same archive is a no-op on the
16
+ * corpus.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { type ExportManifest, MANIFEST_NAME } from "./export";
22
+
23
+ /** One file inside an export archive, decoupled from the zip library. */
24
+ export interface ArchiveEntry {
25
+ path: string;
26
+ data(): Buffer;
27
+ }
28
+
29
+ export interface MergeResult {
30
+ created: string[];
31
+ merged: string[];
32
+ identical: string[];
33
+ conflicts: string[];
34
+ skipped: string[];
35
+ linesAdded: number;
36
+ quarantineDir: string | null;
37
+ }
38
+
39
+ /**
40
+ * Paths that must never cross machines. `machine.json` carries this install's
41
+ * identity — importing it would give two machines one id and silently break
42
+ * every origin-scoped read. The retrieval index is rebuilt from its sources.
43
+ */
44
+ const NEVER_IMPORT = [
45
+ "machine.json",
46
+ "export-manifest.json",
47
+ "memory/learning/.retrieval-index.json",
48
+ ];
49
+
50
+ function normalize(path: string): string {
51
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
52
+ }
53
+
54
+ export function isNeverImport(path: string): boolean {
55
+ const rel = normalize(path);
56
+ return NEVER_IMPORT.some((deny) => rel === deny || rel.endsWith(`/${deny}`));
57
+ }
58
+
59
+ function isJsonl(path: string): boolean {
60
+ return normalize(path).endsWith(".jsonl");
61
+ }
62
+
63
+ function splitLines(raw: string): string[] {
64
+ return raw.split("\n").filter((l) => l.trim().length > 0);
65
+ }
66
+
67
+ /**
68
+ * Union of two JSONL bodies, local order preserved, incoming lines appended
69
+ * only when not already present. Exact-line identity is the dedupe key — no
70
+ * schema is shared across PAL's jsonl files, and every writer serializes a
71
+ * record the same way, so byte equality is the only key that holds for all of
72
+ * them.
73
+ */
74
+ export function mergeJsonlLines(
75
+ localRaw: string,
76
+ incomingRaw: string
77
+ ): { text: string; added: number } {
78
+ const local = splitLines(localRaw);
79
+ const seen = new Set(local);
80
+ const added: string[] = [];
81
+ for (const line of splitLines(incomingRaw)) {
82
+ if (seen.has(line)) continue;
83
+ seen.add(line);
84
+ added.push(line);
85
+ }
86
+ const all = [...local, ...added];
87
+ return { text: all.length > 0 ? `${all.join("\n")}\n` : "", added: added.length };
88
+ }
89
+
90
+ function writeFileEnsuringDir(target: string, data: Buffer | string): void {
91
+ mkdirSync(dirname(target), { recursive: true });
92
+ writeFileSync(target, data);
93
+ }
94
+
95
+ function quarantine(quarantineDir: string, rel: string, data: Buffer): void {
96
+ writeFileEnsuringDir(resolve(quarantineDir, rel), data);
97
+ }
98
+
99
+ /**
100
+ * Merge every archive entry into `home`. `quarantineDir` receives the incoming
101
+ * copy of any file that diverged from its local counterpart, so a conflict
102
+ * loses neither side.
103
+ */
104
+ export function mergeArchive(
105
+ entries: ArchiveEntry[],
106
+ home: string,
107
+ quarantineDir: string
108
+ ): MergeResult {
109
+ const result: MergeResult = {
110
+ created: [],
111
+ merged: [],
112
+ identical: [],
113
+ conflicts: [],
114
+ skipped: [],
115
+ linesAdded: 0,
116
+ quarantineDir: null,
117
+ };
118
+
119
+ for (const entry of entries) {
120
+ const rel = normalize(entry.path);
121
+ if (rel.length === 0 || rel.endsWith("/")) continue;
122
+
123
+ if (isNeverImport(rel)) {
124
+ result.skipped.push(rel);
125
+ continue;
126
+ }
127
+
128
+ const target = resolve(home, rel);
129
+ const incoming = entry.data();
130
+
131
+ if (!existsSync(target)) {
132
+ writeFileEnsuringDir(target, incoming);
133
+ result.created.push(rel);
134
+ continue;
135
+ }
136
+
137
+ const localRaw = readFileSync(target);
138
+ if (localRaw.equals(incoming)) {
139
+ result.identical.push(rel);
140
+ continue;
141
+ }
142
+
143
+ if (isJsonl(rel)) {
144
+ const { text, added } = mergeJsonlLines(
145
+ localRaw.toString("utf-8"),
146
+ incoming.toString("utf-8")
147
+ );
148
+ writeFileSync(target, text);
149
+ result.merged.push(rel);
150
+ result.linesAdded += added;
151
+ continue;
152
+ }
153
+
154
+ quarantine(quarantineDir, rel, incoming);
155
+ result.conflicts.push(rel);
156
+ result.quarantineDir = quarantineDir;
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * The source machine declared by an archive's manifest, or null when the
164
+ * archive predates manifests.
165
+ */
166
+ export function readManifest(entries: ArchiveEntry[]): ExportManifest | null {
167
+ const hit = entries.find((e) => normalize(e.path) === MANIFEST_NAME);
168
+ if (!hit) return null;
169
+ try {
170
+ const parsed = JSON.parse(hit.data().toString("utf-8")) as Partial<ExportManifest>;
171
+ if (typeof parsed.machineId !== "string" || parsed.machineId.length === 0)
172
+ return null;
173
+ return {
174
+ machineId: parsed.machineId,
175
+ label: parsed.label ?? parsed.machineId,
176
+ os: parsed.os ?? "",
177
+ exportedAt: parsed.exportedAt ?? "",
178
+ fileCount: parsed.fileCount ?? 0,
179
+ };
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ export interface ImportLogEntry {
186
+ ts: string;
187
+ archive: string;
188
+ mode: "merge" | "overwrite";
189
+ created: number;
190
+ merged: number;
191
+ identical: number;
192
+ conflicts: number;
193
+ skipped: number;
194
+ linesAdded: number;
195
+ quarantineDir: string | null;
196
+ sourceMachineId?: string | null;
197
+ }
198
+
199
+ /** Append one record per import so a merged corpus stays attributable. */
200
+ export function appendImportLog(home: string, entry: ImportLogEntry): void {
201
+ const logPath = resolve(home, "memory", "state", "import-log.jsonl");
202
+ mkdirSync(dirname(logPath), { recursive: true });
203
+ const line = `${JSON.stringify(entry)}\n`;
204
+ if (existsSync(logPath)) {
205
+ writeFileSync(logPath, readFileSync(logPath, "utf-8") + line);
206
+ return;
207
+ }
208
+ writeFileSync(logPath, line);
209
+ }
210
+
211
+ export function summarize(result: MergeResult): string {
212
+ const parts = [
213
+ `${result.created.length} new`,
214
+ `${result.merged.length} merged (+${result.linesAdded} records)`,
215
+ `${result.identical.length} unchanged`,
216
+ ];
217
+ if (result.conflicts.length > 0) parts.push(`${result.conflicts.length} conflicts`);
218
+ if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`);
219
+ return parts.join(", ");
220
+ }
@@ -20,7 +20,9 @@
20
20
  * yet wired and currently fall through to the API path.
21
21
  */
22
22
 
23
- import { basename } from "node:path";
23
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import { basename, join } from "node:path";
24
26
  import {
25
27
  getActiveAgent,
26
28
  isClaude,
@@ -151,14 +153,14 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
151
153
  "inference",
152
154
  `${tag} route=claude-spawn agent=${agent} model=${opts.model ?? HAIKU_MODEL}`
153
155
  );
154
- return inferenceViaCliSpawn(bin, buildClaudeArgs(opts), opts.user, opts);
156
+ return inferenceViaClaudeSpawn(bin, opts);
155
157
  }
156
158
  }
157
159
  if (isCodex()) {
158
160
  const bin = getCodexBinary();
159
161
  if (bin) {
160
162
  logDebug("inference", `${tag} route=codex-spawn agent=${agent}`);
161
- return inferenceViaCliSpawn(bin, buildCodexArgs(opts), "", opts);
163
+ return inferenceViaCliSpawn(bin, buildCodexArgs(opts), buildCliPrompt(opts), opts);
162
164
  }
163
165
  }
164
166
  if (isCodex() && hasOpenAiKey()) {
@@ -172,7 +174,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
172
174
  return inferenceViaCliSpawn(
173
175
  bin,
174
176
  buildOpencodeArgs(opts),
175
- "",
177
+ buildCliPrompt(opts),
176
178
  opts,
177
179
  extractOpencodeText
178
180
  );
@@ -182,14 +184,19 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
182
184
  const bin = getCopilotBinary();
183
185
  if (bin) {
184
186
  logDebug("inference", `${tag} route=copilot-spawn agent=${agent}`);
185
- return inferenceViaCliSpawn(bin, buildCopilotArgs(opts), "", opts);
187
+ return inferenceViaCliSpawn(
188
+ bin,
189
+ buildCopilotArgs(opts),
190
+ buildCliPrompt(opts),
191
+ opts
192
+ );
186
193
  }
187
194
  }
188
195
  if (isCursor()) {
189
196
  const bin = getCursorBinary();
190
197
  if (bin) {
191
198
  logDebug("inference", `${tag} route=cursor-spawn agent=${agent}`);
192
- return inferenceViaCliSpawn(bin, buildCursorArgs(opts), "", opts);
199
+ return inferenceViaCliSpawn(bin, buildCursorArgs(opts), buildCliPrompt(opts), opts);
193
200
  }
194
201
  }
195
202
  if (hasApiKey()) {
@@ -284,16 +291,23 @@ export function _resetCursorBinaryCache(): void {
284
291
  cursorBinaryCache = undefined;
285
292
  }
286
293
 
287
- /** Build the argv for `claude --print …` from inference options. Pure. */
288
- export function buildClaudeArgs(opts: InferenceOptions): string[] {
289
- const model = opts.model ?? HAIKU_MODEL;
290
- const system = opts.jsonSchema
291
- ? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
292
- : opts.system;
294
+ /**
295
+ * Build the argv for `claude --print …`. Pure.
296
+ *
297
+ * `--system-prompt` is deliberately absent: PAL's system prompts run to several
298
+ * paragraphs, and an argv element cannot carry a newline on Windows once
299
+ * Bun.spawn resolves claude to its .cmd shim and cmd.exe re-parses the command
300
+ * line. System, user and any JSON-schema instruction all travel together on
301
+ * stdin instead, the same way every other agent receives them.
302
+ */
303
+ export function buildClaudeArgs(
304
+ opts: InferenceOptions,
305
+ systemPromptFile?: string
306
+ ): string[] {
293
307
  const args = [
294
308
  "--print",
295
309
  "--model",
296
- model,
310
+ opts.model ?? HAIKU_MODEL,
297
311
  "--tools",
298
312
  "",
299
313
  "--output-format",
@@ -301,12 +315,38 @@ export function buildClaudeArgs(opts: InferenceOptions): string[] {
301
315
  "--setting-sources",
302
316
  "",
303
317
  ];
304
- if (system) {
305
- args.push("--system-prompt", system);
306
- }
318
+ if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile);
307
319
  return args;
308
320
  }
309
321
 
322
+ /**
323
+ * Claude keeps a real system prompt, unlike the other agents, so the system text
324
+ * reaches it through --system-prompt-file rather than --system-prompt. Only the
325
+ * path travels in argv, which is what makes this work on Windows: an argv
326
+ * element cannot carry a newline once Bun.spawn resolves claude to its .cmd
327
+ * shim, and PAL's system prompts run to several paragraphs. Folding the system
328
+ * text into the user message instead is not an option — Claude treats
329
+ * instructions embedded in message content as an injection attempt and refuses.
330
+ */
331
+ async function inferenceViaClaudeSpawn(
332
+ bin: string,
333
+ opts: InferenceOptions
334
+ ): Promise<InferenceResult> {
335
+ const system = opts.jsonSchema
336
+ ? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
337
+ : opts.system;
338
+ if (!system) return inferenceViaCliSpawn(bin, buildClaudeArgs(opts), opts.user, opts);
339
+
340
+ const dir = await mkdtemp(join(tmpdir(), "pal-system-"));
341
+ try {
342
+ const file = join(dir, "system-prompt.md");
343
+ await writeFile(file, system, "utf-8");
344
+ return await inferenceViaCliSpawn(bin, buildClaudeArgs(opts, file), opts.user, opts);
345
+ } finally {
346
+ await rm(dir, { recursive: true, force: true });
347
+ }
348
+ }
349
+
310
350
  /**
311
351
  * Build the argv for `codex exec …` from inference options. Pure.
312
352
  *
@@ -316,20 +356,16 @@ export function buildClaudeArgs(opts: InferenceOptions): string[] {
316
356
  * --sandbox read-only → child cannot execute shell commands even if it tries
317
357
  * --ephemeral → no session persistence; one-shot only
318
358
  *
319
- * Codex has no --system-prompt equivalent the full prompt is a single positional
320
- * argv string. We concatenate system + user + JSON-schema instruction into one
321
- * prompt. ARG_MAX is ~256KB on macOS; typical PAL prompts are 1-2KB.
359
+ * Codex has no --system-prompt equivalent, so system + user + JSON-schema become
360
+ * one prompt. That prompt goes in on stdin, not as a positional argument, because
361
+ * a PAL prompt spans several paragraphs and an argv element cannot carry a
362
+ * newline on Windows: Bun.spawn resolves the CLI to its .cmd shim, cmd.exe
363
+ * re-parses the command line, and the child exits non-zero having written
364
+ * nothing. Codex reads its instructions from stdin when no positional prompt is
365
+ * given, so this costs nothing on POSIX and is the only thing that works on
366
+ * Windows. Do not move the prompt back into argv.
322
367
  */
323
- export function buildCodexArgs(opts: InferenceOptions): string[] {
324
- const parts: string[] = [];
325
- if (opts.system) parts.push(opts.system);
326
- parts.push(opts.user);
327
- if (opts.jsonSchema) {
328
- parts.push(
329
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
330
- );
331
- }
332
- const prompt = parts.join("\n\n");
368
+ export function buildCodexArgs(_opts: InferenceOptions): string[] {
333
369
  return [
334
370
  "exec",
335
371
  "--color",
@@ -340,7 +376,6 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
340
376
  "--sandbox",
341
377
  "read-only",
342
378
  "--ephemeral",
343
- prompt,
344
379
  ];
345
380
  }
346
381
 
@@ -354,21 +389,14 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
354
389
  * text via extractOpencodeText() rather than wading through
355
390
  * decoration ("> build · provider/model" banner etc).
356
391
  *
357
- * opencode (like codex) has no --system-prompt equivalent the full prompt is
358
- * the positional message argv. System + user + JSON-schema are concatenated.
359
- * Provider/model is left unset so opencode uses the user's configured default.
392
+ * opencode (like codex) has no --system-prompt equivalent, so system + user +
393
+ * JSON-schema are concatenated and delivered on stdin rather than as the
394
+ * positional message, for the same reason as codex: a multi-paragraph argv
395
+ * element does not survive cmd.exe on Windows. Provider/model is left unset so
396
+ * opencode uses the user's configured default.
360
397
  */
361
- export function buildOpencodeArgs(opts: InferenceOptions): string[] {
362
- const parts: string[] = [];
363
- if (opts.system) parts.push(opts.system);
364
- parts.push(opts.user);
365
- if (opts.jsonSchema) {
366
- parts.push(
367
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
368
- );
369
- }
370
- const prompt = parts.join("\n\n");
371
- return ["run", "--pure", "--format", "json", prompt];
398
+ export function buildOpencodeArgs(_opts: InferenceOptions): string[] {
399
+ return ["run", "--pure", "--format", "json"];
372
400
  }
373
401
 
374
402
  /**
@@ -385,23 +413,16 @@ export function buildOpencodeArgs(opts: InferenceOptions): string[] {
385
413
  * of running inference. Safe to pair with --mode ask
386
414
  * because that mode disallows tool calls anyway.
387
415
  *
388
- * cursor-agent has no --system-prompt flag system + user + JSON-schema are
389
- * concatenated into a single positional prompt argument.
416
+ * cursor-agent has no --system-prompt flag, so system + user + JSON-schema are
417
+ * concatenated into one prompt delivered on stdin rather than as the trailing
418
+ * positional argument: a multi-paragraph argv element cannot survive cmd.exe on
419
+ * Windows. `-p` stays because for cursor-agent it means --print, not --prompt.
390
420
  *
391
421
  * Auth note: cursor-agent picks up either `cursor-agent login` credentials or
392
422
  * `CURSOR_API_KEY` env var. PAL doesn't manage these — that's the user's setup.
393
423
  */
394
- export function buildCursorArgs(opts: InferenceOptions): string[] {
395
- const parts: string[] = [];
396
- if (opts.system) parts.push(opts.system);
397
- parts.push(opts.user);
398
- if (opts.jsonSchema) {
399
- parts.push(
400
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
401
- );
402
- }
403
- const prompt = parts.join("\n\n");
404
- return ["-p", "--mode", "ask", "--output-format", "text", "--trust", prompt];
424
+ export function buildCursorArgs(_opts: InferenceOptions): string[] {
425
+ return ["-p", "--mode", "ask", "--output-format", "text", "--trust"];
405
426
  }
406
427
 
407
428
  /**
@@ -417,22 +438,14 @@ export function buildCursorArgs(opts: InferenceOptions): string[] {
417
438
  * --allow-all-tools → REQUIRED for non-interactive mode (without it,
418
439
  * copilot prompts for tool-use confirmation)
419
440
  *
420
- * Copilot has no --system-prompt flag system + user + JSON-schema are
421
- * concatenated into a single prompt passed via -p.
441
+ * Copilot has no --system-prompt flag, so system + user + JSON-schema are
442
+ * concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
443
+ * absent: it takes the prompt inline, and a multi-paragraph argv element cannot
444
+ * survive cmd.exe when Bun.spawn resolves copilot to its Windows .cmd shim.
445
+ * Piping stdin keeps copilot non-interactive, so dropping -p costs nothing.
422
446
  */
423
- export function buildCopilotArgs(opts: InferenceOptions): string[] {
424
- const parts: string[] = [];
425
- if (opts.system) parts.push(opts.system);
426
- parts.push(opts.user);
427
- if (opts.jsonSchema) {
428
- parts.push(
429
- `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(opts.jsonSchema)}`
430
- );
431
- }
432
- const prompt = parts.join("\n\n");
447
+ export function buildCopilotArgs(_opts: InferenceOptions): string[] {
433
448
  return [
434
- "-p",
435
- prompt,
436
449
  "--no-custom-instructions",
437
450
  "--disable-builtin-mcps",
438
451
  "--no-auto-update",
@@ -465,12 +478,40 @@ export function extractOpencodeText(rawStdout: string): string {
465
478
  return texts.join("").trim();
466
479
  }
467
480
 
481
+ /**
482
+ * Render a JSON schema for a prompt without double quotes.
483
+ *
484
+ * Bun.spawn resolves an agent CLI on Windows to its `.cmd` shim, which npm
485
+ * installs it as, and cmd.exe re-parses the command line it is handed. A double
486
+ * quote inside an argv element does not survive that round trip: the child exits
487
+ * non-zero having written nothing, so the dispatcher sees an empty abort and
488
+ * gives up. Single quotes carry the same shape to a model and are inert to
489
+ * cmd.exe, so the schema travels intact on every platform.
490
+ */
491
+ function schemaForPrompt(schema: Record<string, unknown>): string {
492
+ return JSON.stringify(schema).replaceAll('"', "'");
493
+ }
494
+
495
+ /** The one instruction line that asks a CLI agent for schema-shaped JSON. */
496
+ export function schemaInstruction(schema: Record<string, unknown>): string {
497
+ return `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${schemaForPrompt(schema)}`;
498
+ }
499
+
500
+ /** system + user + schema instruction, the single prompt a CLI agent receives. */
501
+ export function buildCliPrompt(opts: InferenceOptions): string {
502
+ const parts: string[] = [];
503
+ if (opts.system) parts.push(opts.system);
504
+ parts.push(opts.user);
505
+ if (opts.jsonSchema) parts.push(schemaInstruction(opts.jsonSchema));
506
+ return parts.join("\n\n");
507
+ }
508
+
468
509
  /** Append a JSON-schema instruction to the system prompt (PAI pattern). */
469
510
  export function injectJsonSchemaInstruction(
470
511
  systemPrompt: string,
471
512
  schema: Record<string, unknown>
472
513
  ): string {
473
- const schemaLine = `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${JSON.stringify(schema)}`;
514
+ const schemaLine = schemaInstruction(schema);
474
515
  return systemPrompt ? `${systemPrompt}\n\n${schemaLine}` : schemaLine;
475
516
  }
476
517