diff --git a/apps/backend/src/services/app-config.ts b/apps/backend/src/services/app-config.ts index 57657ae..e96ed02 100644 --- a/apps/backend/src/services/app-config.ts +++ b/apps/backend/src/services/app-config.ts @@ -55,6 +55,14 @@ const SandboxDefSchema = Schema.Struct({ }), }); +const MemoryDefSchema = Schema.Struct({ + recent_count: Schema.optionalWith(Schema.Number, { default: () => 10 }), + search_candidates: Schema.optionalWith(Schema.Number, { + default: () => 15, + }), + max_relevant: Schema.optionalWith(Schema.Number, { default: () => 3 }), +}); + const TomlSchema = Schema.Struct({ default_model: Schema.String, background_model: Schema.optional(Schema.String), @@ -62,6 +70,7 @@ const TomlSchema = Schema.Struct({ key: Schema.String, value: ProviderDefSchema, }), + memory: Schema.optional(MemoryDefSchema), sandbox: Schema.optional(SandboxDefSchema), }); @@ -106,9 +115,16 @@ export type SandboxConfig = { readonly dependencies: ReadonlyArray; }; +export type MemoryConfig = { + readonly recentCount: number; + readonly searchCandidates: number; + readonly maxRelevant: number; +}; + export type AppConfig = { readonly models: ModelsConfig; readonly sandbox: Option.Option; + readonly memory: MemoryConfig; }; // Convention: provider "groq" → env var "GROQ_API_KEY" @@ -209,6 +225,13 @@ export const loadAppConfig = Effect.gen(function* () { ? yield* resolveSandbox(toml.sandbox) : Option.none(); + // Resolve memory config (optional — defaults apply when absent) + const memory: MemoryConfig = { + recentCount: toml.memory?.recent_count ?? 10, + searchCandidates: toml.memory?.search_candidates ?? 15, + maxRelevant: toml.memory?.max_relevant ?? 3, + }; + yield* Effect.log("Config loaded", { providers: providers.map((p) => p.name), models: providers.flatMap((p) => p.models.map((m) => m.id)), @@ -225,7 +248,8 @@ export const loadAppConfig = Effect.gen(function* () { deps: s.dependencies.map((d) => d.package), }), }), + memory, }); - return { models, sandbox } satisfies AppConfig; + return { models, sandbox, memory } satisfies AppConfig; }); diff --git a/apps/backend/src/services/generate/prompts.ts b/apps/backend/src/services/generate/prompts.ts index 1e8f708..ad1bdc5 100644 --- a/apps/backend/src/services/generate/prompts.ts +++ b/apps/backend/src/services/generate/prompts.ts @@ -6,7 +6,7 @@ export const MAX_RETRY_ATTEMPTS = 3; // Streaming system prompt - compact but complete export const STREAMING_PATCH_PROMPT = `You are cuttlekit, a generative UI engine. -Users describe what they want and you build it as live HTML. You also handle user actions like button clicks, form inputs, and selections to update the UI accordingly. +Build live HTML from user descriptions. Handle actions (clicks, inputs, selections) to update UI. OUTPUT: JSONL, one JSON per line with "op" field. Stream multiple small lines, NOT one big line. {"op":"patches","patches":[...]} - 1-3 patches per line MAX, try under 400 chars each. Many changes = many lines, one item per line. diff --git a/apps/backend/src/services/generate/service.ts b/apps/backend/src/services/generate/service.ts index 8e5f632..22487a4 100644 --- a/apps/backend/src/services/generate/service.ts +++ b/apps/backend/src/services/generate/service.ts @@ -6,6 +6,7 @@ import { MemoryService, type MemorySearchResult } from "../memory/index.js"; import { accumulateLinesWithFlush } from "../../stream/utils.js"; import { PatchValidator, renderCETree, getCompactHtmlFromCtx, type Patch, type ValidationContext } from "../vdom/index.js"; import { ModelRegistry } from "../model-registry.js"; +import { loadAppConfig } from "../app-config.js"; import { PatchSchema, LLMResponseSchema, @@ -35,6 +36,7 @@ export class GenerateService extends Effect.Service()( const memory = yield* MemoryService; const patchValidator = yield* PatchValidator; const toolService = yield* ToolService; + const { memory: memoryConfig } = yield* loadAppConfig; // ============================================================ // Parse JSON line - fails with JsonParseError for retry @@ -386,14 +388,14 @@ export class GenerateService extends Effect.Service()( // Fetch recent entries and semantic search results const [recentEntries, relevantEntries] = yield* Effect.all([ memory - .getRecent(sessionId, 5) + .getRecent(sessionId, memoryConfig.recentCount) .pipe( Effect.catchAll(() => Effect.succeed([] as MemorySearchResult[]), ), ), memory - .search(sessionId, searchQuery, 8) + .search(sessionId, searchQuery, memoryConfig.searchCandidates) .pipe( Effect.catchAll(() => Effect.succeed([] as MemorySearchResult[]), @@ -405,10 +407,20 @@ export class GenerateService extends Effect.Service()( const recentIds = new Set(recentEntries.map((e) => e.id)); const uniqueRelevant = relevantEntries .filter((e) => !recentIds.has(e.id)) - .slice(0, 3); + .slice(0, memoryConfig.maxRelevant); - // Build history (context only, not to act on) + // Build history: relevant context first (background), then recent (timeline closest to [NOW]) const historyParts: string[] = []; + if (uniqueRelevant.length > 0) { + historyParts.push( + `[RELEVANT PAST CONTEXT]\n${uniqueRelevant + .map( + (e) => + `- ${e.promptSummary ? `"${e.promptSummary}" → ` : ""}${e.changeSummary}`, + ) + .join("\n")}`, + ); + } if (recentEntries.length > 0) { historyParts.push( `[RECENT CHANGES]\n${recentEntries @@ -421,16 +433,6 @@ export class GenerateService extends Effect.Service()( .join("\n")}`, ); } - if (uniqueRelevant.length > 0) { - historyParts.push( - `[RELEVANT PAST CONTEXT]\n${uniqueRelevant - .map( - (e) => - `- ${e.promptSummary ? `"${e.promptSummary}" → ` : ""}${e.changeSummary}`, - ) - .join("\n")}`, - ); - } // Build current actions (most volatile - goes at end) // Lists all batched actions/prompts in chronological order (1 = oldest, N = latest) diff --git a/apps/backend/src/services/memory/service.ts b/apps/backend/src/services/memory/service.ts index f0414df..77932c5 100644 --- a/apps/backend/src/services/memory/service.ts +++ b/apps/backend/src/services/memory/service.ts @@ -45,15 +45,19 @@ const MemorySummariesSchema = z.object({ promptSummary: z .string() .nullable() - .describe("Summary of user prompts in one sentence, or null if no prompts"), + .describe( + "Compressed user intent, 3-8 words. Drop articles/filler. Null if no prompts.", + ), actionSummary: z .string() .nullable() - .describe("Summary of user actions in one sentence, or null if no actions"), + .describe( + "Compressed action, 3-8 words. Drop articles/filler. Null if no actions.", + ), changeSummary: z .string() .describe( - "1-2 sentence summary of what changed visually/functionally, not technical details", + "Compressed visual/functional change, 5-12 words. Drop articles/connectives/filler. Keep facts/numbers.", ), }); @@ -143,7 +147,9 @@ export class MemoryService extends Effect.Service()( ? `User actions:\n${op.actions.map((a, i) => `${i + 1}. ${a.action}${a.data ? ` (data: ${JSON.stringify(a.data)})` : ""}`).join("\n")}` : "No user actions."; - const prompt = `Analyze these UI changes and generate summaries. + const prompt = `Analyze UI changes. Generate compressed summaries. + +COMPRESSION: Drop articles, connectives, filler. Active voice, present tense. Keep facts/numbers/specifics. ${promptContext} @@ -152,9 +158,9 @@ ${actionContext} ${changeDescription} Generate: -1. promptSummary: If there are prompts, summarize them in one sentence. Otherwise null. -2. actionSummary: If there are actions, summarize them in one sentence. Otherwise null. -3. changeSummary: Describe what changed visually/functionally in 1-2 sentences.`; +1. promptSummary: Compressed user intent (3-8 words). Null if no prompts. +2. actionSummary: Compressed action (3-8 words). Null if no actions. +3. changeSummary: Compressed visual/functional change (5-12 words).`; const result = yield* Effect.promise(() => generateText({