diff --git a/.changeset/cold-lands-help.md b/.changeset/cold-lands-help.md new file mode 100644 index 0000000..60022a5 --- /dev/null +++ b/.changeset/cold-lands-help.md @@ -0,0 +1,5 @@ +--- +"stack-effect": patch +--- + +fix strip cross-target module from autoselect diff --git a/.changeset/lazy-corners-win.md b/.changeset/lazy-corners-win.md new file mode 100644 index 0000000..8a7c04d --- /dev/null +++ b/.changeset/lazy-corners-win.md @@ -0,0 +1,11 @@ +--- +"stack-effect": minor +--- + +add new Toolkits for the ai module + +- **DateTimeToolkit** <- provides functions for working with dates and times +- **MathToolkit** <- provides functions for mathematical operations +- **MemoryToolkit** <- provides functions for working with memory and data storage +- **PlanToolkit** <- provides functions for creating and managing plans and tasks +- **WebFetchToolkit** <- provides functions for making web requests and fetching data from the internet diff --git a/apps/cli/src/commands/add.ts b/apps/cli/src/commands/add.ts index 6f22c0d..a279fec 100644 --- a/apps/cli/src/commands/add.ts +++ b/apps/cli/src/commands/add.ts @@ -101,6 +101,14 @@ const buildModuleTree = ( Effect.gen(function* () { const catalog = yield* CatalogService; + // Collect all module IDs that appear as children of other modules + // so they are excluded from the top-level list (they appear nested instead) + const childModuleIds = new Set( + Arr.flatMap(modules, (mod) => + Arr.map(mod.children ?? [], (child) => child.moduleId), + ), + ); + // Build tree node recursively, following dependencies and implies // visited is a Ref for mutable tracking across sibling branches const buildNode = ( @@ -131,7 +139,9 @@ const buildModuleTree = ( // Mark as visited before processing children yield* Ref.update(visitedRef, (s) => new Set([...s, mod.id])); - // Process required dependencies + // Process required dependencies (cross-target required-module deps) + // These are included in the tree for visibility, but are filtered out + // when building the Selection (BlueprintService resolves them automatically). const depChildren = yield* pipe( mod.dependencies, Arr.filter( @@ -242,7 +252,12 @@ const buildModuleTree = ( }); // Build tree for each top-level module (each with fresh visited Ref) - return yield* Effect.forEach(modules, (mod) => + // Exclude modules that are children of other modules in this set + const topLevelModules = Arr.filter( + modules, + (mod) => !childModuleIds.has(mod.id), + ); + return yield* Effect.forEach(topLevelModules, (mod) => Effect.gen(function* () { const visitedRef = yield* Ref.make(new Set()); const result = yield* buildNode(mod, "root", visitedRef); @@ -817,6 +832,7 @@ export const add = Command.make( Effect.gen(function* () { const configure = yield* ConfigureService; const pipeline = yield* ScaffoldPipeline; + const catalog = yield* CatalogService; const repoRoot = Option.getOrElse(flags.root, () => process.cwd()); @@ -842,11 +858,83 @@ export const add = Command.make( ) : yield* collectTargetsInteractive; - // Build selection + // Build selection, routing each module to the target it's supported on. + // Cross-target module IDs that appeared in the TUI tree (e.g., toolkit + // children of a parent on another target) are placed on the correct target. + // BlueprintService will also resolve required-module deps automatically. + const selectionTargets = new Map< + string, + { identity: TargetIdentity; modules: Set } + >(); + + // Seed with collected targets + for (const t of collected) { + const identity = new TargetIdentity({ kind: t.kind, name: t.name }); + selectionTargets.set(identity.toKey(), { + identity, + modules: new Set(), + }); + } + + // Route each module to its supported target + yield* Effect.forEach(collected, (t) => + Effect.gen(function* () { + const identity = new TargetIdentity({ kind: t.kind, name: t.name }); + yield* Effect.forEach(Arr.dedupe(t.modules), (id) => + Effect.gen(function* () { + // Check if supported on the collected target first + const ownSupported = yield* catalog + .isSupportedOn(id, identity) + .pipe(Effect.orElseSucceed(() => false)); + if (ownSupported) { + selectionTargets.get(identity.toKey())!.modules.add(id); + return; + } + + // Find which target this module belongs to + const mod = yield* catalog + .getModule(id) + .pipe(Effect.orElseSucceed(() => null)); + if (!mod) return; + + for (const rule of mod.supportedOn) { + if (rule._tag === "identity") { + const targetKey = new TargetIdentity(rule.identity).toKey(); + if (!selectionTargets.has(targetKey)) { + selectionTargets.set(targetKey, { + identity: new TargetIdentity(rule.identity), + modules: new Set(), + }); + } + selectionTargets.get(targetKey)!.modules.add(id); + return; + } + if (rule._tag === "kind") { + // Find existing target of this kind + const existing = Arr.findFirst(collected, (c) => + c.kind === rule.kind ? true : false, + ); + if (Option.isSome(existing)) { + const key = new TargetIdentity({ + kind: existing.value.kind, + name: existing.value.name, + }).toKey(); + selectionTargets.get(key)!.modules.add(id); + return; + } + } + } + }), + ); + }), + ); + const selection: typeof Selection.Type = { - targets: Arr.map(collected, (t) => ({ - identity: new TargetIdentity({ kind: t.kind, name: t.name }), - modules: Arr.map(t.modules, (id) => ({ id })), + targets: Arr.fromIterable(selectionTargets.values()).map((entry) => ({ + identity: entry.identity, + modules: Arr.fromIterable(entry.modules).map((id) => ({ + id: id as typeof ModuleId.Type, + })), })), }; diff --git a/packages/catalog/src/registry/content/ai.ts b/packages/catalog/src/registry/content/ai.ts index 798d674..d286c9b 100644 --- a/packages/catalog/src/registry/content/ai.ts +++ b/packages/catalog/src/registry/content/ai.ts @@ -21,25 +21,27 @@ export const FastModelLive = AnthropicLanguageModel.layer({ `; // Think Toolkit - minimal required toolkit for ChatService -export const aiThinkToolkitContents = `import { Effect, Schema } from "effect"; +export const aiThinkToolkitContents = `import { Effect, Schema, String } from "effect"; import { Tool, Toolkit } from "effect/unstable/ai"; -/** - * Think Tool - Allows the AI to reason through complex problems step-by-step. - * This is a minimal tool that simply returns the thought, enabling the model - * to "think out loud" without requiring external computation. - */ const thinkTool = Tool.make("think", { - description: - "Use this tool to think through a problem step-by-step before responding. " + - "Output your reasoning process. This helps with complex tasks that require " + - "multi-step reasoning. Example: think(thought: 'Let me break this down...')", + description: String.stripMargin(\` + |Use when facing ambiguous or multi-step problems where reasoning before + |acting will improve accuracy. The thought is recorded but not shown to + |the user.\`), parameters: Schema.Struct({ thought: Schema.String, }), success: Schema.String, }); +/** + * Allows the model to reason through complex problems step-by-step. + * Returns the thought as-is, enabling "thinking out loud" without + * affecting the external state. + * + * @module + */ export const ThinkToolkit = Toolkit.make(thinkTool); export const ThinkToolkitLive = ThinkToolkit.toLayer( @@ -118,111 +120,560 @@ export const ChatServiceLive = Layer.effect(ChatService)(ChatService.make).pipe( ); `; -export const aiSampleToolkitContents = `import { Data, DateTime, Effect, Schema } from "effect"; +// DateTime Toolkit - timezone-aware date/time for agents +export const aiDateTimeToolkitContents = `import { DateTime, Effect, Option, Schema, String } from "effect"; import { Tool, Toolkit } from "effect/unstable/ai"; -class CalculatorError extends Data.TaggedError("CalculatorError")<{ - readonly message: string; -}> {} +const getCurrentDatetimeTool = Tool.make("get_current_datetime", { + description: String.stripMargin(\` + |Get the current date and time in a specified timezone. + |Use IANA timezone identifiers (e.g. 'America/New_York', 'Europe/London', 'UTC'). + \`), + parameters: Schema.Struct({ + timezone: Schema.String, + }), + success: Schema.Struct({ + iso: Schema.DateTimeUtc, + formatted: Schema.String, + timezone: Schema.String, + unix: Schema.Number, + }), + failure: Schema.String, + failureMode: "return", +}); /** - * Calculator Tool - Safely evaluates mathematical expressions + * Provides the current date and time in a specified timezone. + * Models have no access to real-time clocks, making this essential + * for any time-aware agent behavior. + * + * @module */ -const calculatorTool = Tool.make("calculate", { - description: - "Evaluate a mathematical expression safely. Supports basic arithmetic operations (+, -, *, /), exponentiation (^), and common functions (sin, cos, sqrt, etc). Example: calculate(expression: '2 + 2 * 10')", +export const DateTimeToolkit = Toolkit.make(getCurrentDatetimeTool); + +export const DateTimeToolkitLive = DateTimeToolkit.toLayer( + Effect.succeed({ + get_current_datetime: (params) => + Effect.gen(function* () { + const tz = params.timezone || "UTC"; + const now = yield* DateTime.now; + const zoned = yield* Option.match(DateTime.setZoneNamed(now, tz), { + onNone: () => + Effect.fail(\`Invalid timezone "\${tz}": not a valid IANA timezone\`), + onSome: Effect.succeed, + }); + + yield* Effect.logDebug(\`Getting current datetime for timezone: \${tz}\`); + + return { + iso: now, + formatted: DateTime.format(zoned, { + dateStyle: "full", + timeStyle: "long", + }), + timezone: tz, + unix: DateTime.toEpochMillis(now), + }; + }), + }), +); +`; + +// Math Toolkit - deterministic arithmetic evaluation +export const aiMathToolkitContents = `import { Effect, pipe, Schema, String } from "effect"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +const SAFE_EXPRESSION_PATTERN = /^[\\d\\s+\\-*/().,%^e]+$/; + +const MathExpression = Schema.String.check( + Schema.isNonEmpty({ message: "Expression cannot be empty" }), + Schema.isTrimmed({ + message: "Expression must not have leading/trailing whitespace", + }), + Schema.isPattern(SAFE_EXPRESSION_PATTERN, { + description: String.stripMargin(\` + |Only digits, arithmetic operators (+, -, *, /, %, ^), + |parentheses, and decimal points are allowed + \`), + }), +).annotate({ + title: "MathExpression", + description: String.stripMargin(\` + |An arithmetic expression using numbers and operators. + |Supports: +, -, *, /, % (modulo), ^ or ** (exponent), parentheses. + \`), + examples: ["(42 * 3.14) / 7", "2 ^ 10", "100 % 7", "3.14 * (2 + 1)"], +}); + +const calculateTool = Tool.make("calculate", { + description: String.stripMargin(\` + |Evaluate an arithmetic expression deterministically. Use instead of + |mental math. Supports: +, -, *, /, % (modulo), ** (exponent), parentheses. + \`), parameters: Schema.Struct({ - expression: Schema.String, + expression: MathExpression, }), success: Schema.String, + failure: Schema.String, + failureMode: "return", }); +const normalize = String.replaceAll("^", "**"); + +const evaluate = (expr: string, original: string) => + pipe( + Effect.try({ + try: () => new Function(\`return (\${expr})\`)() as unknown, + catch: (cause) => + \`Failed to evaluate expression '\${original}': \${cause instanceof Error ? cause.message : globalThis.String(cause)}\`, + }), + Effect.filterOrFail( + (result): result is number => + typeof result === "number" && Number.isFinite(result), + (result) => + \`Expression did not produce a finite number: '\${original}' = \${globalThis.String(result)}\`, + ), + ); + /** - * Echo Tool - Simple echo for testing + * Evaluates arithmetic expressions deterministically. + * Models are unreliable at mental math; this offloads computation + * to a safe evaluator restricted to numeric operators. + * + * @module */ -const echoTool = Tool.make("echo", { - description: - "Echo back a message. Useful for testing tool calling. Example: echo(message: 'Hello, World!')", +export const MathToolkit = Toolkit.make(calculateTool); + +export const MathToolkitLive = MathToolkit.toLayer( + Effect.succeed({ + calculate: (params) => + Effect.gen(function* () { + const expr = params.expression; + const normalized = normalize(expr); + const result = yield* evaluate(normalized, expr); + + yield* Effect.logDebug(\`Calculate: \${expr} = \${result}\`); + return globalThis.String(result); + }), + }), +); +`; + +// Memory Toolkit - key-value scratchpad for agentic sessions +export const aiMemoryToolkitContents = `import { + Array as Arr, + Effect, + HashMap, + Option, + pipe, + Ref, + Schema, + String, +} from "effect"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +const memorySetTool = Tool.make("memory_set", { + description: String.stripMargin(\` + |Store a key-value pair in session memory. Overwrites existing keys. + \`), parameters: Schema.Struct({ - message: Schema.String, + key: Schema.String, + value: Schema.String, }), success: Schema.String, + failure: Schema.String, + failureMode: "return", +}); + +const memoryGetTool = Tool.make("memory_get", { + description: String.stripMargin(\` + |Retrieve a value by key from session memory. + \`), + parameters: Schema.Struct({ + key: Schema.String, + }), + success: Schema.String, + failure: Schema.String, + failureMode: "return", +}); + +const memoryListTool = Tool.make("memory_list", { + description: String.stripMargin(\` + |List all keys in session memory. + \`), + parameters: Tool.EmptyParams, + success: Schema.String, + failure: Schema.String, + failureMode: "return", +}); + +const memoryDeleteTool = Tool.make("memory_delete", { + description: String.stripMargin(\` + |Remove a key from session memory. + \`), + parameters: Schema.Struct({ + key: Schema.String, + }), + success: Schema.String, + failure: Schema.String, + failureMode: "return", }); /** - * Get Current Time Tool - Returns current UTC time + * Key-value scratchpad for agentic loops. Allows the model to persist + * and retrieve facts across tool invocations within a single session. + * + * @module */ -const getCurrentTimeTool = Tool.make("getCurrentTime", { - description: - "Get the current date and time in a given timezone. Example: getCurrentTime(timezone: 'UTC')", +export const MemoryToolkit = Toolkit.make( + memorySetTool, + memoryGetTool, + memoryListTool, + memoryDeleteTool, +); + +/** Backed by an in-memory HashMap Ref scoped to the layer lifetime. */ +export const InMemoryToolkitLive = MemoryToolkit.toLayer( + Effect.gen(function* () { + const store = yield* Ref.make(HashMap.empty()); + + return { + memory_set: (params) => + Ref.update(store, HashMap.set(params.key, params.value)).pipe( + Effect.tap(() => Effect.logDebug(\`Memory set: \${params.key}\`)), + Effect.map(() => \`Stored "\${params.key}"\`), + ), + + memory_get: (params) => + Ref.get(store).pipe( + Effect.map((map) => HashMap.get(map, params.key)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(\`Key "\${params.key}" not found in memory\`), + onSome: Effect.succeed, + }), + ), + Effect.tap(() => Effect.logDebug(\`Memory get: \${params.key}\`)), + ), + + memory_list: () => + Ref.get(store).pipe( + Effect.map((map) => Arr.fromIterable(HashMap.keys(map))), + Effect.tap((keys) => + Effect.logDebug(\`Memory list: \${keys.length} keys\`), + ), + Effect.map(JSON.stringify), + ), + + memory_delete: (params) => + Ref.modify(store, (map) => + HashMap.has(map, params.key) + ? ([true, HashMap.remove(map, params.key)] as const) + : ([false, map] as const), + ).pipe( + Effect.flatMap((deleted) => + deleted + ? Effect.succeed(\`Deleted "\${params.key}"\`) + : Effect.fail(\`Key "\${params.key}" not found in memory\`), + ), + Effect.tap(() => Effect.logDebug(\`Memory delete: \${params.key}\`)), + ), + }; + }), +); +`; + +// Plan Toolkit - structured task tracking for agentic loops +export const aiPlanToolkitContents = `import { + Array as Arr, + Effect, + Option, + pipe, + Ref, + Schema, + String, +} from "effect"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +const PlanStatus = Schema.Literals([ + "pending", + "in_progress", + "completed", + "skipped", +] as const); + +const PlanStep = Schema.Struct({ + content: Schema.String, + status: PlanStatus, +}); +type PlanStep = typeof PlanStep.Type; + +const StepIndex = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const PlanResponse = Schema.Struct({ + steps: Schema.Array( + Schema.Struct({ + index: StepIndex, + content: Schema.String, + status: PlanStatus, + }), + ), +}); +type PlanResponse = typeof PlanResponse.Type; + +const planCreateTool = Tool.make("plan_create", { + description: String.stripMargin(\` + |Create an ordered plan for multi-step work. Replaces any existing plan. + |All steps start as 'pending'. + \`), + parameters: Schema.Struct({ + steps: Schema.NonEmptyArray(Schema.String), + }), + success: PlanResponse, + failure: Schema.String, + failureMode: "return", +}); + +const planUpdateTool = Tool.make("plan_update", { + description: String.stripMargin(\` + |Update a step's status by 0-based index. + |Statuses: pending, in_progress, completed, skipped. + |Only one step may be in_progress; setting a new one auto-completes the prior. + \`), + parameters: Schema.Struct({ + stepIndex: StepIndex, + status: PlanStatus, + }), + success: PlanResponse, + failure: Schema.String, + failureMode: "return", +}); + +const planGetTool = Tool.make("plan_get", { + description: String.stripMargin(\` + |Retrieve the current plan and step statuses. + \`), parameters: Tool.EmptyParams, - success: Schema.String, + success: Schema.Union([ + PlanResponse, + Schema.Struct({ message: Schema.String }), + ]), + failure: Schema.String, + failureMode: "return", }); -export const SampleToolkit = Toolkit.make( - calculatorTool, - echoTool, - getCurrentTimeTool, +/** + * Structured task tracking for agentic loops. Forces the model to plan + * before acting and track progress through steps. Enforces at most one + * step in_progress at a time. + * + * @module + */ +export const PlanToolkit = Toolkit.make( + planCreateTool, + planUpdateTool, + planGetTool, ); -export const SampleToolkitLive = SampleToolkit.toLayer( +const formatPlan = (steps: Array): PlanResponse => ({ + steps: Arr.map(steps, (step, index) => ({ index, ...step })), +}); + +export const PlanToolkitLive = PlanToolkit.toLayer( Effect.gen(function* () { + const planRef = yield* Ref.make>([]); + return { - calculate: (params) => + plan_create: (params) => Effect.gen(function* () { - yield* Effect.logDebug(\`Calculating: \${params.expression}\`); + const steps: Array = Arr.map(params.steps, (content) => ({ + content, + status: "pending" as const, + })); + + yield* Ref.set(planRef, steps); + yield* Effect.logDebug(\`Plan created with \${steps.length} steps\`); + return formatPlan(steps); + }), - // Simple safe evaluation for basic math - // Whitelist allowed characters - const sanitized = params.expression.replace(/[^0-9+\\-*/().\\s]/g, ""); + plan_update: (params) => + Effect.gen(function* () { + const steps = yield* Ref.get(planRef); - if (sanitized !== params.expression) { - return yield* Effect.succeed( - \`Error: Expression contains invalid characters. Only numbers and basic operators (+, -, *, /, parentheses) are allowed.\`, - ); + if (Arr.isArrayEmpty(steps)) { + return yield* Effect.fail("No plan exists. Use plan_create first."); } - return yield* Effect.try({ - try: () => { - const value = Function(\`"use strict"; return (\${sanitized})\`)(); - if (typeof value !== "number" || Number.isNaN(value)) { - throw new CalculatorError({ - message: "Result is not a valid number", - }); - } - return \`\${params.expression} = \${value}\`; - }, - catch: (error) => - new CalculatorError({ - message: \`Invalid expression: \${error instanceof Error ? error.message : String(error)}\`, - }), - }).pipe( - Effect.catch((error) => Effect.succeed(\`Error: \${error.message}\`)), + const updated = yield* pipe( + params.status === "in_progress" + ? Option.some( + Arr.map(steps, (step, i): PlanStep => { + if (i === params.stepIndex) + return { ...step, status: "in_progress" }; + if (step.status === "in_progress") + return { ...step, status: "completed" }; + return step; + }), + ) + : Arr.modify(steps, params.stepIndex, (step) => ({ + ...step, + status: params.status, + })), + Option.match({ + onNone: () => + Effect.fail( + \`Invalid step index \${params.stepIndex}. Plan has \${steps.length} steps (0-\${steps.length - 1}).\`, + ), + onSome: Effect.succeed, + }), ); - }), - echo: (params) => - Effect.gen(function* () { - yield* Effect.logDebug(\`Echo: \${params.message}\`); - return yield* Effect.succeed(\`Echo: \${params.message}\`); + yield* Ref.set(planRef, updated); + yield* Effect.logDebug( + \`Plan step \${params.stepIndex} -> \${params.status}\`, + ); + return formatPlan(updated); }), - getCurrentTime: () => + plan_get: () => + Ref.get(planRef).pipe( + Effect.map((steps) => + Arr.isArrayEmpty(steps) + ? { message: "No plan exists. Use plan_create to create one." } + : formatPlan(steps), + ), + ), + }; + }), +); +`; + +// WebFetch Toolkit - URL retrieval for retrieval-augmented workflows +export const aiWebFetchToolkitContents = `import { Effect, Layer, Match, pipe, Schema, String } from "effect"; +import { Tool, Toolkit } from "effect/unstable/ai"; +import { + FetchHttpClient, + HttpClient, + HttpClientResponse, +} from "effect/unstable/http"; + +const MAX_CONTENT_LENGTH = 8000; + +const HTML_ENTITIES: ReadonlyArray = [ + [" ", " "], + ["&", "&"], + ["<", "<"], + [">", ">"], + [""", '"'], + ["'", "'"], +]; + +const stripHtml = (html: string): string => + pipe( + html, + String.replace(/]*>[\\s\\S]*?<\\/script>/gi, ""), + String.replace(/]*>[\\s\\S]*?<\\/style>/gi, ""), + String.replace(/<[^>]+>/g, " "), + (s) => + HTML_ENTITIES.reduce( + (acc, [entity, char]) => acc.replaceAll(entity, char), + s, + ), + String.replace(/\\s+/g, " "), + String.trim, + ); + +const truncate = (text: string): string => + String.length(text) > MAX_CONTENT_LENGTH + ? \`\${pipe(text, String.takeLeft(MAX_CONTENT_LENGTH))}...[truncated]\` + : text; + +const fetchUrlTool = Tool.make("fetch_url", { + description: String.stripMargin(\` + |Fetch a URL and return its content as plain text. + |HTML is stripped automatically. Output truncated at 8000 characters. + \`), + parameters: Schema.Struct({ url: Schema.URLFromString }), + success: Schema.String, + failure: Schema.String, + failureMode: "return", +}); + +/** + * Retrieves content from URLs for retrieval-augmented workflows. + * HTML is stripped automatically and output is truncated at 8000 characters. + * + * @module + */ +export const WebFetchToolkit = Toolkit.make(fetchUrlTool); + +/** Provides its own HTTP client for network access. */ +export const WebFetchToolkitLive = WebFetchToolkit.toLayer( + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + + const http = client.pipe( + HttpClient.followRedirects(10), + HttpClient.retryTransient({ times: 2 }), + ); + + return { + fetch_url: (params) => Effect.gen(function* () { - const now = yield* DateTime.now; - const timeString = DateTime.formatUtc(now, { - locale: "en-US", - dateStyle: "medium", - timeStyle: "medium", - }); - yield* Effect.logDebug(\`Current time (UTC): \${timeString}\`); - return yield* Effect.succeed( - \`Current time in UTC: \${timeString} (ISO: \${DateTime.formatIso(now)})\`, + yield* Effect.logDebug(\`Fetching URL: \${params.url}\`); + + const response = yield* pipe( + http.get(params.url), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError((error) => + Match.value(error.reason).pipe( + Match.tag( + "TransportError", + () => + \`Network error fetching "\${params.url}": connection failed or timed out\`, + ), + Match.tag( + "InvalidUrlError", + () => \`Invalid URL: "\${params.url}"\`, + ), + Match.tag( + "StatusCodeError", + (r) => + \`HTTP \${globalThis.String(r.response.status)} from "\${params.url}"\`, + ), + Match.orElse( + () => \`Failed to fetch "\${params.url}": \${error.message}\`, + ), + ), + ), ); + + const raw = yield* pipe( + response.text, + Effect.mapError( + () => \`Failed to read response body from "\${params.url}"\`, + ), + ); + const contentType = String.toLowerCase( + response.headers["content-type"] ?? "", + ); + const text = Match.value(contentType).pipe( + Match.when(String.includes("text/html"), () => stripHtml(raw)), + Match.orElse(() => raw), + ); + + const result = truncate(text); + + yield* Effect.logDebug( + \`Fetched \${String.String(String.length(text))} chars from \${params.url} (returned \${String.String(String.length(result))})\`, + ); + + return result; }), }; }), -); +).pipe(Layer.provide(FetchHttpClient.layer)); `; export const aiAgenticLoopContents = `import type { ChatStreamPart } from "@repo/domain/Chat"; diff --git a/packages/catalog/src/registry/content/chat.ts b/packages/catalog/src/registry/content/chat.ts index 76a595f..4051170 100644 --- a/packages/catalog/src/registry/content/chat.ts +++ b/packages/catalog/src/registry/content/chat.ts @@ -164,12 +164,13 @@ export class ChatRpc extends RpcGroup.make( `; // Server chat handler (separate from EventRpc) -export const serverChatContents = `import { ChatService } from "@repo/ai"; +export const serverChatContents = `import { ChatService, ChatServiceLive, FastModelLive } from "@repo/ai"; import { ChatRpc } from "@repo/domain/ChatRpc"; -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { Prompt } from "effect/unstable/ai"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -export const ChatRpcLive = ChatRpc.toLayer( +const ChatRpcHandlers = ChatRpc.toLayer( Effect.gen(function* () { const bot = yield* ChatService; yield* Effect.logInfo("Starting Chat RPC Live Implementation"); @@ -190,4 +191,14 @@ export const ChatRpcLive = ChatRpc.toLayer( }); }), ); + +export const ChatRpcLive = RpcServer.layerHttp({ + group: ChatRpc, + path: "/chat-rpc", + protocol: "http", +}).pipe( + Layer.provide(ChatRpcHandlers), + Layer.provide(RpcSerialization.layerNdjson), + Layer.provide([ChatServiceLive, FastModelLive]), +); `; diff --git a/packages/catalog/src/registry/content/client-foldkit.ts b/packages/catalog/src/registry/content/client-foldkit.ts index b335529..fe296d8 100644 --- a/packages/catalog/src/registry/content/client-foldkit.ts +++ b/packages/catalog/src/registry/content/client-foldkit.ts @@ -60,6 +60,11 @@ export default defineConfig({ "@": new URL("./src", import.meta.url).pathname, }, }, + server: { + port: 3000, + strictPort: true, + host: "127.0.0.1", + }, }); `; diff --git a/packages/catalog/src/registry/content/client.ts b/packages/catalog/src/registry/content/client.ts index b40d3cc..6fe7c40 100644 --- a/packages/catalog/src/registry/content/client.ts +++ b/packages/catalog/src/registry/content/client.ts @@ -149,6 +149,7 @@ export default defineConfig({ include: ["@repo/domain"], }, server: { + port: 3000, strictPort: true, host: "127.0.0.1", }, diff --git a/packages/catalog/src/registry/content/rpc.ts b/packages/catalog/src/registry/content/rpc.ts index 5922157..e5cfb42 100644 --- a/packages/catalog/src/registry/content/rpc.ts +++ b/packages/catalog/src/registry/content/rpc.ts @@ -23,9 +23,10 @@ export class EventRpc extends RpcGroup.make( // Server tick handler export const serverTickContents = `import { EventRpc, type TickEvent } from "@repo/domain/Rpc"; -import { Effect, Queue } from "effect"; +import { Effect, Layer, Queue } from "effect"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -export const EventRpcLive = EventRpc.toLayer( +const EventRpcHandlers = EventRpc.toLayer( Effect.gen(function* () { yield* Effect.logInfo("Starting Event RPC Live Implementation"); return EventRpc.of({ @@ -49,4 +50,13 @@ export const EventRpcLive = EventRpc.toLayer( }); }), ); + +export const EventRpcLive = RpcServer.layerHttp({ + group: EventRpc, + path: "/rpc", + protocol: "http", +}).pipe( + Layer.provide(EventRpcHandlers), + Layer.provide(RpcSerialization.layerNdjson), +); `; diff --git a/packages/catalog/src/registry/content/websocket.ts b/packages/catalog/src/registry/content/websocket.ts index cf949b0..53323d8 100644 --- a/packages/catalog/src/registry/content/websocket.ts +++ b/packages/catalog/src/registry/content/websocket.ts @@ -66,15 +66,17 @@ export class WebSocketRpc extends RpcGroup.make( `; // Server Presence RPC handler -export const serverPresenceContents = `import { +export const serverPresenceContents = `import { BunCrypto } from "@effect/platform-bun"; +import { type ClientInfo, type WebSocketEvent, WebSocketRpc, } from "@repo/domain/WebSocket"; import { ClientGenerator, PresenceService } from "@repo/presence"; import { DateTime, Effect, Layer, Queue, Stream } from "effect"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -export const PresenceRpcLive = WebSocketRpc.toLayer( +const PresenceRpcHandlers = WebSocketRpc.toLayer( Effect.gen(function* () { const presence = yield* PresenceService; const gen = yield* ClientGenerator; @@ -164,5 +166,14 @@ export const PresenceRpcLive = WebSocketRpc.toLayer( ).pipe( Layer.provide(PresenceService.layer), Layer.provide(ClientGenerator.layer), + Layer.provide(BunCrypto.layer), +); + +export const PresenceRpcLive = RpcServer.layerHttp({ + group: WebSocketRpc, + path: "/ws", +}).pipe( + Layer.provide(PresenceRpcHandlers), + Layer.provide(RpcSerialization.layerNdjson), ); `; diff --git a/packages/catalog/src/registry/modules/packages.ts b/packages/catalog/src/registry/modules/packages.ts index 178a431..1a50a6f 100644 --- a/packages/catalog/src/registry/modules/packages.ts +++ b/packages/catalog/src/registry/modules/packages.ts @@ -7,11 +7,15 @@ import { import { aiAgenticLoopContents, aiChatServiceContents, + aiDateTimeToolkitContents, aiIndexContents, aiLanguageModelContents, aiMailboxEventsContents, - aiSampleToolkitContents, + aiMathToolkitContents, + aiMemoryToolkitContents, + aiPlanToolkitContents, aiThinkToolkitContents, + aiWebFetchToolkitContents, } from "../content/ai"; import { presenceClientGeneratorContents, @@ -123,9 +127,209 @@ export const packageModules: ReadonlyArray = [ ], }, { - id: ModuleId.make("ai-sample-toolkit"), - title: "Sample Toolkit", - description: "Sample AI toolkit with calculator, echo, and time tools", + id: ModuleId.make("ai-datetime-toolkit"), + title: "DateTime Toolkit", + description: + "Timezone-aware date and time tool for time-sensitive agent behavior", + supportedOn: [ + { + _tag: "identity", + identity: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "ai", + }), + }, + ], + dependencies: [], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/toolkits/DateTimeToolkit.ts", + contents: aiDateTimeToolkitContents, + }, + { + _tag: "barrel-export", + barrelPath: "{{targetPath}}/src/index.ts", + exportPath: "./toolkits/DateTimeToolkit", + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkit", + functionName: "Toolkit.merge", + argument: "DateTimeToolkit", + import: { + moduleSpecifier: "../toolkits/DateTimeToolkit", + namedImports: ["DateTimeToolkit"], + }, + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkitLive", + functionName: "Layer.mergeAll", + argument: "DateTimeToolkitLive", + import: { + moduleSpecifier: "../toolkits/DateTimeToolkit", + namedImports: ["DateTimeToolkitLive"], + }, + }, + ], + }, + { + id: ModuleId.make("ai-math-toolkit"), + title: "Math Toolkit", + description: "Deterministic arithmetic evaluator for safe math computation", + supportedOn: [ + { + _tag: "identity", + identity: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "ai", + }), + }, + ], + dependencies: [], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/toolkits/MathToolkit.ts", + contents: aiMathToolkitContents, + }, + { + _tag: "barrel-export", + barrelPath: "{{targetPath}}/src/index.ts", + exportPath: "./toolkits/MathToolkit", + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkit", + functionName: "Toolkit.merge", + argument: "MathToolkit", + import: { + moduleSpecifier: "../toolkits/MathToolkit", + namedImports: ["MathToolkit"], + }, + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkitLive", + functionName: "Layer.mergeAll", + argument: "MathToolkitLive", + import: { + moduleSpecifier: "../toolkits/MathToolkit", + namedImports: ["MathToolkitLive"], + }, + }, + ], + }, + { + id: ModuleId.make("ai-memory-toolkit"), + title: "Memory Toolkit", + description: + "Key-value scratchpad for persisting facts across tool invocations", + supportedOn: [ + { + _tag: "identity", + identity: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "ai", + }), + }, + ], + dependencies: [], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/toolkits/MemoryToolkit.ts", + contents: aiMemoryToolkitContents, + }, + { + _tag: "barrel-export", + barrelPath: "{{targetPath}}/src/index.ts", + exportPath: "./toolkits/MemoryToolkit", + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkit", + functionName: "Toolkit.merge", + argument: "MemoryToolkit", + import: { + moduleSpecifier: "../toolkits/MemoryToolkit", + namedImports: ["MemoryToolkit"], + }, + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkitLive", + functionName: "Layer.mergeAll", + argument: "InMemoryToolkitLive", + import: { + moduleSpecifier: "../toolkits/MemoryToolkit", + namedImports: ["InMemoryToolkitLive"], + }, + }, + ], + }, + { + id: ModuleId.make("ai-plan-toolkit"), + title: "Plan Toolkit", + description: + "Structured task tracking that forces plan-before-act discipline", + supportedOn: [ + { + _tag: "identity", + identity: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "ai", + }), + }, + ], + dependencies: [], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/toolkits/PlanToolkit.ts", + contents: aiPlanToolkitContents, + }, + { + _tag: "barrel-export", + barrelPath: "{{targetPath}}/src/index.ts", + exportPath: "./toolkits/PlanToolkit", + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkit", + functionName: "Toolkit.merge", + argument: "PlanToolkit", + import: { + moduleSpecifier: "../toolkits/PlanToolkit", + namedImports: ["PlanToolkit"], + }, + }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkitLive", + functionName: "Layer.mergeAll", + argument: "PlanToolkitLive", + import: { + moduleSpecifier: "../toolkits/PlanToolkit", + namedImports: ["PlanToolkitLive"], + }, + }, + ], + }, + { + id: ModuleId.make("ai-webfetch-toolkit"), + title: "WebFetch Toolkit", + description: + "URL content retrieval with HTML stripping for retrieval-augmented workflows", supportedOn: [ { _tag: "identity", @@ -139,36 +343,34 @@ export const packageModules: ReadonlyArray = [ contributions: [ { _tag: "file", - path: "{{targetPath}}/src/toolkits/SampleToolkit.ts", - contents: aiSampleToolkitContents, + path: "{{targetPath}}/src/toolkits/WebFetchToolkit.ts", + contents: aiWebFetchToolkitContents, }, { _tag: "barrel-export", barrelPath: "{{targetPath}}/src/index.ts", - exportPath: "./toolkits/SampleToolkit", + exportPath: "./toolkits/WebFetchToolkit", }, - // Add SampleToolkit to ChatToolkit merge { _tag: "ts-call-arg", path: "{{targetPath}}/src/services/ChatService.ts", targetVariable: "ChatToolkit", functionName: "Toolkit.merge", - argument: "SampleToolkit", + argument: "WebFetchToolkit", import: { - moduleSpecifier: "../toolkits/SampleToolkit", - namedImports: ["SampleToolkit"], + moduleSpecifier: "../toolkits/WebFetchToolkit", + namedImports: ["WebFetchToolkit"], }, }, - // Add SampleToolkitLive to ChatToolkitLive merge { _tag: "ts-call-arg", path: "{{targetPath}}/src/services/ChatService.ts", targetVariable: "ChatToolkitLive", functionName: "Layer.mergeAll", - argument: "SampleToolkitLive", + argument: "WebFetchToolkitLive", import: { - moduleSpecifier: "../toolkits/SampleToolkit", - namedImports: ["SampleToolkitLive"], + moduleSpecifier: "../toolkits/WebFetchToolkit", + namedImports: ["WebFetchToolkitLive"], }, }, ], @@ -214,7 +416,17 @@ export const packageModules: ReadonlyArray = [ }, ], children: [ - { moduleId: ModuleId.make("ai-sample-toolkit"), requirement: "optional" }, + { + moduleId: ModuleId.make("ai-datetime-toolkit"), + requirement: "optional", + }, + { moduleId: ModuleId.make("ai-math-toolkit"), requirement: "optional" }, + { moduleId: ModuleId.make("ai-memory-toolkit"), requirement: "optional" }, + { moduleId: ModuleId.make("ai-plan-toolkit"), requirement: "optional" }, + { + moduleId: ModuleId.make("ai-webfetch-toolkit"), + requirement: "optional", + }, ], contributions: [ { diff --git a/packages/catalog/src/registry/modules/server.ts b/packages/catalog/src/registry/modules/server.ts index 619f199..ec6a543 100644 --- a/packages/catalog/src/registry/modules/server.ts +++ b/packages/catalog/src/registry/modules/server.ts @@ -76,6 +76,17 @@ export const serverModules: ReadonlyArray = [ name: "@repo/domain", value: "workspace:*", }, + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/index.ts", + targetVariable: "AllRouters", + functionName: "Layer.mergeAll", + argument: "EventRpcLive", + import: { + moduleSpecifier: "./Rpc/Event", + namedImports: ["EventRpcLive"], + }, + }, ], }, { @@ -126,21 +137,10 @@ export const serverModules: ReadonlyArray = [ path: "{{targetPath}}/src/index.ts", targetVariable: "AllRouters", functionName: "Layer.mergeAll", - argument: "ChatServiceLive", - import: { - moduleSpecifier: "@repo/ai", - namedImports: ["ChatServiceLive"], - }, - }, - { - _tag: "ts-call-arg", - path: "{{targetPath}}/src/index.ts", - targetVariable: "AllRouters", - functionName: "Layer.mergeAll", - argument: "FastModelLive", + argument: "ChatRpcLive", import: { - moduleSpecifier: "@repo/ai", - namedImports: ["FastModelLive"], + moduleSpecifier: "./Rpc/Chat", + namedImports: ["ChatRpcLive"], }, }, ], @@ -193,10 +193,10 @@ export const serverModules: ReadonlyArray = [ path: "{{targetPath}}/src/index.ts", targetVariable: "AllRouters", functionName: "Layer.mergeAll", - argument: "PresenceServiceLive", + argument: "PresenceRpcLive", import: { - moduleSpecifier: "@repo/presence", - namedImports: ["PresenceServiceLive"], + moduleSpecifier: "./Rpc/Presence", + namedImports: ["PresenceRpcLive"], }, }, ], diff --git a/packages/scaffold/src/service/plan/PlanService.test.ts b/packages/scaffold/src/service/plan/PlanService.test.ts index b9044a7..af8a372 100644 --- a/packages/scaffold/src/service/plan/PlanService.test.ts +++ b/packages/scaffold/src/service/plan/PlanService.test.ts @@ -317,7 +317,7 @@ const HttpRpcRouter = Layer.empty; expect(serverOutcome._tag).toBe("composed"); assert(serverOutcome._tag === "composed"); - // Should have ts-add-import operations for the AI services + // Should have ts-add-import operations for the ChatRpcLive layer const importOps = serverOutcome.operations.filter( (op) => op._tag === "ts-add-import", ); @@ -325,13 +325,8 @@ const HttpRpcRouter = Layer.empty; expect.arrayContaining([ expect.objectContaining({ _tag: "ts-add-import", - moduleSpecifier: "@repo/ai", - namedImports: ["ChatServiceLive"], - }), - expect.objectContaining({ - _tag: "ts-add-import", - moduleSpecifier: "@repo/ai", - namedImports: ["FastModelLive"], + moduleSpecifier: "./Rpc/Chat", + namedImports: ["ChatRpcLive"], }), ]), ); @@ -346,13 +341,7 @@ const HttpRpcRouter = Layer.empty; _tag: "ts-append-call-arg", targetVariable: "AllRouters", functionName: "Layer.mergeAll", - argument: "ChatServiceLive", - }), - expect.objectContaining({ - _tag: "ts-append-call-arg", - targetVariable: "AllRouters", - functionName: "Layer.mergeAll", - argument: "FastModelLive", + argument: "ChatRpcLive", }), ]), ); @@ -614,10 +603,10 @@ const HttpRpcRouter = Layer.empty; _tag: "attached-module", id: toAttachedModuleNodeId( aiIdentity.toKey(), - ModuleId.make("ai-sample-toolkit"), + ModuleId.make("ai-datetime-toolkit"), ), targetId: aiIdentity.toKey(), - moduleId: ModuleId.make("ai-sample-toolkit"), + moduleId: ModuleId.make("ai-datetime-toolkit"), }, ], edges: [ @@ -628,11 +617,11 @@ const HttpRpcRouter = Layer.empty; reason: "owns-module", }, { - id: `owns-module=>packages/ai=>${toAttachedModuleNodeId(aiIdentity.toKey(), ModuleId.make("ai-sample-toolkit"))}`, + id: `owns-module=>packages/ai=>${toAttachedModuleNodeId(aiIdentity.toKey(), ModuleId.make("ai-datetime-toolkit"))}`, from: aiIdentity.toKey(), to: toAttachedModuleNodeId( aiIdentity.toKey(), - ModuleId.make("ai-sample-toolkit"), + ModuleId.make("ai-datetime-toolkit"), ), reason: "owns-module", }, @@ -657,12 +646,12 @@ const HttpRpcRouter = Layer.empty; expect(indexOutcome._tag).toBe("composed"); expect(indexOutcome.classification).toBe("create"); - // Should have ts-add-reexport for the barrel export from ai-sample-toolkit + // Should have ts-add-reexport for the barrel export from ai-datetime-toolkit expect(indexOutcome).toMatchObject({ operations: expect.arrayContaining([ expect.objectContaining({ _tag: "ts-add-reexport", - moduleSpecifier: "./toolkits/SampleToolkit", + moduleSpecifier: "./toolkits/DateTimeToolkit", }), ]), }); @@ -674,7 +663,7 @@ const HttpRpcRouter = Layer.empty; () => Effect.gen(function* () { const existingContents = `export * from "./LanguageModel"; -export * from "./toolkits/SampleToolkit"; +export * from "./toolkits/DateTimeToolkit"; `; const plan = yield* buildPlan({ @@ -757,7 +746,7 @@ export * from "./toolkits/SampleToolkit"; expect.objectContaining({ _tag: "barrelExport", path: "packages/ai/src/index.ts", - exportPath: "./toolkits/SampleToolkit", + exportPath: "./toolkits/DateTimeToolkit", }), ]), );