From c1d98c67a303eec8d71d13c320bae884d4386b69 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Tue, 3 Nov 2026 09:23:38 +0530 Subject: [PATCH 1/3] refactor(pi-ask): replace typebox with @earendil-works/pi-schema --- pi-ask/package-lock.json | 23 ++-- pi-ask/package.json | 7 +- pi-ask/src/answer-extraction.ts | 8 +- pi-ask/src/config/schema.ts | 213 +++++++++++++++++++------------- pi-ask/src/config/store.ts | 2 +- pi-ask/src/schema.ts | 179 ++++++++++++++++----------- pi-ask/tests/ask-tool.test.ts | 15 ++- pi-ask/tests/contract.test.ts | 8 +- 8 files changed, 272 insertions(+), 183 deletions(-) diff --git a/pi-ask/package-lock.json b/pi-ask/package-lock.json index ce63b6e1..c0c5f41f 100644 --- a/pi-ask/package-lock.json +++ b/pi-ask/package-lock.json @@ -8,19 +8,20 @@ "name": "@gagansd/pi-ask", "version": "0.1.5", "license": "MIT", + "dependencies": { + "@earendil-works/pi-schema": "1.5.0" + }, "devDependencies": { "@earendil-works/pi-ai": "^1.4.0", "@earendil-works/pi-coding-agent": "^1.4.0", "@earendil-works/pi-tui": "^1.4.0", "@types/node": "26.6.5", - "typebox": "^1.4.3", "typescript": "7.0.2" }, "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-tui": "*" } }, "node_modules/@anthropic-ai/sdk": { @@ -539,6 +540,15 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-schema": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-schema/-/pi-schema-1.5.0.tgz", + "integrity": "sha512-RW0MbT2tR65OY6xMsgB/yVBb7m/RTNwA+9hurSN1vVXfHZRVmVnXbruBPrx/PzbrQnW5yLVlMRWxErGDQtF1Hg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-telemetry": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz", @@ -1902,13 +1912,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/typebox": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.4.3.tgz", - "integrity": "sha512-u3nyf7ZciAv1GOijVpI0TDt9At5wmEi7lSHSlRrnjuThv1DcB7fgGBqqrz+fGZ54jAfTpOOX0JXwxSIg3pnxQA==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/pi-ask/package.json b/pi-ask/package.json index 6ea8874f..4b27fe28 100644 --- a/pi-ask/package.json +++ b/pi-ask/package.json @@ -43,11 +43,13 @@ "pack:check": "npm pack --dry-run", "prepublishOnly": "npm test && npm run typecheck" }, + "dependencies": { + "@earendil-works/pi-schema": "1.5.0" + }, "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-tui": "*" }, "overrides": { "undici": "8.12.2" @@ -57,7 +59,6 @@ "@earendil-works/pi-coding-agent": "^1.4.0", "@earendil-works/pi-tui": "^1.4.0", "@types/node": "26.6.5", - "typebox": "^1.4.3", "typescript": "7.0.2" }, "pi": { diff --git a/pi-ask/src/answer-extraction.ts b/pi-ask/src/answer-extraction.ts index 04de737c..ff168d9e 100644 --- a/pi-ask/src/answer-extraction.ts +++ b/pi-ask/src/answer-extraction.ts @@ -8,7 +8,7 @@ import { type UserMessage, } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { Value } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import type { AskConfig } from "./config/schema.ts"; import { AnswerExtractionParamsSchema } from "./schema.ts"; import { collectValidationIssues } from "./state/normalize.ts"; @@ -16,6 +16,8 @@ import type { AskParams } from "./types.ts"; const ANSWER_EXTRACTION_TOOL_NAME = "ask_user"; +const validateAnswerExtractionParams = compile(AnswerExtractionParamsSchema); + const ANSWER_EXTRACTION_TOOL = { name: ANSWER_EXTRACTION_TOOL_NAME, description: @@ -223,11 +225,11 @@ export function parseExtractionCandidate( } function parseExtractionValue(value: unknown): ParsedExtraction { - const [shapeIssue] = Value.Errors(AnswerExtractionParamsSchema, value); + const [shapeIssue] = validateAnswerExtractionParams.errors(value); if (shapeIssue) { return { ok: false, - error: `${shapeIssue.instancePath || "root"} ${shapeIssue.message}`, + error: `${shapeIssue.path || "root"} ${shapeIssue.message}`, }; } const params = value as AskParams; diff --git a/pi-ask/src/config/schema.ts b/pi-ask/src/config/schema.ts index a8eb34b7..abf90b9b 100644 --- a/pi-ask/src/config/schema.ts +++ b/pi-ask/src/config/schema.ts @@ -1,94 +1,135 @@ -import { type Static, Type } from "typebox"; -import { Compile } from "typebox/compile"; +import { + arr, + bool, + compile, + lit, + num, + obj, + opt, + str, + union, + type Static, +} from "@earendil-works/pi-schema"; -const AskAnswerModelPreferenceSchema = Type.Object({ - id: Type.String(), - provider: Type.String(), -}); +const AskAnswerModelPreferenceSchema = obj( + { + id: str(), + provider: str(), + }, + { open: true } +); -const AskNotificationChannelSchema = Type.Union([ - Type.Literal("bell"), - Type.Literal("osc9"), - Type.Literal("osc777"), - Type.Object({ - command: Type.String(), - type: Type.Literal("command"), - }), +const AskNotificationChannelSchema = union([ + lit("bell"), + lit("osc9"), + lit("osc777"), + obj( + { + command: str(), + type: lit("command"), + }, + { open: true } + ), ]); -const AskKeyBindingSchema = Type.Union([ - Type.String(), - Type.Array(Type.String()), -]); +const AskKeyBindingSchema = union([str(), arr(str())]); -const AskConfigKeymapsSchema = Type.Object({ - global: Type.Object({ - dismiss: Type.Optional(AskKeyBindingSchema), - settings: Type.Optional(AskKeyBindingSchema), - }), - main: Type.Object({ - cancel: Type.Optional(AskKeyBindingSchema), - changeQuestionType: Type.Optional(AskKeyBindingSchema), - confirm: Type.Optional(AskKeyBindingSchema), - nextOption: Type.Optional(AskKeyBindingSchema), - nextTab: Type.Optional(AskKeyBindingSchema), - optionNote: Type.Optional(AskKeyBindingSchema), - previousOption: Type.Optional(AskKeyBindingSchema), - previousTab: Type.Optional(AskKeyBindingSchema), - questionNote: Type.Optional(AskKeyBindingSchema), - toggle: Type.Optional(AskKeyBindingSchema), - }), - editor: Type.Object({ - close: Type.Optional(AskKeyBindingSchema), - nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), - nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema), - previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), - previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema), - submit: Type.Optional(AskKeyBindingSchema), - }), - noteEditor: Type.Object({ - close: Type.Optional(AskKeyBindingSchema), - nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), - nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema), - previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), - previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema), - save: Type.Optional(AskKeyBindingSchema), - }), - settingsModal: Type.Object({ - close: Type.Optional(AskKeyBindingSchema), - nextOption: Type.Optional(AskKeyBindingSchema), - previousOption: Type.Optional(AskKeyBindingSchema), - toggle: Type.Optional(AskKeyBindingSchema), - }), -}); +const AskConfigKeymapsSchema = obj( + { + global: obj( + { + dismiss: opt(AskKeyBindingSchema), + settings: opt(AskKeyBindingSchema), + }, + { open: true } + ), + main: obj( + { + cancel: opt(AskKeyBindingSchema), + changeQuestionType: opt(AskKeyBindingSchema), + confirm: opt(AskKeyBindingSchema), + nextOption: opt(AskKeyBindingSchema), + nextTab: opt(AskKeyBindingSchema), + optionNote: opt(AskKeyBindingSchema), + previousOption: opt(AskKeyBindingSchema), + previousTab: opt(AskKeyBindingSchema), + questionNote: opt(AskKeyBindingSchema), + toggle: opt(AskKeyBindingSchema), + }, + { open: true } + ), + editor: obj( + { + close: opt(AskKeyBindingSchema), + nextOptionWhenEmpty: opt(AskKeyBindingSchema), + nextTabWhenEmpty: opt(AskKeyBindingSchema), + previousOptionWhenEmpty: opt(AskKeyBindingSchema), + previousTabWhenEmpty: opt(AskKeyBindingSchema), + submit: opt(AskKeyBindingSchema), + }, + { open: true } + ), + noteEditor: obj( + { + close: opt(AskKeyBindingSchema), + nextOptionWhenEmpty: opt(AskKeyBindingSchema), + nextTabWhenEmpty: opt(AskKeyBindingSchema), + previousOptionWhenEmpty: opt(AskKeyBindingSchema), + previousTabWhenEmpty: opt(AskKeyBindingSchema), + save: opt(AskKeyBindingSchema), + }, + { open: true } + ), + settingsModal: obj( + { + close: opt(AskKeyBindingSchema), + nextOption: opt(AskKeyBindingSchema), + previousOption: opt(AskKeyBindingSchema), + toggle: opt(AskKeyBindingSchema), + }, + { open: true } + ), + }, + { open: true } +); -export const AskConfigFileSchema = Type.Object({ - schemaVersion: Type.Optional(Type.Number()), - answer: Type.Optional( - Type.Object({ - extractionModels: Type.Optional( - Type.Array(AskAnswerModelPreferenceSchema) - ), - extractionRetries: Type.Optional(Type.Number()), - extractionTimeoutMs: Type.Optional(Type.Number()), - }) - ), - behaviour: Type.Optional( - Type.Object({ - autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()), - confirmDismissWhenDirty: Type.Optional(Type.Boolean()), - doublePressReviewShortcuts: Type.Optional(Type.Boolean()), - showFooterHints: Type.Optional(Type.Boolean()), - }) - ), - keymaps: Type.Optional(AskConfigKeymapsSchema), - notifications: Type.Optional( - Type.Object({ - channels: Type.Optional(Type.Array(AskNotificationChannelSchema)), - enabled: Type.Optional(Type.Boolean()), - }) - ), -}); +export const AskConfigFileSchema = obj( + { + schemaVersion: opt(num()), + answer: opt( + obj( + { + extractionModels: opt(arr(AskAnswerModelPreferenceSchema)), + extractionRetries: opt(num()), + extractionTimeoutMs: opt(num()), + }, + { open: true } + ) + ), + behaviour: opt( + obj( + { + autoSubmitWhenAnsweredWithoutNotes: opt(bool()), + confirmDismissWhenDirty: opt(bool()), + doublePressReviewShortcuts: opt(bool()), + showFooterHints: opt(bool()), + }, + { open: true } + ) + ), + keymaps: opt(AskConfigKeymapsSchema), + notifications: opt( + obj( + { + channels: opt(arr(AskNotificationChannelSchema)), + enabled: opt(bool()), + }, + { open: true } + ) + ), + }, + { open: true } +); export type AskConfigFile = Static; export type AskAnswerModelPreference = Static< @@ -156,4 +197,4 @@ export interface AskConfig { }; } -export const validateAskConfigFile = Compile(AskConfigFileSchema); +export const validateAskConfigFile = compile(AskConfigFileSchema); diff --git a/pi-ask/src/config/store.ts b/pi-ask/src/config/store.ts index 45dd1e3a..01bca5ca 100644 --- a/pi-ask/src/config/store.ts +++ b/pi-ask/src/config/store.ts @@ -88,7 +88,7 @@ export class AskConfigStore { } const parsed = parseJson(content); - if (!parsed.ok || !validateAskConfigFile.Check(parsed.value)) { + if (!parsed.ok || !validateAskConfigFile.check(parsed.value)) { return this.loadDefaultsWithNotice(INVALID_CONFIG_NOTICE); } diff --git a/pi-ask/src/schema.ts b/pi-ask/src/schema.ts index f954e04d..411ce1a6 100644 --- a/pi-ask/src/schema.ts +++ b/pi-ask/src/schema.ts @@ -1,86 +1,123 @@ -import { StringEnum } from "@earendil-works/pi-ai"; -import { Type } from "typebox"; +import { + arr, + bool, + obj, + opt, + str, + type Schema, +} from "@earendil-works/pi-schema"; -export const AskOptionSchema = Type.Object({ - value: Type.String({ - description: - "Required machine-readable value returned for this option in the result", - }), - label: Type.String({ - description: "Required short visible option label shown in the list", - }), - description: Type.Optional( - Type.String({ - description: "Optional one-line explanation to help the user choose", - }) - ), - recommended: Type.Optional( - Type.Boolean({ - description: - "Optional presentation marker for a grounded preference; use the description to explain the reason", - }) - ), +/** `{ type: "string", enum }`: Google-compatible string enum, identical JSON to pi-ai's StringEnum. */ +export function stringEnum( + values: T, + options?: { description?: string; default?: T[number] }, +): Schema { + return { + type: "string", + enum: [...values], + ...(options?.description ? { description: options.description } : {}), + }; +} + +const AskQuestionLabelSchema = str({ + description: "Short tab label, e.g. Goal, Audience, Tone, Scope", }); -export const AskQuestionSchema = Type.Object({ - id: Type.String({ - description: - "Required stable question identifier used as the key in returned answers", - }), - label: Type.Optional( - Type.String({ - description: "Short tab label, e.g. Goal, Audience, Tone, Scope", - }) - ), - prompt: Type.String({ - description: - "Required direct question shown to the user; ask about one decision at a time", - }), - type: Type.Optional( - StringEnum(["single", "multi", "text"] as const, { +const AskQuestionTypeSchema = stringEnum(["single", "multi", "text"] as const, { + description: + "Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `text` means the user should type an unconstrained answer. `text` questions have no options.", +}); + +export const AskOptionSchema = obj( + { + value: str({ description: - "Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `text` means the user should type an unconstrained answer. `text` questions have no options.", - }) - ), - options: Type.Optional( - Type.Array(AskOptionSchema, { + "Required machine-readable value returned for this option in the result", + }), + label: str({ + description: "Required short visible option label shown in the list", + }), + description: opt( + str({ + description: "Optional one-line explanation to help the user choose", + }) + ), + recommended: opt( + bool({ + description: + "Optional presentation marker for a grounded preference; use the description to explain the reason", + }) + ), + }, + { open: true } +); + +export const AskQuestionSchema = obj( + { + id: str({ description: - "Answer options for single/multi questions; provide clear, distinct choices and do not add filler options. Omit for text questions.", - }) - ), -}); + "Required stable question identifier used as the key in returned answers", + }), + label: opt(AskQuestionLabelSchema), + prompt: str({ + description: + "Required direct question shown to the user; ask about one decision at a time", + }), + type: opt(AskQuestionTypeSchema), + options: opt( + arr(AskOptionSchema, { + description: + "Answer options for single/multi questions; provide clear, distinct choices and do not add filler options. Omit for text questions.", + }) + ), + }, + { open: true } +); -export const AskParamsSchema = Type.Object({ - title: Type.Optional( - Type.String({ +const AskParamsProperties = { + title: opt( + str({ description: "Optional short title shown above the clarification flow, e.g. README direction", }) ), - questions: Type.Array(AskQuestionSchema, { - description: "Questions to ask in the interactive clarification flow", - }), -}); +}; -const AnswerExtractionQuestionSchema = Type.Object({ - id: Type.String({ description: "Stable snake_case question identifier" }), - label: AskQuestionSchema.properties.label, - prompt: Type.String({ description: "Direct question shown to the user" }), - type: AskQuestionSchema.properties.type, - options: Type.Optional( - Type.Array(AskOptionSchema, { - description: - "Choices explicitly offered by the assistant. Omit for text questions.", - }) - ), -}); +export const AskParamsSchema = obj( + { + ...AskParamsProperties, + questions: arr(AskQuestionSchema, { + description: "Questions to ask in the interactive clarification flow", + }), + }, + { open: true } +); -export const AnswerExtractionParamsSchema = Type.Object({ - ...AskParamsSchema.properties, - questions: Type.Array(AnswerExtractionQuestionSchema, { - description: "Questions extracted from the assistant message", - }), -}); +const AnswerExtractionQuestionSchema = obj( + { + id: str({ description: "Stable snake_case question identifier" }), + label: opt(AskQuestionLabelSchema), + prompt: str({ description: "Direct question shown to the user" }), + type: opt(AskQuestionTypeSchema), + options: opt( + arr(AskOptionSchema, { + description: + "Choices explicitly offered by the assistant. Omit for text questions.", + }) + ), + }, + { open: true } +); + +export const AnswerExtractionParamsSchema = obj( + { + ...AskParamsProperties, + questions: arr(AnswerExtractionQuestionSchema, { + description: "Questions extracted from the assistant message", + }), + }, + { open: true } +); /** * Model-facing copy of a schema with per-field `description` prose removed. diff --git a/pi-ask/tests/ask-tool.test.ts b/pi-ask/tests/ask-tool.test.ts index a042e323..54750129 100644 --- a/pi-ask/tests/ask-tool.test.ts +++ b/pi-ask/tests/ask-tool.test.ts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { Value } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import { registerAskTool } from "../src/ask-tool.ts"; import { AskOptionSchema, AskParamsSchema } from "../src/schema.ts"; +const checkAskOption = compile(AskOptionSchema); +const checkAskParams = compile(AskParamsSchema); + const NON_INTERACTIVE_MESSAGE_RE = /Needs user input: ask_user requires interactive TUI mode\./; const FIRST_QUESTION_RE = /1\. Goal: What should I optimize for\?/; @@ -106,7 +109,7 @@ test("ask option schema and tool guidance support grounded recommendations", () const { tool } = registerMockTool(); assert.equal( - Value.Check(AskOptionSchema, { + checkAskOption.check({ value: "small", label: "Small", description: "Lowest implementation risk", @@ -139,7 +142,7 @@ test("registered schema strips prose but keeps the description field", () => { test("ask params schema constrains question types", () => { assert.equal( - Value.Check(AskParamsSchema, { + checkAskParams.check({ questions: [ { id: "layout", @@ -152,7 +155,7 @@ test("ask params schema constrains question types", () => { false ); assert.equal( - Value.Check(AskParamsSchema, { + checkAskParams.check({ questions: [ { id: "dir", @@ -167,10 +170,10 @@ test("ask params schema constrains question types", () => { test("public schema requires semantic identifiers and labels", () => { assert.equal( - Value.Check(AskOptionSchema, { value: "fast", label: "Fast" }), + checkAskOption.check({ value: "fast", label: "Fast" }), true ); - assert.equal(Value.Check(AskOptionSchema, { value: "fast" }), false); + assert.equal(checkAskOption.check({ value: "fast" }), false); }); test("ask tool returns pending questions in non-interactive mode", async () => { diff --git a/pi-ask/tests/contract.test.ts b/pi-ask/tests/contract.test.ts index c56d5f25..70db4767 100644 --- a/pi-ask/tests/contract.test.ts +++ b/pi-ask/tests/contract.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Value } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import { registerAnswerCommands } from "../src/answer-commands.ts"; import { collectExtractionBusinessIssues } from "../src/answer-extraction.ts"; import { registerAskSettingsCommand } from "../src/ask-settings-command.ts"; @@ -19,6 +19,8 @@ import { } from "../src/state/transitions.ts"; import type { AskParams } from "../src/types.ts"; +const checkAskParams = compile(AskParamsSchema); + function registerMockTool() { const tools: Record[] = []; registerAskTool({ @@ -37,12 +39,12 @@ function registerMockTool() { test("registers ask_user", () => { const tool = registerMockTool(); assert.equal(tool.name, "ask_user"); - assert.equal(Value.Check(AskParamsSchema, { questions: [] }), true); + assert.equal(checkAskParams.check({ questions: [] }), true); }); test("schema accepts text questions and rejects preview and required", () => { assert.equal( - Value.Check(AskParamsSchema, { + checkAskParams.check({ questions: [{ id: "dir", prompt: "Which directory?", type: "text" }], }), true From b46b8e1d485034c188aecd4c97e55bec81c988ef Mon Sep 17 00:00:00 2001 From: GaganSD Date: Tue, 3 Nov 2026 09:28:24 +0530 Subject: [PATCH 2/3] refactor(pi-ask): drop unused default option from string enum helper --- pi-ask/src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pi-ask/src/schema.ts b/pi-ask/src/schema.ts index 411ce1a6..c8f43d6a 100644 --- a/pi-ask/src/schema.ts +++ b/pi-ask/src/schema.ts @@ -10,7 +10,7 @@ import { /** `{ type: "string", enum }`: Google-compatible string enum, identical JSON to pi-ai's StringEnum. */ export function stringEnum( values: T, - options?: { description?: string; default?: T[number] }, + options?: { description?: string }, ): Schema { return { type: "string", From efe3dba5fc677f224d08cb3efa3efd7f38ba4065 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Tue, 3 Nov 2026 09:51:09 +0530 Subject: [PATCH 3/3] refactor(pi-ask): keep string enum helper module-local --- pi-ask/src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pi-ask/src/schema.ts b/pi-ask/src/schema.ts index c8f43d6a..c3f49e06 100644 --- a/pi-ask/src/schema.ts +++ b/pi-ask/src/schema.ts @@ -8,7 +8,7 @@ import { } from "@earendil-works/pi-schema"; /** `{ type: "string", enum }`: Google-compatible string enum, identical JSON to pi-ai's StringEnum. */ -export function stringEnum( +function stringEnum( values: T, options?: { description?: string }, ): Schema {