diff --git a/.changeset/khaki-plums-mate.md b/.changeset/khaki-plums-mate.md new file mode 100644 index 0000000..e2a525f --- /dev/null +++ b/.changeset/khaki-plums-mate.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Options tool for defining user-configurable options for twists diff --git a/twister/package.json b/twister/package.json index 2e1816d..91445ac 100644 --- a/twister/package.json +++ b/twister/package.json @@ -35,6 +35,11 @@ "types": "./dist/tag.d.ts", "default": "./dist/tag.js" }, + "./options": { + "@plotday/source": "./src/options.ts", + "types": "./dist/options.d.ts", + "default": "./dist/options.js" + }, "./tools/twists": { "@plotday/source": "./src/tools/twists.ts", "types": "./dist/tools/twists.d.ts", diff --git a/twister/src/index.ts b/twister/src/index.ts index 0a5d181..a0bc135 100644 --- a/twister/src/index.ts +++ b/twister/src/index.ts @@ -3,5 +3,6 @@ export * from "./plot"; export * from "./tag"; export * from "./tool"; export * from "./tools"; +export * from "./options"; export * from "./utils/types"; export { getBuilderDocumentation } from "./creator-docs"; diff --git a/twister/src/options.ts b/twister/src/options.ts new file mode 100644 index 0000000..6d23c04 --- /dev/null +++ b/twister/src/options.ts @@ -0,0 +1,112 @@ +import { ITool } from "./tool"; + +/** + * A select option definition for twist configuration. + * Renders as a dropdown in the Flutter UI. + */ +export type SelectDef = { + type: "select"; + label: string; + description?: string; + choices: ReadonlyArray<{ value: string; label: string }>; + default: string; +}; + +/** + * A text input option definition for twist configuration. + * Renders as a text field in the Flutter UI. + */ +export type TextDef = { + type: "text"; + label: string; + description?: string; + default: string; + placeholder?: string; +}; + +/** + * A number input option definition for twist configuration. + * Renders as a number field in the Flutter UI. + */ +export type NumberDef = { + type: "number"; + label: string; + description?: string; + default: number; + min?: number; + max?: number; +}; + +/** + * A boolean toggle option definition for twist configuration. + * Renders as a switch in the Flutter UI. + */ +export type BooleanDef = { + type: "boolean"; + label: string; + description?: string; + default: boolean; +}; + +/** + * Union of all option definition types. + */ +export type OptionDef = SelectDef | TextDef | NumberDef | BooleanDef; + +/** + * Schema defining all configurable options for a twist. + * Each key maps to an option definition that describes its type, label, and default value. + */ +export type OptionsSchema = Record; + +/** + * Infers the resolved value types from an options schema. + * Boolean options resolve to `boolean`, number options to `number`, + * and select/text options to `string`. + */ +export type ResolvedOptions = { + [K in keyof T]: T[K] extends BooleanDef + ? boolean + : T[K] extends NumberDef + ? number + : string; +}; + +/** + * Built-in marker class for twist configuration options. + * + * Declare options in your twist's `build()` method to expose configurable + * settings to users. The schema is introspected at deploy time and stored + * alongside permissions. At runtime, user values are merged with defaults. + * + * @example + * ```typescript + * import { Options, type OptionsSchema } from "@plotday/twister/options"; + * + * export default class MyTwist extends Twist { + * build(build: ToolBuilder) { + * return { + * options: build(Options, { + * model: { + * type: 'select', + * label: 'AI Model', + * choices: [ + * { value: 'fast', label: 'Fast' }, + * { value: 'smart', label: 'Smart' }, + * ], + * default: 'fast', + * }, + * }), + * // ... other tools + * }; + * } + * + * async respond(note: Note) { + * const model = this.tools.options.model; // typed as string + * } + * } + * ``` + */ +export abstract class Options extends ITool { + static readonly toolId = "Options"; +} diff --git a/twister/src/utils/types.ts b/twister/src/utils/types.ts index 52b8fcd..369624e 100644 --- a/twister/src/utils/types.ts +++ b/twister/src/utils/types.ts @@ -8,6 +8,7 @@ * * @internal */ +import type { Options, OptionsSchema, ResolvedOptions } from "../options"; import type { Callbacks } from "../tools/callbacks"; import type { Store } from "../tools/store"; import type { Tasks } from "../tools/tasks"; @@ -63,10 +64,16 @@ export type InferOptions = T extends { * Function type for building tool dependencies. * Used in build methods to request tool instances. */ -export type ToolBuilder = any>( - ToolClass: TC, - options?: InferOptions -) => Promise>; +export type ToolBuilder = { + ( + ToolClass: typeof Options, + schema: T + ): Promise>; + any>( + ToolClass: TC, + options?: InferOptions + ): Promise>; +}; /** * Interface for managing tool initialization and lifecycle. diff --git a/twists/chat/src/index.ts b/twists/chat/src/index.ts index 7e8b308..d455761 100644 --- a/twists/chat/src/index.ts +++ b/twists/chat/src/index.ts @@ -9,12 +9,28 @@ import { type ToolBuilder, Twist, } from "@plotday/twister"; -import { AI, type AIMessage } from "@plotday/twister/tools/ai"; +import { Options } from "@plotday/twister/options"; +import { AI, type AIMessage, AIModel } from "@plotday/twister/tools/ai"; import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; export default class ChatTwist extends Twist { build(build: ToolBuilder) { return { + options: build(Options, { + model: { + type: "select" as const, + label: "AI Model", + description: "The AI model used for chat responses", + choices: [ + { value: "anthropic/claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, + { value: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5 (Fast)" }, + { value: "openai/gpt-5", label: "GPT-5" }, + { value: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { value: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash (Fast)" }, + ], + default: "anthropic/claude-sonnet-4-5", + }, + }), ai: build(AI), plot: build(Plot, { activity: { @@ -111,7 +127,7 @@ You can provide either or both inline and standalone links. Only use standalone }); const response = await this.tools.ai.prompt({ - model: { speed: "balanced", cost: "medium" }, + model: { speed: "balanced", cost: "medium", hint: this.tools.options.model as AIModel }, messages, outputSchema: schema, });