@aaroncql/pim-agent 0.0.1

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,636 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { validateToolArguments } from "@earendil-works/pi-ai";
6
+ import type { Static, TSchema } from "typebox";
7
+ import { Levenshtein } from "./Levenshtein";
8
+
9
+ type Issue = { readonly path: string; readonly message: string };
10
+
11
+ type JsonSchema = {
12
+ readonly type?: string;
13
+ readonly const?: unknown;
14
+ readonly properties?: Readonly<Record<string, JsonSchema>>;
15
+ readonly items?: JsonSchema | readonly JsonSchema[];
16
+ readonly anyOf?: readonly JsonSchema[];
17
+ readonly oneOf?: readonly JsonSchema[];
18
+ readonly enum?: readonly unknown[];
19
+ };
20
+
21
+ export class Tools {
22
+ /**
23
+ * Wrap a tool definition so pi's validator errors get rewritten before they
24
+ * reach the model. Pi runs `prepareArguments` before validation, so we call
25
+ * pi's validator ourselves inside it, rewrite any throw, and return the
26
+ * (coerced) args; pi's own second validation pass then sees clean input.
27
+ * After successful validation we also reject unknown top-level keys, since
28
+ * TypeBox object schemas accept them by default and typos like
29
+ * `headlimit` vs `head_limit` would silently no-op.
30
+ *
31
+ * Use `Tools.register` for `pi.registerTool` callers; use `Tools.wrap` to
32
+ * pass into `customTools`.
33
+ */
34
+ static wrap<TParams extends TSchema, TDetails = unknown, TState = unknown>(
35
+ def: ToolDefinition<TParams, TDetails, TState>
36
+ ): ToolDefinition<TParams, TDetails, TState> {
37
+ const schema = def.parameters as unknown as JsonSchema;
38
+ return {
39
+ ...def,
40
+ prepareArguments: (rawArgs: unknown): Static<TParams> => {
41
+ const prepared = def.prepareArguments
42
+ ? def.prepareArguments(rawArgs)
43
+ : (rawArgs as Static<TParams>);
44
+ const cleaned = coerceQuotedEnums(prepared, schema) as Static<TParams>;
45
+ const strictIssues = checkStrictTypes(cleaned, schema, "");
46
+ if (strictIssues.length > 0) {
47
+ const lines = strictIssues.map((s) => ` - ${s}`).join("\n");
48
+ throw new Error(
49
+ `Validation failed for tool "${def.name}":\n${lines}`
50
+ );
51
+ }
52
+ let validated: Static<TParams>;
53
+ try {
54
+ validated = validateToolArguments(
55
+ { name: def.name, parameters: def.parameters } as never,
56
+ {
57
+ type: "toolCall",
58
+ id: "",
59
+ name: def.name,
60
+ arguments: cleaned as Record<string, unknown>,
61
+ }
62
+ ) as Static<TParams>;
63
+ } catch (err) {
64
+ throw new Error(
65
+ Tools.rewriteValidationError(def.name, schema, err, cleaned)
66
+ );
67
+ }
68
+ const unknownKeys = findUnknownTopLevelKeys(schema, validated);
69
+ if (unknownKeys.length > 0) {
70
+ throw new Error(
71
+ formatUnknownKeysError(def.name, schema, unknownKeys)
72
+ );
73
+ }
74
+ return validated;
75
+ },
76
+ };
77
+ }
78
+
79
+ static register<
80
+ TParams extends TSchema,
81
+ TDetails = unknown,
82
+ TState = unknown,
83
+ >(pi: ExtensionAPI, def: ToolDefinition<TParams, TDetails, TState>): void {
84
+ pi.registerTool(Tools.wrap(def));
85
+ }
86
+
87
+ /**
88
+ * Rewrite a `validateToolArguments` error string into a clearer form.
89
+ * `schema` is the tool's parameters schema, used to enumerate allowed values
90
+ * for `anyOf`/`enum` failures. `args` is the validated input, used to pick
91
+ * the matching branch of a discriminated union. Public for testing.
92
+ */
93
+ static rewriteValidationError(
94
+ toolName: string,
95
+ schema: JsonSchema,
96
+ err: unknown,
97
+ args?: unknown
98
+ ): string {
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ if (!message.startsWith("Validation failed for tool")) {
101
+ return message;
102
+ }
103
+
104
+ const raw = parseIssues(message);
105
+ const collapsed = collapseAnyOf(raw, schema, args);
106
+ const issues = collapsed.map((issue) => formatIssue(issue, schema));
107
+
108
+ const header = `Validation failed for tool "${toolName}":`;
109
+ if (issues.length === 0) {
110
+ return header;
111
+ }
112
+ return `${header}\n${issues.map((s) => ` - ${s}`).join("\n")}`;
113
+ }
114
+ }
115
+
116
+ function parseIssues(message: string): Issue[] {
117
+ const issues: Issue[] = [];
118
+ for (const line of message.split("\n")) {
119
+ if (line.startsWith("Received arguments:")) {
120
+ break;
121
+ }
122
+ if (!line.startsWith(" - ")) {
123
+ continue;
124
+ }
125
+ const body = line.slice(4);
126
+ const colonIdx = body.indexOf(": ");
127
+ if (colonIdx === -1) {
128
+ issues.push({ path: "", message: body });
129
+ } else {
130
+ issues.push({
131
+ path: body.slice(0, colonIdx),
132
+ message: body.slice(colonIdx + 2),
133
+ });
134
+ }
135
+ }
136
+ return issues;
137
+ }
138
+
139
+ /**
140
+ * Pi emits one error per anyOf branch plus a `must match a schema in anyOf`
141
+ * parent error, producing 6+ noisy lines for a 6-variant union. Replace the
142
+ * whole cluster with a single synthesised line. If the actual value has a
143
+ * discriminator that matches one branch, surface only that branch's real
144
+ * errors instead.
145
+ */
146
+ function collapseAnyOf(
147
+ issues: readonly Issue[],
148
+ schema: JsonSchema,
149
+ args: unknown
150
+ ): Issue[] {
151
+ const handled = new Set<number>();
152
+ const inserts = new Map<number, Issue[]>();
153
+
154
+ issues.forEach((issue, idx) => {
155
+ if (handled.has(idx) || issue.message !== "must match a schema in anyOf") {
156
+ return;
157
+ }
158
+ const node = walkSchema(schema, issue.path);
159
+ const branches = node?.anyOf ?? node?.oneOf;
160
+ if (!node || !branches) {
161
+ return;
162
+ }
163
+ handled.add(idx);
164
+ issues.forEach((other, otherIdx) => {
165
+ if (handled.has(otherIdx)) {
166
+ return;
167
+ }
168
+ if (other.path === issue.path || isUnderPath(other.path, issue.path)) {
169
+ handled.add(otherIdx);
170
+ }
171
+ });
172
+
173
+ const value = walkValue(args, issue.path);
174
+ const matched = matchDiscriminatedBranch(branches, value);
175
+ if (matched) {
176
+ const branchIssues = revalidateBranch(matched, value).map((sub) => ({
177
+ path: joinPath(issue.path, sub.path),
178
+ message: sub.message,
179
+ }));
180
+ inserts.set(idx, branchIssues);
181
+ } else {
182
+ inserts.set(idx, [
183
+ { path: issue.path, message: describeAnyOf(branches) },
184
+ ]);
185
+ }
186
+ });
187
+
188
+ const result: Issue[] = [];
189
+ issues.forEach((issue, idx) => {
190
+ if (inserts.has(idx)) {
191
+ result.push(...inserts.get(idx)!);
192
+ return;
193
+ }
194
+ if (!handled.has(idx)) {
195
+ result.push(issue);
196
+ }
197
+ });
198
+ return result;
199
+ }
200
+
201
+ function describeAnyOf(branches: readonly JsonSchema[]): string {
202
+ const constValues = branches
203
+ .map((b) => (b && "const" in b ? b.const : undefined))
204
+ .filter((v) => v !== undefined);
205
+ if (constValues.length === branches.length) {
206
+ return `must be one of: ${constValues.map(displayValue).join(", ")}`;
207
+ }
208
+
209
+ const discriminator = findDiscriminator(branches);
210
+ if (discriminator) {
211
+ const values = discriminator.values.map(displayValue).join(", ");
212
+ return `must match one of the allowed variants (${discriminator.field}: ${values})`;
213
+ }
214
+
215
+ return `must match one of ${branches.length} allowed variants`;
216
+ }
217
+
218
+ /**
219
+ * Format an enum value for an error message. Strings render bare so a weaker
220
+ * model that retries off the message doesn't include the quotes in its next
221
+ * attempt (e.g. `action: "\"create\""`). Non-strings keep JSON form for
222
+ * disambiguation.
223
+ */
224
+ function displayValue(value: unknown): string {
225
+ return typeof value === "string" ? value : JSON.stringify(value);
226
+ }
227
+
228
+ function matchDiscriminatedBranch(
229
+ branches: readonly JsonSchema[],
230
+ value: unknown
231
+ ): JsonSchema | undefined {
232
+ if (!isRecord(value)) {
233
+ return undefined;
234
+ }
235
+ const discriminator = findDiscriminator(branches);
236
+ if (!discriminator) {
237
+ return undefined;
238
+ }
239
+ const actual = value[discriminator.field];
240
+ const branchIndex = discriminator.values.findIndex(
241
+ (v) => JSON.stringify(v) === JSON.stringify(actual)
242
+ );
243
+ return branchIndex >= 0 ? branches[branchIndex] : undefined;
244
+ }
245
+
246
+ function findDiscriminator(
247
+ branches: readonly JsonSchema[]
248
+ ): { readonly field: string; readonly values: readonly unknown[] } | undefined {
249
+ const objectBranches = branches.filter(
250
+ (b) => b.type === "object" && b.properties
251
+ );
252
+ if (
253
+ objectBranches.length !== branches.length ||
254
+ objectBranches.length === 0
255
+ ) {
256
+ return undefined;
257
+ }
258
+ for (const propName of Object.keys(objectBranches[0]!.properties!)) {
259
+ const values: unknown[] = [];
260
+ for (const branch of objectBranches) {
261
+ const prop = branch.properties![propName];
262
+ if (prop && "const" in prop) {
263
+ values.push(prop.const);
264
+ } else {
265
+ break;
266
+ }
267
+ }
268
+ if (
269
+ values.length === objectBranches.length &&
270
+ new Set(values.map((v) => JSON.stringify(v))).size === values.length
271
+ ) {
272
+ return { field: propName, values };
273
+ }
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ function revalidateBranch(branch: JsonSchema, value: unknown): Issue[] {
279
+ try {
280
+ validateToolArguments(
281
+ { name: "_branch", parameters: branch as TSchema } as never,
282
+ {
283
+ type: "toolCall",
284
+ id: "",
285
+ name: "_branch",
286
+ arguments: (value ?? {}) as Record<string, unknown>,
287
+ }
288
+ );
289
+ return [];
290
+ } catch (err) {
291
+ const message = err instanceof Error ? err.message : String(err);
292
+ return parseIssues(message);
293
+ }
294
+ }
295
+
296
+ function walkSchema(
297
+ schema: JsonSchema | undefined,
298
+ path: string
299
+ ): JsonSchema | undefined {
300
+ if (!schema) {
301
+ return undefined;
302
+ }
303
+ if (!path) {
304
+ return schema;
305
+ }
306
+ let current: JsonSchema | undefined = schema;
307
+ for (const part of path.split(".")) {
308
+ if (!current) {
309
+ return undefined;
310
+ }
311
+ if (current.properties && part in current.properties) {
312
+ current = current.properties[part];
313
+ continue;
314
+ }
315
+ if (current.items) {
316
+ current = Array.isArray(current.items)
317
+ ? current.items[Number(part)]
318
+ : current.items;
319
+ continue;
320
+ }
321
+ return undefined;
322
+ }
323
+ return current;
324
+ }
325
+
326
+ function walkValue(value: unknown, path: string): unknown {
327
+ if (!path) {
328
+ return value;
329
+ }
330
+ let current = value;
331
+ for (const part of path.split(".")) {
332
+ if (current === null || current === undefined) {
333
+ return undefined;
334
+ }
335
+ if (Array.isArray(current)) {
336
+ current = current[Number(part)];
337
+ } else if (isRecord(current)) {
338
+ current = current[part];
339
+ } else {
340
+ return undefined;
341
+ }
342
+ }
343
+ return current;
344
+ }
345
+
346
+ function isUnderPath(candidate: string, parent: string): boolean {
347
+ if (!parent) {
348
+ return candidate.length > 0;
349
+ }
350
+ return candidate.startsWith(`${parent}.`);
351
+ }
352
+
353
+ function joinPath(parent: string, child: string): string {
354
+ if (!parent) {
355
+ return child;
356
+ }
357
+ if (!child) {
358
+ return parent;
359
+ }
360
+ return `${parent}.${child}`;
361
+ }
362
+
363
+ function isRecord(value: unknown): value is Record<string, unknown> {
364
+ return typeof value === "object" && value !== null && !Array.isArray(value);
365
+ }
366
+
367
+ function formatIssue(issue: Issue, schema: JsonSchema): string {
368
+ const requiredMatch = issue.message.match(
369
+ /^must have required propert(?:y|ies) (.+)$/
370
+ );
371
+ if (requiredMatch) {
372
+ const props = requiredMatch[1]!;
373
+ const parent = issue.path.includes(".")
374
+ ? issue.path.slice(0, issue.path.lastIndexOf("."))
375
+ : "";
376
+ const where = parent ? ` at ${parent}` : "";
377
+ const noun = props.includes(",") ? "properties" : "property";
378
+ return `missing required ${noun}${where}: ${props}`;
379
+ }
380
+
381
+ if (issue.message === "must be equal to one of the allowed values") {
382
+ const node = walkSchema(schema, issue.path);
383
+ if (node?.enum && node.enum.length > 0) {
384
+ const values = node.enum.map(displayValue).join(", ");
385
+ return `${issue.path}: must be one of: ${values}`;
386
+ }
387
+ }
388
+
389
+ if (!issue.path) {
390
+ return issue.message;
391
+ }
392
+ return `${issue.path}: ${issue.message}`;
393
+ }
394
+
395
+ /**
396
+ * Recursively unwrap quoted enum values. Weaker models sometimes send
397
+ * `"\"create\""` instead of `"create"` because the JSON Schema and earlier
398
+ * error messages show enum values quoted. Only unwraps when the inner value is
399
+ * a valid enum/const match, so real typos still surface as errors.
400
+ */
401
+ function coerceQuotedEnums(
402
+ value: unknown,
403
+ schema: JsonSchema | undefined
404
+ ): unknown {
405
+ if (!schema) {
406
+ return value;
407
+ }
408
+
409
+ if (typeof value === "string") {
410
+ const allowed = collectAllowedStrings(schema);
411
+ if (allowed && allowed.length > 0 && !allowed.includes(value)) {
412
+ const unwrapped = stripWrappingQuotes(value);
413
+ if (unwrapped !== value && allowed.includes(unwrapped)) {
414
+ return unwrapped;
415
+ }
416
+ }
417
+ return value;
418
+ }
419
+
420
+ if (isRecord(value)) {
421
+ let mutated: Record<string, unknown> | undefined;
422
+ const propSchemas = schema.properties;
423
+ for (const key of Object.keys(value)) {
424
+ const subSchema = propSchemas?.[key];
425
+ const next = coerceQuotedEnums(value[key], subSchema);
426
+ if (next !== value[key]) {
427
+ mutated ??= { ...value };
428
+ mutated[key] = next;
429
+ }
430
+ }
431
+ if (schema.anyOf || schema.oneOf) {
432
+ const branches = (schema.anyOf ?? schema.oneOf) as readonly JsonSchema[];
433
+ const branch =
434
+ matchDiscriminatedBranch(branches, mutated ?? value) ??
435
+ branches.find((b) => b.type === "object" && b.properties);
436
+ if (branch) {
437
+ const recursed = coerceQuotedEnums(mutated ?? value, branch);
438
+ if (recursed !== (mutated ?? value)) {
439
+ return recursed;
440
+ }
441
+ }
442
+ }
443
+ return mutated ?? value;
444
+ }
445
+
446
+ if (Array.isArray(value)) {
447
+ const itemsField = schema.items;
448
+ if (!itemsField || Array.isArray(itemsField)) {
449
+ return value;
450
+ }
451
+ const itemSchema = itemsField as JsonSchema;
452
+ let mutated: unknown[] | undefined;
453
+ for (let i = 0; i < value.length; i++) {
454
+ const next = coerceQuotedEnums(value[i], itemSchema);
455
+ if (next !== value[i]) {
456
+ mutated ??= [...value];
457
+ mutated[i] = next;
458
+ }
459
+ }
460
+ return mutated ?? value;
461
+ }
462
+
463
+ return value;
464
+ }
465
+
466
+ function collectAllowedStrings(schema: JsonSchema): string[] | undefined {
467
+ if (schema.enum) {
468
+ const strings = schema.enum.filter(
469
+ (v): v is string => typeof v === "string"
470
+ );
471
+ return strings.length > 0 ? strings : undefined;
472
+ }
473
+ if (typeof schema.const === "string") {
474
+ return [schema.const];
475
+ }
476
+ const branches = schema.anyOf ?? schema.oneOf;
477
+ if (branches) {
478
+ const collected: string[] = [];
479
+ for (const branch of branches) {
480
+ const inner = collectAllowedStrings(branch);
481
+ if (inner) {
482
+ collected.push(...inner);
483
+ }
484
+ }
485
+ return collected.length > 0 ? collected : undefined;
486
+ }
487
+ return undefined;
488
+ }
489
+
490
+ function stripWrappingQuotes(value: string): string {
491
+ if (value.length < 2) {
492
+ return value;
493
+ }
494
+ const first = value[0]!;
495
+ const last = value[value.length - 1]!;
496
+ const quoteChars = ['"', "'", "`"];
497
+ if (quoteChars.includes(first) && first === last) {
498
+ return value.slice(1, -1);
499
+ }
500
+ return value;
501
+ }
502
+
503
+ /**
504
+ * Pi-ai intentionally coerces a lot of LLM-quirk inputs (`"42"` → 42,
505
+ * `"true"` → true, single value → array, etc.) so weak/cheap models don't
506
+ * fail on JSON shakiness. This is good. But two of those coercions are
507
+ * almost certainly silent bugs:
508
+ *
509
+ * - `null` → `0` / `""` / `false` / `"null"` for primitive fields. `null`
510
+ * is never a sensible value for a non-nullable primitive; treating it as
511
+ * the type's zero value hides the model's confusion.
512
+ * - `"42.5"` → `42` for an integer field. The float-shaped string means the
513
+ * model misunderstood the type; truncating loses information without
514
+ * recovering the intent.
515
+ *
516
+ * Reject both before pi's `Value.Convert` runs.
517
+ */
518
+ function checkStrictTypes(
519
+ value: unknown,
520
+ schema: JsonSchema | undefined,
521
+ path: string
522
+ ): string[] {
523
+ if (!schema) {
524
+ return [];
525
+ }
526
+
527
+ const types = collectSchemaTypes(schema);
528
+ if (types.length > 0 && !types.includes("null") && value === null) {
529
+ return [
530
+ `${path || "root"}: must not be null (expected ${types.join(" | ")})`,
531
+ ];
532
+ }
533
+
534
+ if (
535
+ types.includes("integer") &&
536
+ typeof value === "string" &&
537
+ /^-?\d+\.\d*[1-9]/.test(value)
538
+ ) {
539
+ return [
540
+ `${path || "root"}: must be an integer (received "${value}" — fractional part would be truncated)`,
541
+ ];
542
+ }
543
+
544
+ if (isRecord(value) && schema.properties) {
545
+ const issues: string[] = [];
546
+ for (const [key, sub] of Object.entries(value)) {
547
+ const subSchema = schema.properties[key];
548
+ if (subSchema) {
549
+ issues.push(...checkStrictTypes(sub, subSchema, joinPath(path, key)));
550
+ }
551
+ }
552
+ return issues;
553
+ }
554
+
555
+ if (Array.isArray(value)) {
556
+ const itemsField = schema.items;
557
+ if (!itemsField || Array.isArray(itemsField)) {
558
+ return [];
559
+ }
560
+ const itemSchema = itemsField as JsonSchema;
561
+ const issues: string[] = [];
562
+ for (let i = 0; i < value.length; i++) {
563
+ issues.push(
564
+ ...checkStrictTypes(value[i], itemSchema, joinPath(path, String(i)))
565
+ );
566
+ }
567
+ return issues;
568
+ }
569
+
570
+ return [];
571
+ }
572
+
573
+ function collectSchemaTypes(schema: JsonSchema): string[] {
574
+ const types = new Set<string>();
575
+ if (typeof schema.type === "string") {
576
+ types.add(schema.type);
577
+ }
578
+ if (Array.isArray(schema.type)) {
579
+ for (const t of schema.type) {
580
+ if (typeof t === "string") {
581
+ types.add(t);
582
+ }
583
+ }
584
+ }
585
+ const branches = schema.anyOf ?? schema.oneOf;
586
+ if (branches) {
587
+ for (const b of branches) {
588
+ for (const t of collectSchemaTypes(b)) {
589
+ types.add(t);
590
+ }
591
+ }
592
+ }
593
+ return Array.from(types);
594
+ }
595
+
596
+ function findUnknownTopLevelKeys(schema: JsonSchema, args: unknown): string[] {
597
+ if (schema.type !== "object" || !schema.properties || !isRecord(args)) {
598
+ return [];
599
+ }
600
+ const known = new Set(Object.keys(schema.properties));
601
+ return Object.keys(args).filter((key) => !known.has(key));
602
+ }
603
+
604
+ function formatUnknownKeysError(
605
+ toolName: string,
606
+ schema: JsonSchema,
607
+ unknownKeys: readonly string[]
608
+ ): string {
609
+ const known = schema.properties ? Object.keys(schema.properties) : [];
610
+ const lines = unknownKeys.map((key) => {
611
+ const suggestion = closestKey(key, known);
612
+ const hint = suggestion ? ` (did you mean "${suggestion}"?)` : "";
613
+ return ` - unknown property: ${key}${hint}`;
614
+ });
615
+ return `Validation failed for tool "${toolName}":\n${lines.join("\n")}`;
616
+ }
617
+
618
+ function closestKey(
619
+ key: string,
620
+ candidates: readonly string[]
621
+ ): string | undefined {
622
+ const lowered = key.toLowerCase();
623
+ let best: { key: string; distance: number } | undefined;
624
+ for (const candidate of candidates) {
625
+ if (candidate.toLowerCase() === lowered) {
626
+ return candidate;
627
+ }
628
+ const d = Levenshtein.distance(lowered, candidate.toLowerCase());
629
+ if (d <= Math.max(2, Math.floor(candidate.length / 3))) {
630
+ if (!best || d < best.distance) {
631
+ best = { key: candidate, distance: d };
632
+ }
633
+ }
634
+ }
635
+ return best?.key;
636
+ }