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-args.ts
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* `items: { item: [...] }`. Sibling free models invented whole vocabularies
|
|
10
10
|
* (`phases` / `name` / `tasks`) the schema never declared.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three families are mechanically decidable from the CALLER's own declared
|
|
13
13
|
* schema, and this module repairs exactly those:
|
|
14
14
|
*
|
|
15
|
+
* - Prefix-glue salvage: an argument string carrying the tail of the previous
|
|
16
|
+
* call in front of the caller's own object is recovered (see parseArguments).
|
|
17
|
+
*
|
|
15
18
|
* - Envelope unwrap: a declared array that arrives wrapped in a single-key
|
|
16
19
|
* object (`items: { item: [...] }`) is unwrapped.
|
|
17
20
|
* - JSON-string decode: a declared object/array that arrives as a JSON string
|
|
@@ -38,6 +41,14 @@ import { canonicalizeTool } from "./tool-translation.ts";
|
|
|
38
41
|
/** Bound on recursive repair/validation; real host schemas nest 3-4 deep. */
|
|
39
42
|
const MAX_DEPTH = 8;
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Text ending in a bare numeric literal (`"timeout":18`, `"n":1.`, `"n":1e`).
|
|
46
|
+
* A complete JSON value is never followed by a digit, so this can only be a
|
|
47
|
+
* literal the model was still writing when the stream cut — see
|
|
48
|
+
* {@link appendClosers}.
|
|
49
|
+
*/
|
|
50
|
+
const CUT_NUMBER_TAIL = /[\d.eE+-]$/;
|
|
51
|
+
|
|
41
52
|
/** Declared JSON Schema type, tolerating union types like ["array","null"]. */
|
|
42
53
|
function schemaType(schema: Record<string, unknown>): string {
|
|
43
54
|
const declared = schema.type;
|
|
@@ -49,10 +60,47 @@ function schemaType(schema: Record<string, unknown>): string {
|
|
|
49
60
|
return "";
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Does a value already satisfy the declared schema? Type, then required keys,
|
|
66
|
+
* then each present property recursively. A present property the schema does
|
|
67
|
+
* not declare is ignored (never fails): hosts decide their own extras policy,
|
|
68
|
+
* and this only gates whether a promotion is safe.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately reads five keywords — `type`, `enum`, `properties`, `items`,
|
|
71
|
+
* `required` — and nothing else. `minItems`, `minLength`, `pattern`,
|
|
72
|
+
* `additionalProperties` and `strict` are NOT enforced, on purpose: this
|
|
73
|
+
* function only decides whether to hand the repaired value to the host, and
|
|
74
|
+
* the host validates the same keywords itself. A payload that violates
|
|
75
|
+
* `minItems` is rejected by the host whether or not the repair accepted it, so
|
|
76
|
+
* enforcing it here could only ever DISABLE a repair that would have been
|
|
77
|
+
* accepted downstream — strictly worse, never better. The trade-off this does
|
|
78
|
+
* leave: repair never rescues a call whose only fault is a constraint like
|
|
79
|
+
* `minItems`, because the shape was never the problem.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Is this property allowed to be absent?
|
|
83
|
+
*
|
|
84
|
+
* Both hosts rewrite a tool's schema for strict mode by listing EVERY property
|
|
85
|
+
* key in `required` and wrapping each optional one in
|
|
86
|
+
* `anyOf: [<schema>, { type: "null" }]` (OMP: packages/ai CONSTRAINTS.md:34-49;
|
|
87
|
+
* Pi: core/constrained-sampling.ts:118-123). So "required" there does not mean
|
|
88
|
+
* "must be present" — it means "must be addressable". Reading it literally made
|
|
89
|
+
* this gate refuse every well-formed call on a strict tool, which silently
|
|
90
|
+
* disabled the whole repair for `bash` and the strict edit modes: the glued
|
|
91
|
+
* payload the host rejects is exactly the one this module exists to fix.
|
|
92
|
+
*/
|
|
93
|
+
function isNullableProperty(prop: unknown): boolean {
|
|
94
|
+
if (prop === null || typeof prop !== "object" || Array.isArray(prop)) return false;
|
|
95
|
+
const rec = prop as Record<string, unknown>;
|
|
96
|
+
if (Array.isArray(rec.type)) return (rec.type as unknown[]).includes("null");
|
|
97
|
+
const anyOf = rec.anyOf;
|
|
98
|
+
if (Array.isArray(anyOf)) {
|
|
99
|
+
for (const branch of anyOf) {
|
|
100
|
+
if (branch !== null && typeof branch === "object" && !Array.isArray(branch) && (branch as Record<string, unknown>).type === "null") return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
56
104
|
}
|
|
57
105
|
|
|
58
106
|
/**
|
|
@@ -60,38 +108,74 @@ function structuralProperty(schema: Record<string, unknown>): boolean {
|
|
|
60
108
|
* then each present property recursively. A present property the schema does
|
|
61
109
|
* not declare is ignored (never fails): hosts decide their own extras policy,
|
|
62
110
|
* and this only gates whether a promotion is safe.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately reads `type`, `enum`, `properties`, `items`, `required` — and
|
|
113
|
+
* nothing else. `minItems`, `minLength`, `pattern`, `additionalProperties` and
|
|
114
|
+
* `strict` are NOT enforced, on purpose: this function only decides whether to
|
|
115
|
+
* hand the repaired value to the host, and the host validates the same
|
|
116
|
+
* keywords itself. A payload that violates `minItems` is rejected by the host
|
|
117
|
+
* whether or not the repair accepted it, so enforcing it here could only ever
|
|
118
|
+
* DISABLE a repair the host would otherwise have accepted — strictly worse,
|
|
119
|
+
* never better. The trade-off this leaves: repair never rescues a call whose
|
|
120
|
+
* only fault is a constraint like `minItems`, because the shape was never the
|
|
121
|
+
* problem.
|
|
63
122
|
*/
|
|
64
123
|
function matchesSchema(value: unknown, schema: Record<string, unknown>, depth: number): boolean {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
|
|
86
|
-
if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
|
|
124
|
+
if (depth > MAX_DEPTH) return false;
|
|
125
|
+
const type = schemaType(schema);
|
|
126
|
+
if (type === "array") {
|
|
127
|
+
if (!Array.isArray(value)) return false;
|
|
128
|
+
const items = schema.items;
|
|
129
|
+
if (items === null || typeof items !== "object" || Array.isArray(items)) return true;
|
|
130
|
+
return value.every((element) => matchesSchema(element, items as Record<string, unknown>, depth + 1));
|
|
131
|
+
}
|
|
132
|
+
if (type === "object") {
|
|
133
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
134
|
+
const rec = value as Record<string, unknown>;
|
|
135
|
+
const props = schema.properties;
|
|
136
|
+
const declared = props !== null && typeof props === "object" && !Array.isArray(props) ? (props as Record<string, unknown>) : null;
|
|
137
|
+
if (Array.isArray(schema.required)) {
|
|
138
|
+
for (const key of schema.required) {
|
|
139
|
+
if (typeof key !== "string" || key in rec) continue;
|
|
140
|
+
// Strict-mode hosts list every key here and mark the optional ones nullable,
|
|
141
|
+
// so a nullable key that is absent is correct, not a violation.
|
|
142
|
+
if (declared !== null && isNullableProperty(declared[key])) continue;
|
|
143
|
+
return false;
|
|
87
144
|
}
|
|
88
|
-
return true;
|
|
89
145
|
}
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
146
|
+
if (declared === null) return true;
|
|
147
|
+
for (const [key, entry] of Object.entries(rec)) {
|
|
148
|
+
const propSchema = declared[key];
|
|
149
|
+
if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
|
|
150
|
+
if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
|
|
151
|
+
}
|
|
94
152
|
return true;
|
|
153
|
+
}
|
|
154
|
+
if (type === "string") return typeof value === "string";
|
|
155
|
+
if (type === "number") return typeof value === "number" && Number.isFinite(value);
|
|
156
|
+
if (type === "boolean") return typeof value === "boolean";
|
|
157
|
+
if (Array.isArray(schema.enum)) return schema.enum.some((member) => member === value);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The spellings free models use for "no value" when the slot is nullable. */
|
|
162
|
+
const NULL_SENTINELS = new Set(["null", "none", "undefined", "nil", "n/a"]);
|
|
163
|
+
|
|
164
|
+
function isNullSentinel(text: string): boolean {
|
|
165
|
+
return NULL_SENTINELS.has(text.trim().toLowerCase());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The non-null branch of a nullable declaration, or null when it is not one. */
|
|
169
|
+
function nonNullBranch(prop: Record<string, unknown>): Record<string, unknown> | null {
|
|
170
|
+
const anyOf = prop.anyOf;
|
|
171
|
+
if (!Array.isArray(anyOf)) return null;
|
|
172
|
+
for (const branch of anyOf) {
|
|
173
|
+
if (branch === null || typeof branch !== "object" || Array.isArray(branch)) continue;
|
|
174
|
+
const rec = branch as Record<string, unknown>;
|
|
175
|
+
if (rec.type === "null") continue;
|
|
176
|
+
return rec;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
95
179
|
}
|
|
96
180
|
|
|
97
181
|
/**
|
|
@@ -164,6 +248,24 @@ function repairValue(
|
|
|
164
248
|
out[key] = entry;
|
|
165
249
|
continue;
|
|
166
250
|
}
|
|
251
|
+
// A strict host makes optional keys `anyOf: [T, null]`, and free models
|
|
252
|
+
// routinely answer that slot with the SENTINEL AS TEXT — `"timeout":"null"`,
|
|
253
|
+
// `"pty":"None"` (measured across 4 of 37 catalog models). The host rejects
|
|
254
|
+
// the call because a string is not a number and not null.
|
|
255
|
+
//
|
|
256
|
+
// Restricted to non-string inner types on purpose: when T is `string`,
|
|
257
|
+
// `"null"` could be a genuine value and rewriting it would destroy data.
|
|
258
|
+
// With T = number/boolean/object/array the property can never legitimately
|
|
259
|
+
// hold a bare string, so collapsing a null sentinel loses nothing and makes
|
|
260
|
+
// the call one the host accepts.
|
|
261
|
+
if (typeof entry === "string" && isNullSentinel(entry)) {
|
|
262
|
+
const nonNull = nonNullBranch(propSchema as Record<string, unknown>);
|
|
263
|
+
if (nonNull !== null && schemaType(nonNull) !== "string") {
|
|
264
|
+
out[key] = null;
|
|
265
|
+
changed = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
167
269
|
const repaired = repairValue(entry, propSchema as Record<string, unknown>, depth + 1);
|
|
168
270
|
if (repaired.changed) changed = true;
|
|
169
271
|
out[key] = repaired.value;
|
|
@@ -318,28 +420,29 @@ function promoteStringArray(
|
|
|
318
420
|
|
|
319
421
|
/**
|
|
320
422
|
* Per-request map of caller tool name (lowercased) to the schema its
|
|
321
|
-
* arguments are repaired against.
|
|
322
|
-
*
|
|
323
|
-
*
|
|
423
|
+
* arguments are repaired against. Every tool that declares object parameters
|
|
424
|
+
* is kept, NOT only those with an object/array property: the prefix-glue
|
|
425
|
+
* salvage in {@link parseArguments} needs only the caller's own schema to
|
|
426
|
+
* confirm the recovered object, and it fires most often on scalar-only tools.
|
|
427
|
+
* `bash` is the live case — OMP's `bashSchemaBase` declares only `command`,
|
|
428
|
+
* `timeout`, `cwd`, `pty` (reference/oh-my-pi tools/bash.ts:327), so a
|
|
429
|
+
* structural-only filter left the most-used tool unrepairable and the host
|
|
430
|
+
* rejected the glued payload outright.
|
|
324
431
|
*
|
|
325
432
|
* Keyed by CALLER name, which is what arrives downstream after the casing and
|
|
326
433
|
* find->glob restores. Injected fingerprint placeholders are absent by
|
|
327
434
|
* construction, so they are never repaired.
|
|
328
435
|
*/
|
|
329
436
|
export function buildArgRepairSchemas(tools: unknown[]): Map<string, Record<string, unknown>> {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (!hasStructural) continue;
|
|
340
|
-
schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
|
|
341
|
-
}
|
|
342
|
-
return schemas;
|
|
437
|
+
const schemas = new Map<string, Record<string, unknown>>();
|
|
438
|
+
for (const tool of tools) {
|
|
439
|
+
const canon = canonicalizeTool(tool);
|
|
440
|
+
if (canon === null) continue;
|
|
441
|
+
const props = canon.parameters.properties;
|
|
442
|
+
if (props === null || typeof props !== "object" || Array.isArray(props)) continue;
|
|
443
|
+
schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
|
|
444
|
+
}
|
|
445
|
+
return schemas;
|
|
343
446
|
}
|
|
344
447
|
|
|
345
448
|
|
|
@@ -374,8 +477,13 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
|
|
|
374
477
|
} catch {
|
|
375
478
|
// fall through to the salvage scan
|
|
376
479
|
}
|
|
377
|
-
|
|
378
|
-
|
|
480
|
+
// Only a brace that OPENS a top-level object can start the caller's call: a
|
|
481
|
+
// brace nested inside an already-unterminated object belongs to that outer
|
|
482
|
+
// object, so promoting it would drop the declared key that wraps it
|
|
483
|
+
// (`{"opts":{"a":1` must not salvage to `{"a":1}`).
|
|
484
|
+
for (const start of topLevelObjectStarts(argsJson)) {
|
|
485
|
+
const candidate = argsJson.slice(start);
|
|
486
|
+
const i = start;
|
|
379
487
|
// Two shapes to try, in order: the candidate as written, then the candidate
|
|
380
488
|
// with its missing closers appended. The closer is only offered at the FIRST
|
|
381
489
|
// brace and only when something precedes it — that fragment is the evidence
|
|
@@ -395,7 +503,35 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
|
|
|
395
503
|
}
|
|
396
504
|
}
|
|
397
505
|
}
|
|
398
|
-
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Offsets of every `{` that could open the caller's own object.
|
|
511
|
+
*
|
|
512
|
+
* Depth counts braces only, clamped at zero, and deliberately ignores string
|
|
513
|
+
* state. The junk in front is itself a cut fragment: it usually ends
|
|
514
|
+
* mid-string (`…/freelane-proxy"`), so string tracking would classify the whole
|
|
515
|
+
* real call as "inside a string" and find no start at all — which is exactly
|
|
516
|
+
* the live failure this scan has to recover from.
|
|
517
|
+
*
|
|
518
|
+
* A nested brace is a candidate only when no key precedes it: `{"opts":{"a":1}`
|
|
519
|
+
* must NOT promote its inner `{"a":1}` (a `:` sits between, so the outer
|
|
520
|
+
* object is a real one and its declared key would be dropped), while the
|
|
521
|
+
* observed prefix junk `}{` must still reach the real call that follows it.
|
|
522
|
+
*/
|
|
523
|
+
function topLevelObjectStarts(text: string): number[] {
|
|
524
|
+
const starts: number[] = [];
|
|
525
|
+
const openAt: number[] = [];
|
|
526
|
+
for (let i = 0; i < text.length; i++) {
|
|
527
|
+
if (text[i] === "{") {
|
|
528
|
+
if (openAt.length === 0 || !text.slice(openAt[openAt.length - 1] + 1, i).includes(":")) starts.push(i);
|
|
529
|
+
openAt.push(i);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (text[i] === "}" && openAt.length > 0) openAt.pop();
|
|
533
|
+
}
|
|
534
|
+
return starts;
|
|
399
535
|
}
|
|
400
536
|
|
|
401
537
|
/**
|
|
@@ -404,11 +540,15 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
|
|
|
404
540
|
*
|
|
405
541
|
* Only ever called on a candidate that starts at a `{` preceded by a fragment
|
|
406
542
|
* of the previous call, and only when the candidate's own contents already
|
|
407
|
-
* end cleanly.
|
|
543
|
+
* end cleanly. Three refusals keep this honest:
|
|
408
544
|
*
|
|
409
545
|
* - Cut inside a string literal (an unterminated `"`) returns undefined. The
|
|
410
546
|
* value itself is incomplete, so any closing brace would silently shorten
|
|
411
547
|
* it — and these arguments carry shell commands.
|
|
548
|
+
* - Cut inside a number literal returns undefined. `{"timeout":18` may have
|
|
549
|
+
* been `180`, `18.5`, or `18`; completing it hands the host a confident wrong
|
|
550
|
+
* value with nothing to signal the guess. (`timeout` is a declared number on
|
|
551
|
+
* both hosts' `bash`.)
|
|
412
552
|
* - Nothing left open returns undefined, so a well-formed candidate never
|
|
413
553
|
* reaches this path.
|
|
414
554
|
*
|
|
@@ -433,6 +573,8 @@ function appendClosers(text: string): string | undefined {
|
|
|
433
573
|
}
|
|
434
574
|
// Inside a string at the end: the value was cut mid-token, so refuse.
|
|
435
575
|
if (inString || open.length === 0) return undefined;
|
|
576
|
+
// A bare number at the end is a cut literal (`18` of `180`), so refuse.
|
|
577
|
+
if (CUT_NUMBER_TAIL.test(text)) return undefined;
|
|
436
578
|
return text + open.reverse().join("");
|
|
437
579
|
}
|
|
438
580
|
/**
|