pi-freeflow 1.36.0 → 1.36.3
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 +45 -0
- package/package.json +1 -1
- package/src/catalog.ts +6 -0
- package/src/config.ts +9 -4
- package/src/index.ts +1 -0
- package/src/models.ts +5 -44
- package/src/opencode-fingerprint.ts +88 -5
- package/src/probe.ts +7 -7
- package/src/proxy.ts +234 -144
- package/src/stream-pipe.ts +85 -1
- package/src/tool-args.ts +194 -52
- package/src/tool-translation.ts +179 -87
package/src/tool-translation.ts
CHANGED
|
@@ -53,7 +53,17 @@ export const PI_TOOL_NAMES = [
|
|
|
53
53
|
"ls",
|
|
54
54
|
] as const;
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* OMP host built-ins: BUILTIN_TOOL_NAMES in
|
|
58
|
+
* reference/oh-my-pi/packages/coding-agent/src/tools/builtin-names.ts, plus
|
|
59
|
+
* browser/computer (real top-level tools in the same checkout) and `hub`.
|
|
60
|
+
*
|
|
61
|
+
* `find` here is OMP's semantic search (reference/oh-my-pi
|
|
62
|
+
* tools/jfind/index.ts:54), NOT Pi's file finder: they are different tools
|
|
63
|
+
* with different schemas, and both are declared together whenever the session
|
|
64
|
+
* enables it. That is why the translator renames `find` to `glob` only when the
|
|
65
|
+
* caller left the `glob` slot empty — see translateToolsForPath.
|
|
66
|
+
*/
|
|
57
67
|
export const OMP_TOOL_NAMES = [
|
|
58
68
|
"read",
|
|
59
69
|
"bash",
|
|
@@ -62,15 +72,20 @@ export const OMP_TOOL_NAMES = [
|
|
|
62
72
|
"ast_edit",
|
|
63
73
|
"ask",
|
|
64
74
|
"debug",
|
|
75
|
+
"ida",
|
|
65
76
|
"eval",
|
|
66
77
|
"github",
|
|
67
78
|
"glob",
|
|
68
79
|
"grep",
|
|
80
|
+
"find",
|
|
69
81
|
"lsp",
|
|
70
82
|
"checkpoint",
|
|
71
83
|
"rewind",
|
|
84
|
+
"context_notes",
|
|
85
|
+
"new_context",
|
|
72
86
|
"security_scan",
|
|
73
87
|
"task",
|
|
88
|
+
"wait",
|
|
74
89
|
"hub",
|
|
75
90
|
"todo",
|
|
76
91
|
"web_search",
|
|
@@ -175,18 +190,58 @@ export const OMP_FINGERPRINT_DEFS: Record<Exclude<FingerprintToolName, "edit">,
|
|
|
175
190
|
},
|
|
176
191
|
};
|
|
177
192
|
|
|
193
|
+
/** Minimal valid schemas for injected compat tools to satisfy upstream inference gate. */
|
|
194
|
+
export const COMPAT_FINGERPRINT_SCHEMAS: Record<FingerprintToolName, Record<string, unknown>> = {
|
|
195
|
+
bash: {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: { command: { type: "string", description: "Shell command to run." } },
|
|
198
|
+
required: ["command"],
|
|
199
|
+
},
|
|
200
|
+
read: {
|
|
201
|
+
type: "object",
|
|
202
|
+
properties: { path: { type: "string", description: "File path to read." } },
|
|
203
|
+
required: ["path"],
|
|
204
|
+
},
|
|
205
|
+
glob: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: { path: { type: "string", description: "Search path." } },
|
|
208
|
+
},
|
|
209
|
+
grep: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: { pattern: { type: "string", description: "Search pattern." } },
|
|
212
|
+
required: ["pattern"],
|
|
213
|
+
},
|
|
214
|
+
edit: {
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: { path: { type: "string", description: "File path to edit." } },
|
|
217
|
+
required: ["path"],
|
|
218
|
+
},
|
|
219
|
+
write: {
|
|
220
|
+
type: "object",
|
|
221
|
+
properties: { path: { type: "string", description: "File path to write." } },
|
|
222
|
+
required: ["path"],
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
178
226
|
/** Caller tool names that only an OMP host sends (never Pi). */
|
|
179
227
|
export const OMP_CALLER_MARKERS = ["ask", "task", "todo", "hub", "lsp"] as const;
|
|
180
228
|
|
|
181
229
|
/**
|
|
182
230
|
* True when a lowercased caller-tool name set looks like OMP: any of
|
|
183
|
-
* ask/task/todo/hub/lsp present, or glob present
|
|
184
|
-
*
|
|
231
|
+
* ask/task/todo/hub/lsp present, or `glob` present at all.
|
|
232
|
+
*
|
|
233
|
+
* `glob` alone is the discriminator because Pi has no such tool (its file
|
|
234
|
+
* finder is `find`, reference/pi core/tools/index.ts:95) while OMP declares
|
|
235
|
+
* both. The old test was "glob without find", which assumed `find` was
|
|
236
|
+
* Pi-exclusive; OMP ships a semantic `find` too (reference/oh-my-pi
|
|
237
|
+
* tools/jfind/index.ts:54), so an OMP read-only session carrying glob+find was
|
|
238
|
+
* classified as Pi and its fingerprint slots came back as non-executable
|
|
239
|
+
* placeholders. Case-insensitive; pass already-lowercased names.
|
|
185
240
|
*/
|
|
186
241
|
export function isOmpLikeCaller(lowerNames: Iterable<string>): boolean {
|
|
187
242
|
const set = lowerNames instanceof Set ? lowerNames : new Set(lowerNames);
|
|
188
243
|
for (const m of OMP_CALLER_MARKERS) if (set.has(m)) return true;
|
|
189
|
-
return set.has("glob")
|
|
244
|
+
return set.has("glob");
|
|
190
245
|
}
|
|
191
246
|
|
|
192
247
|
export interface CanonicalTool {
|
|
@@ -385,8 +440,14 @@ export function sanitizeUpstreamToolName(name: string, taken: ReadonlySet<string
|
|
|
385
440
|
}
|
|
386
441
|
/** Inspect the original caller tools (pre-translate) for the rewrite cases. */
|
|
387
442
|
export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
|
|
388
|
-
|
|
389
|
-
|
|
443
|
+
// Same rule as translateToolsForPath: the find->glob rename fires only when
|
|
444
|
+
// the caller left the `glob` slot empty. OMP declares both real tools, so
|
|
445
|
+
// there the rename must not happen and `find` must keep its own name.
|
|
446
|
+
const callerDeclaresGlob = callerTools.some((tool) => {
|
|
447
|
+
const canon = canonicalizeTool(tool);
|
|
448
|
+
return canon !== null && canon.name.trim().toLowerCase() === "glob";
|
|
449
|
+
});
|
|
450
|
+
const nameFor = (name: string): string => (callerDeclaresGlob ? name.trim() : upstreamToolNameFor(name));
|
|
390
451
|
const renamed: Record<string, string> = {};
|
|
391
452
|
// Same first-seen-wins order and case rules as translateToolsForPath, so
|
|
392
453
|
// the recorded map keys match the names that actually reach upstream.
|
|
@@ -395,25 +456,28 @@ export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
|
|
|
395
456
|
const canon = canonicalizeTool(tool);
|
|
396
457
|
if (!canon) continue;
|
|
397
458
|
const callerName = canon.name;
|
|
398
|
-
|
|
399
|
-
else if (callerName.trim().toLowerCase() === "glob") sawGlob = true;
|
|
459
|
+
const upstreamName = nameFor(callerName);
|
|
400
460
|
// Mirrors translateToolsForPath: dedup on the lowercased
|
|
401
461
|
// pre-sanitization name (a collapsed duplicate never reaches upstream),
|
|
402
462
|
// then sanitize with the caller's casing intact.
|
|
403
|
-
const dedupKey =
|
|
463
|
+
const dedupKey = upstreamName.toLowerCase();
|
|
404
464
|
if (seen.has(dedupKey)) continue;
|
|
405
465
|
seen.add(dedupKey);
|
|
406
|
-
const sanitized = sanitizeUpstreamToolName(
|
|
466
|
+
const sanitized = sanitizeUpstreamToolName(upstreamName, seen);
|
|
407
467
|
if (sanitized.toLowerCase() !== dedupKey) seen.add(sanitized.toLowerCase());
|
|
408
468
|
// Only character-folding rewrites belong in the sanitized map. The
|
|
409
|
-
// find->glob rename is a
|
|
410
|
-
//
|
|
411
|
-
//
|
|
469
|
+
// find->glob rename is a single-name fallback, so a name that came in
|
|
470
|
+
// as `find` must not be recorded here: upstream `glob` may be the
|
|
471
|
+
// caller's own glob.
|
|
412
472
|
if (sanitized !== callerName && !isFindToolName(callerName)) {
|
|
413
473
|
renamed[sanitized.toLowerCase()] = callerName;
|
|
414
474
|
}
|
|
415
475
|
}
|
|
416
|
-
const
|
|
476
|
+
const sawFind = callerTools.some((tool) => {
|
|
477
|
+
const canon = canonicalizeTool(tool);
|
|
478
|
+
return canon !== null && isFindToolName(canon.name);
|
|
479
|
+
});
|
|
480
|
+
const restored: FindGlobRestore = { renamedFindToGlob: sawFind && !callerDeclaresGlob, renamed };
|
|
417
481
|
return restored;
|
|
418
482
|
}
|
|
419
483
|
/**
|
|
@@ -489,22 +553,46 @@ function carryExtraFields(
|
|
|
489
553
|
}
|
|
490
554
|
}
|
|
491
555
|
/**
|
|
492
|
-
* Reshape a caller tool array for the target path. Tools already in the
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* `Bash` never duplicates `bash` and `find` collapses with `glob`.
|
|
503
|
-
* Re-translating translated output adds zero tools. Never returns null entries.
|
|
556
|
+
* Reshape a caller tool array for the target path. Tools already in the target
|
|
557
|
+
* shape pass through verbatim (identical reference, every extra field intact).
|
|
558
|
+
* Function tools in another shape are converted, carrying
|
|
559
|
+
* `description`/`parameters`/`strict` verbatim plus every other top-level
|
|
560
|
+
* caller field except the wire-shape-forbidden keys (`type`/`function`
|
|
561
|
+
* wrapper, `name`, `description`, `parameters`/`input_schema`), which are
|
|
562
|
+
* remapped per target shape. Never injects `additionalProperties`. Every other
|
|
563
|
+
* entry passes through verbatim. Duplicates collapse first-seen-wins on the
|
|
564
|
+
* lowercased upstream name, so `Bash` never duplicates `bash`. Re-translating
|
|
565
|
+
* translated output adds zero tools. Never returns null entries.
|
|
504
566
|
* `tool_choice` is never imposed here; use the retarget helpers to carry it.
|
|
567
|
+
*
|
|
568
|
+
* Names ride the caller's own unless `forFingerprint` is set — that flag exists
|
|
569
|
+
* for the OpenCode free tier alone, whose gate expects the sextet name `glob`
|
|
570
|
+
* and therefore needs Pi's `find` renamed into it. On every other lane the
|
|
571
|
+
* rename is pure loss: the upstream would have accepted `find`, and the host
|
|
572
|
+
* gets `find` back and dispatches it natively.
|
|
505
573
|
*/
|
|
506
|
-
export function translateToolsForPath(tools: unknown[], pathname: string): Record<string, unknown>[] {
|
|
574
|
+
export function translateToolsForPath(tools: unknown[], pathname: string, opts?: { forFingerprint?: boolean }): Record<string, unknown>[] {
|
|
507
575
|
const api = apiForPathname(pathname);
|
|
576
|
+
// Only the OpenCode free tier needs a tool renamed: its gate looks for a
|
|
577
|
+
// sextet whose `glob` slot Pi's `find` can fill. Every other upstream accepts
|
|
578
|
+
// the caller's own names, so on those lanes the name rides verbatim in BOTH
|
|
579
|
+
// directions and the host dispatches it natively — nothing to translate, and
|
|
580
|
+
// nothing to restore.
|
|
581
|
+
//
|
|
582
|
+
// Even on the fingerprint lane the rename is skipped when the caller declared
|
|
583
|
+
// `glob` itself: OMP declares both `glob` (file patterns) and `find`
|
|
584
|
+
// (semantic search, reference/oh-my-pi tools/jfind/index.ts:54), and renaming
|
|
585
|
+
// there would collapse two real tools into one under the other's schema.
|
|
586
|
+
let callerDeclaresGlob = false;
|
|
587
|
+
for (const tool of tools) {
|
|
588
|
+
const canon = canonicalizeTool(tool);
|
|
589
|
+
if (canon && canon.name.trim().toLowerCase() === "glob") {
|
|
590
|
+
callerDeclaresGlob = true;
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const renameFind = opts?.forFingerprint === true && !callerDeclaresGlob;
|
|
595
|
+
const upstreamNameFor = (name: string): string => (renameFind ? upstreamToolNameFor(name) : name.trim());
|
|
508
596
|
const seen = new Set<string>();
|
|
509
597
|
const out: Record<string, unknown>[] = [];
|
|
510
598
|
for (const tool of tools) {
|
|
@@ -517,15 +605,16 @@ export function translateToolsForPath(tools: unknown[], pathname: string): Recor
|
|
|
517
605
|
continue;
|
|
518
606
|
}
|
|
519
607
|
// Duplicate collapse stays first-seen-wins on the lowercased upstream name
|
|
520
|
-
// (`Bash` never duplicates `bash`, `find` collapses with `glob`)
|
|
521
|
-
// the pre-sanitization name: the emitted name keeps the caller's
|
|
522
|
-
// the sanitizer only ever folds characters the upstream shape
|
|
523
|
-
|
|
608
|
+
// (`Bash` never duplicates `bash`, a Pi `find` still collapses with `glob`)
|
|
609
|
+
// and runs on the pre-sanitization name: the emitted name keeps the caller's
|
|
610
|
+
// casing, and the sanitizer only ever folds characters the upstream shape
|
|
611
|
+
// forbids.
|
|
612
|
+
const dedupKey = upstreamNameFor(canon.name).toLowerCase();
|
|
524
613
|
if (seen.has(dedupKey)) continue;
|
|
525
614
|
seen.add(dedupKey);
|
|
526
615
|
// The find->glob rename and the name sanitization share one upstream name,
|
|
527
616
|
// so a caller tool needing either is emitted under a single new name.
|
|
528
|
-
const upstreamName = sanitizeUpstreamToolName(
|
|
617
|
+
const upstreamName = sanitizeUpstreamToolName(upstreamNameFor(canon.name), seen);
|
|
529
618
|
if (upstreamName.toLowerCase() !== dedupKey) seen.add(upstreamName.toLowerCase());
|
|
530
619
|
const needsRename = upstreamName !== canon.name;
|
|
531
620
|
if (!needsRename && alreadyTargetShape(rec, api)) {
|
|
@@ -535,60 +624,17 @@ export function translateToolsForPath(tools: unknown[], pathname: string): Recor
|
|
|
535
624
|
const renamed: CanonicalTool = needsRename
|
|
536
625
|
? { ...canon, name: upstreamName }
|
|
537
626
|
: canon;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
627
|
+
const converted =
|
|
628
|
+
api === "responses"
|
|
629
|
+
? toResponsesTool(renamed)
|
|
630
|
+
: api === "messages"
|
|
631
|
+
? toAnthropicTool(renamed)
|
|
632
|
+
: toChatTool(renamed);
|
|
633
|
+
carryExtraFields(rec, converted);
|
|
634
|
+
out.push(converted);
|
|
546
635
|
}
|
|
547
636
|
return out;
|
|
548
637
|
}
|
|
549
|
-
/**
|
|
550
|
-
* Inject the missing fingerprint tools into an already-translated tool array,
|
|
551
|
-
* using the target path's shape. Idempotent and case-insensitive: `Bash`
|
|
552
|
-
* satisfies `bash` and is never duplicated.
|
|
553
|
-
* OMP-like callers (explicit `ompLike`, else auto-detected from the array via
|
|
554
|
-
* {@link isOmpLikeCaller}) get REAL minimal executable definitions from
|
|
555
|
-
* {@link OMP_FINGERPRINT_DEFS}; everyone else (Pi) keeps the empty COMPAT
|
|
556
|
-
* placeholders. `edit` is always the COMPAT placeholder, even for OMP-like
|
|
557
|
-
* callers — no static edit def executes on either host, so it must stay
|
|
558
|
-
* cloaked downstream. Re-injecting injected output adds zero tools.
|
|
559
|
-
*/
|
|
560
|
-
export function injectFingerprintTools(
|
|
561
|
-
tools: Record<string, unknown>[],
|
|
562
|
-
pathname: string,
|
|
563
|
-
ompLike?: boolean,
|
|
564
|
-
): Record<string, unknown>[] {
|
|
565
|
-
const present = new Set<string>();
|
|
566
|
-
for (const tool of tools) {
|
|
567
|
-
const canon = canonicalizeTool(tool);
|
|
568
|
-
if (canon) present.add(canon.name.trim().toLowerCase());
|
|
569
|
-
}
|
|
570
|
-
const omp = ompLike ?? isOmpLikeCaller(present);
|
|
571
|
-
const api = apiForPathname(pathname);
|
|
572
|
-
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
|
573
|
-
if (present.has(name.toLowerCase())) continue;
|
|
574
|
-
const def = omp && name !== "edit" ? OMP_FINGERPRINT_DEFS[name] : null;
|
|
575
|
-
const canon: CanonicalTool = {
|
|
576
|
-
name,
|
|
577
|
-
description: def ? def.description : COMPAT_TOOL_DESCRIPTION,
|
|
578
|
-
parameters: def ? { ...(def.parameters as Record<string, unknown>) } : { type: "object", properties: {} },
|
|
579
|
-
};
|
|
580
|
-
tools.push(
|
|
581
|
-
api === "responses"
|
|
582
|
-
? toResponsesTool(canon)
|
|
583
|
-
: api === "messages"
|
|
584
|
-
? toAnthropicTool(canon)
|
|
585
|
-
: toChatTool(canon),
|
|
586
|
-
);
|
|
587
|
-
present.add(name.toLowerCase());
|
|
588
|
-
}
|
|
589
|
-
return tools;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
638
|
/**
|
|
593
639
|
* Responses <-> Chat body translation. Cline serves chat completions only, so
|
|
594
640
|
* the responses body is reshaped to chat before the Cline branch sends it,
|
|
@@ -675,7 +721,10 @@ export function chatBodyFromResponsesBody(body: Record<string, unknown>): Record
|
|
|
675
721
|
out.messages = messages;
|
|
676
722
|
if (Array.isArray(body.tools)) out.tools = translateToolsForPath(body.tools, "/v1/chat/completions");
|
|
677
723
|
if (body.tool_choice !== undefined) out.tool_choice = body.tool_choice;
|
|
678
|
-
|
|
724
|
+
// `max_output_tokens` is the Responses-native cap and is what a host actually
|
|
725
|
+
// sets on this path; without it here the cap silently vanished on the
|
|
726
|
+
// chat-only lanes and the model answered at its own default length.
|
|
727
|
+
for (const key of ["temperature", "top_p", "max_tokens", "max_completion_tokens", "max_output_tokens", "stop", "presence_penalty", "frequency_penalty", "seed", "user", "parallel_tool_calls", "response_format"] as const) {
|
|
679
728
|
if (body[key] !== undefined) out[key] = body[key];
|
|
680
729
|
}
|
|
681
730
|
return out;
|
|
@@ -741,8 +790,51 @@ export function chatResponsesJsonFromChatCompletion(chat: Record<string, unknown
|
|
|
741
790
|
*/
|
|
742
791
|
export function chatResponsesSseFromChatCompletion(resp: Record<string, unknown>): string {
|
|
743
792
|
const head = { type: "response.created", response: { id: resp.id, object: "response", model: resp.model } };
|
|
793
|
+
let out = `event: response.created\ndata: ${JSON.stringify(head)}\n\n`;
|
|
794
|
+
const output = resp.output;
|
|
795
|
+
if (Array.isArray(output)) {
|
|
796
|
+
for (let idx = 0; idx < output.length; idx++) {
|
|
797
|
+
const item = output[idx] as Record<string, unknown>;
|
|
798
|
+
if (item && item.type === "message" && Array.isArray(item.content)) {
|
|
799
|
+
for (const part of item.content) {
|
|
800
|
+
const p = part as Record<string, unknown>;
|
|
801
|
+
if (p && p.type === "output_text" && typeof p.text === "string" && p.text.length > 0) {
|
|
802
|
+
out += `event: response.output_text.delta\ndata: ${JSON.stringify({
|
|
803
|
+
type: "response.output_text.delta",
|
|
804
|
+
output_index: idx,
|
|
805
|
+
delta: p.text,
|
|
806
|
+
})}\n\n`;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
} else if (item && item.type === "function_call") {
|
|
810
|
+
out += `event: response.output_item.added\ndata: ${JSON.stringify({
|
|
811
|
+
type: "response.output_item.added",
|
|
812
|
+
output_index: idx,
|
|
813
|
+
item: {
|
|
814
|
+
type: "function_call",
|
|
815
|
+
id: item.id,
|
|
816
|
+
call_id: item.call_id,
|
|
817
|
+
name: item.name,
|
|
818
|
+
arguments: "",
|
|
819
|
+
},
|
|
820
|
+
})}\n\n`;
|
|
821
|
+
if (typeof item.arguments === "string" && item.arguments.length > 0) {
|
|
822
|
+
out += `event: response.function_call_arguments.delta\ndata: ${JSON.stringify({
|
|
823
|
+
type: "response.function_call_arguments.delta",
|
|
824
|
+
output_index: idx,
|
|
825
|
+
delta: item.arguments,
|
|
826
|
+
})}\n\n`;
|
|
827
|
+
}
|
|
828
|
+
out += `event: response.output_item.done\ndata: ${JSON.stringify({
|
|
829
|
+
type: "response.output_item.done",
|
|
830
|
+
output_index: idx,
|
|
831
|
+
item,
|
|
832
|
+
})}\n\n`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
744
836
|
const done = { type: "response.completed", response: resp };
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
837
|
+
out += `event: response.completed\ndata: ${JSON.stringify(done)}\n\n`;
|
|
838
|
+
out += `data: [DONE]\n\n`;
|
|
839
|
+
return out;
|
|
748
840
|
}
|