@oh-my-pi/pi-ai 16.3.4 → 16.3.6

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.
@@ -835,6 +835,263 @@ function normalizeOptionalNullsForSchema(
835
835
  return { value: changed ? nextValue : value, changed };
836
836
  }
837
837
 
838
+ function decodeJsonPointerToken(token: string): string {
839
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
840
+ }
841
+
842
+ function resolveLocalJsonSchemaRef(root: unknown, ref: string): unknown | undefined {
843
+ if (ref === "#") return root;
844
+ if (!ref.startsWith("#/")) return undefined;
845
+ let current: unknown = root;
846
+ for (const rawToken of ref.slice(2).split("/")) {
847
+ const token = decodeJsonPointerToken(rawToken);
848
+ if (current === null || typeof current !== "object") return undefined;
849
+ current = (current as Record<string, unknown>)[token];
850
+ }
851
+ return current;
852
+ }
853
+
854
+ function normalizeEnumStringWhitespace(
855
+ schema: unknown,
856
+ value: unknown,
857
+ root: unknown = schema,
858
+ refs: ReadonlySet<string> = new Set(),
859
+ ): { value: unknown; changed: boolean } {
860
+ if (value === null || value === undefined) return { value, changed: false };
861
+ if (schema === null || typeof schema !== "object") return { value, changed: false };
862
+
863
+ const schemaObject = schema as Record<string, unknown>;
864
+ const ref = schemaObject.$ref;
865
+ if (typeof ref === "string") {
866
+ if (refs.has(ref)) return { value, changed: false };
867
+ const resolved = resolveLocalJsonSchemaRef(root, ref);
868
+ if (resolved === undefined) return { value, changed: false };
869
+ return normalizeEnumStringWhitespace(resolved, value, root, new Set([...refs, ref]));
870
+ }
871
+
872
+ const branchMatches = (branch: unknown, candidate: unknown): boolean => {
873
+ if (branch !== null && typeof branch === "object") {
874
+ const branchRef = (branch as Record<string, unknown>).$ref;
875
+ if (typeof branchRef === "string" && !refs.has(branchRef)) {
876
+ const resolved = resolveLocalJsonSchemaRef(root, branchRef);
877
+ if (resolved !== undefined) return branchMatchesSchema(resolved, candidate);
878
+ }
879
+ }
880
+ return branchMatchesSchema(branch, candidate);
881
+ };
882
+
883
+ const normalizeAnyOfLike = (keyword: "anyOf" | "oneOf"): { value: unknown; changed: boolean } => {
884
+ const branches = schemaObject[keyword];
885
+ if (!Array.isArray(branches)) return { value, changed: false };
886
+ if (branches.some(branch => branchMatches(branch, value))) return { value, changed: false };
887
+
888
+ for (const branch of branches) {
889
+ const normalized = normalizeEnumStringWhitespace(branch, value, root, refs);
890
+ if (!normalized.changed) continue;
891
+ if (branchMatches(branch, normalized.value)) return normalized;
892
+ }
893
+ return { value, changed: false };
894
+ };
895
+
896
+ const anyOfNormalization = normalizeAnyOfLike("anyOf");
897
+ if (anyOfNormalization.changed) return anyOfNormalization;
898
+
899
+ const oneOfNormalization = normalizeAnyOfLike("oneOf");
900
+ if (oneOfNormalization.changed) return oneOfNormalization;
901
+
902
+ if (Array.isArray(schemaObject.allOf)) {
903
+ let changed = false;
904
+ let nextValue: unknown = value;
905
+ for (const branch of schemaObject.allOf) {
906
+ const normalized = normalizeEnumStringWhitespace(branch, nextValue, root, refs);
907
+ if (!normalized.changed) continue;
908
+ nextValue = normalized.value;
909
+ changed = true;
910
+ }
911
+ if (changed) return { value: nextValue, changed: true };
912
+ }
913
+
914
+ if (typeof value === "string") {
915
+ const trimmed = value.trim();
916
+ if (trimmed !== value) {
917
+ const enumValues = schemaObject.enum;
918
+ if (Array.isArray(enumValues) && !enumValues.includes(value) && enumValues.includes(trimmed)) {
919
+ return { value: trimmed, changed: true };
920
+ }
921
+ const constValue = schemaObject.const;
922
+ if (typeof constValue === "string" && trimmed === constValue) {
923
+ return { value: trimmed, changed: true };
924
+ }
925
+ }
926
+ return { value, changed: false };
927
+ }
928
+
929
+ if (Array.isArray(value)) {
930
+ let changed = false;
931
+ let nextValue = value;
932
+ const prefixItems = schemaObject.prefixItems;
933
+ if (Array.isArray(prefixItems)) {
934
+ for (let i = 0; i < value.length && i < prefixItems.length; i += 1) {
935
+ const itemSchema = prefixItems[i];
936
+ const normalized = normalizeEnumStringWhitespace(itemSchema, value[i], root, refs);
937
+ if (!normalized.changed) continue;
938
+ if (!changed) {
939
+ nextValue = [...value];
940
+ changed = true;
941
+ }
942
+ nextValue[i] = normalized.value;
943
+ }
944
+ }
945
+
946
+ const itemSchema = schemaObject.items;
947
+ if (itemSchema !== null && typeof itemSchema === "object" && !Array.isArray(itemSchema)) {
948
+ for (let i = 0; i < value.length; i += 1) {
949
+ if (Array.isArray(prefixItems) && i < prefixItems.length) continue;
950
+ const normalized = normalizeEnumStringWhitespace(itemSchema, nextValue[i], root, refs);
951
+ if (!normalized.changed) continue;
952
+ if (!changed) {
953
+ nextValue = [...value];
954
+ changed = true;
955
+ }
956
+ nextValue[i] = normalized.value;
957
+ }
958
+ }
959
+ return { value: changed ? nextValue : value, changed };
960
+ }
961
+
962
+ if (typeof value !== "object") return { value, changed: false };
963
+ const properties = schemaObject.properties;
964
+ if (!properties || typeof properties !== "object") return { value, changed: false };
965
+
966
+ const propsObject = properties as Record<string, unknown>;
967
+ const valueObject = value as Record<string, unknown>;
968
+ let changed = false;
969
+ let nextValue = valueObject;
970
+ for (const [key, propertySchema] of Object.entries(propsObject)) {
971
+ if (!(key in nextValue)) continue;
972
+ const normalized = normalizeEnumStringWhitespace(propertySchema, nextValue[key], root, refs);
973
+ if (!normalized.changed) continue;
974
+ if (!changed) {
975
+ nextValue = { ...nextValue };
976
+ changed = true;
977
+ }
978
+ nextValue[key] = normalized.value;
979
+ }
980
+ return { value: changed ? nextValue : valueObject, changed };
981
+ }
982
+
983
+ // ============================================================================
984
+ // Identifier-string trailing-whitespace normalization (LLM quirk).
985
+ // ============================================================================
986
+ //
987
+ // LLMs sometimes emit tool arguments with a trailing newline dangling off a
988
+ // short identifier — a path, URL, or a display label like `title`. These
989
+ // values are never legitimately terminated by line breaks, so we strip trailing
990
+ // line terminators from string values on the well-known keys below before the
991
+ // tool ever sees them. Content-carrying properties (`content`, `input`, `body`,
992
+ // `text`, `command`, `code`) are intentionally not traversed or trimmed so
993
+ // genuine trailing whitespace survives on writes, patches, shell commands, and
994
+ // eval snippets.
995
+ // ============================================================================
996
+
997
+ /**
998
+ * Property names whose values are treated as short identifiers — filesystem
999
+ * paths, URLs, URIs, or display labels. The trim only fires on strings sitting
1000
+ * under one of these keys, so `path: "docs/report "` still targets the file
1001
+ * whose name ends in a space.
1002
+ */
1003
+ const IDENTIFIER_STRING_KEYS: ReadonlySet<string> = new Set([
1004
+ "path",
1005
+ "paths",
1006
+ "file",
1007
+ "file_path",
1008
+ "filePath",
1009
+ "filepath",
1010
+ "url",
1011
+ "uri",
1012
+ "title",
1013
+ "label",
1014
+ ]);
1015
+
1016
+ const CONTENT_CARRYING_KEYS: ReadonlySet<string> = new Set(["content", "input", "body", "text", "command", "code"]);
1017
+
1018
+ const TRAILING_LINE_TERMINATOR_RE = /[\r\n]+$/;
1019
+
1020
+ function trimTrailingLineTerminators(input: string): string {
1021
+ if (!TRAILING_LINE_TERMINATOR_RE.test(input)) return input;
1022
+ return input.replace(TRAILING_LINE_TERMINATOR_RE, "");
1023
+ }
1024
+
1025
+ function trimIdentifierStringLeaf(input: unknown): unknown {
1026
+ if (typeof input === "string") {
1027
+ const trimmed = trimTrailingLineTerminators(input);
1028
+ return trimmed === input ? input : trimmed;
1029
+ }
1030
+ if (Array.isArray(input)) {
1031
+ let changed = false;
1032
+ let next = input;
1033
+ for (let i = 0; i < input.length; i += 1) {
1034
+ const item = input[i];
1035
+ if (typeof item !== "string") continue;
1036
+ const trimmed = trimTrailingLineTerminators(item);
1037
+ if (trimmed === item) continue;
1038
+ if (!changed) {
1039
+ next = input.slice();
1040
+ changed = true;
1041
+ }
1042
+ next[i] = trimmed;
1043
+ }
1044
+ return changed ? next : input;
1045
+ }
1046
+ return input;
1047
+ }
1048
+
1049
+ /**
1050
+ * Recursively strip trailing line terminators from string values whose property
1051
+ * key matches {@link IDENTIFIER_STRING_KEYS}. Runs by property name only
1052
+ * (schema-agnostic) so it fires uniformly across Zod, ArkType, and plain JSON
1053
+ * Schema tools while preserving nested payloads under content-carrying keys.
1054
+ */
1055
+ function normalizeIdentifierStringWhitespace(value: unknown): { value: unknown; changed: boolean } {
1056
+ if (Array.isArray(value)) {
1057
+ let changed = false;
1058
+ let next = value;
1059
+ for (let i = 0; i < value.length; i += 1) {
1060
+ const normalized = normalizeIdentifierStringWhitespace(value[i]);
1061
+ if (!normalized.changed) continue;
1062
+ if (!changed) {
1063
+ next = [...value];
1064
+ changed = true;
1065
+ }
1066
+ next[i] = normalized.value;
1067
+ }
1068
+ return { value: changed ? next : value, changed };
1069
+ }
1070
+
1071
+ if (value === null || typeof value !== "object") return { value, changed: false };
1072
+
1073
+ const source = value as Record<string, unknown>;
1074
+ let changed = false;
1075
+ let out: Record<string, unknown> = source;
1076
+ for (const [key, entry] of Object.entries(source)) {
1077
+ let nextEntry = entry;
1078
+ if (CONTENT_CARRYING_KEYS.has(key)) continue;
1079
+ if (IDENTIFIER_STRING_KEYS.has(key)) {
1080
+ const trimmed = trimIdentifierStringLeaf(entry);
1081
+ if (trimmed !== entry) nextEntry = trimmed;
1082
+ }
1083
+ const nested = normalizeIdentifierStringWhitespace(nextEntry);
1084
+ if (nested.changed) nextEntry = nested.value;
1085
+ if (nextEntry === entry) continue;
1086
+ if (!changed) {
1087
+ out = { ...source };
1088
+ changed = true;
1089
+ }
1090
+ out[key] = nextEntry;
1091
+ }
1092
+ return { value: changed ? out : value, changed };
1093
+ }
1094
+
838
1095
  // ============================================================================
839
1096
  // Double-encoded object-key normalization (LLM quirk).
840
1097
  // ============================================================================
@@ -1485,6 +1742,23 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1485
1742
  changed = true;
1486
1743
  }
1487
1744
 
1745
+ const enumStringNormalization = normalizeEnumStringWhitespace(json, normalizedArgs);
1746
+ if (enumStringNormalization.changed) {
1747
+ normalizedArgs = enumStringNormalization.value;
1748
+ changed = true;
1749
+ }
1750
+
1751
+ // Strip trailing whitespace from string values on well-known
1752
+ // identifier-like property names (paths, URLs, titles). Some models tack
1753
+ // a newline onto a short-identifier arg from stream artifacts; downstream
1754
+ // tools then either fail to stat the target or annotate a "corrected
1755
+ // from" hint the model misreads as tool corruption.
1756
+ const identifierStringNormalization = normalizeIdentifierStringWhitespace(normalizedArgs);
1757
+ if (identifierStringNormalization.changed) {
1758
+ normalizedArgs = identifierStringNormalization.value;
1759
+ changed = true;
1760
+ }
1761
+
1488
1762
  // Then re-shape JSON-stringified arrays whose schema accepts both string
1489
1763
  // and array (e.g. `paths: string | string[]`). Without this, zod accepts
1490
1764
  // the literal `'["a","b"]'` as a string and downstream tools treat it as
@@ -1495,6 +1769,12 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1495
1769
  changed = true;
1496
1770
  }
1497
1771
 
1772
+ const identifierStringNormalizationAfterArray = normalizeIdentifierStringWhitespace(normalizedArgs);
1773
+ if (identifierStringNormalizationAfterArray.changed) {
1774
+ normalizedArgs = identifierStringNormalizationAfterArray.value;
1775
+ changed = true;
1776
+ }
1777
+
1498
1778
  // Single-argument tools (e.g. `edit`): if the model put the lone required
1499
1779
  // string under a different key, adopt the first string field as that key.
1500
1780
  const singleStringNorm = normalizeSingleStringField(json, normalizedArgs);
@@ -1527,6 +1807,16 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1527
1807
  normalizedArgs = nullNormalization.value;
1528
1808
  }
1529
1809
 
1810
+ const enumStringNormalizationPass = normalizeEnumStringWhitespace(json, normalizedArgs);
1811
+ if (enumStringNormalizationPass.changed) {
1812
+ normalizedArgs = enumStringNormalizationPass.value;
1813
+ }
1814
+
1815
+ const identifierStringNormalizationPass = normalizeIdentifierStringWhitespace(normalizedArgs);
1816
+ if (identifierStringNormalizationPass.changed) {
1817
+ normalizedArgs = identifierStringNormalizationPass.value;
1818
+ }
1819
+
1530
1820
  // Re-run the union-string coercion because `coerceArgsFromIssues` may
1531
1821
  // have just unwrapped a JSON-stringified object at the root or inside a
1532
1822
  // nested field — exposing `string | string[]` descendants the initial
@@ -1536,6 +1826,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1536
1826
  normalizedArgs = stringEncodedArrayNormPass.value;
1537
1827
  }
1538
1828
 
1829
+ const identifierStringNormalizationAfterArrayPass = normalizeIdentifierStringWhitespace(normalizedArgs);
1830
+ if (identifierStringNormalizationAfterArrayPass.changed) {
1831
+ normalizedArgs = identifierStringNormalizationAfterArrayPass.value;
1832
+ }
1833
+
1539
1834
  // Re-run single-string remap: `coerceArgsFromIssues` may have just
1540
1835
  // unwrapped a JSON-stringified root object, exposing a mislabelled lone
1541
1836
  // string field the initial pre-pass could not see.