diff --git a/src/bot/PlannerBot.ts b/src/bot/PlannerBot.ts index 9ade770..6052180 100644 --- a/src/bot/PlannerBot.ts +++ b/src/bot/PlannerBot.ts @@ -10,7 +10,16 @@ import { UserTokenClient } from "botframework-connector"; import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs"; import { createGraphClient } from "../graph/graphClientFactory"; import { PlannerClient } from "../graph/plannerClient"; -import { ClassifiedAction, LlmClassifier, PlanContext, RecentTaskContext } from "../llm/types"; +import { + ClassifiedAction, + ConversationContext, + LlmClassifier, + MemoryPatch, + PlanContext, + RawTurn, + RecentTaskContext, + WorkingMemory, +} from "../llm/types"; import { ActionContext, buildClarificationCard, @@ -36,6 +45,15 @@ async function signOutUser(context: TurnContext, connectionName: string): Promis const OAUTH_PROMPT = "graphOAuthPrompt"; const MAIN_DIALOG = "mainDialog"; +// Memory / ring-buffer caps. These prevent unbounded growth of conversation +// state even if the LLM keeps adding to its own memory. +const IDLE_MS = 10 * 60 * 1000; +const MAX_RAW_TURNS = 2; +const MAX_OPEN_LOOPS = 5; +const MAX_NOTES_LEN = 500; +const MAX_USER_LEN = 500; +const MAX_BOTACTION_LEN = 400; + interface PendingAction { action: Exclude; ctx: ActionContext; @@ -70,6 +88,8 @@ export class PlannerBot extends TeamsActivityHandler { private readonly dialogs: DialogSet; private readonly dialogStateAccessor: StatePropertyAccessor; private readonly pendingAccessor: StatePropertyAccessor; + private readonly memoryAccessor: StatePropertyAccessor; + private readonly rawTurnsAccessor: StatePropertyAccessor; private readonly connectionName: string; constructor(deps: PlannerBotDeps) { @@ -83,6 +103,12 @@ export class PlannerBot extends TeamsActivityHandler { this.pendingAccessor = this.conversationState.createProperty( "PendingAction", ); + this.memoryAccessor = this.conversationState.createProperty( + "WorkingMemory", + ); + this.rawTurnsAccessor = this.conversationState.createProperty( + "RawTurns", + ); this.dialogs = new DialogSet(this.dialogStateAccessor); this.dialogs.add( @@ -139,6 +165,7 @@ export class PlannerBot extends TeamsActivityHandler { if (text.toLowerCase() === "logout") { await signOutUser(context, this.connectionName); await this.pendingAccessor.set(context, undefined); + await this.wipeMemory(context); await context.sendActivity("로그아웃 완료."); await next(); return; @@ -227,16 +254,20 @@ export class PlannerBot extends TeamsActivityHandler { const validPlanIds = new Set(plans.map((p) => p.planId)); recentTasks = recentTasks.filter((t) => validPlanIds.has(t.planId)); - const action = await this.classifier.classify({ + const convoCtx = await this.loadConvoCtx(context); + const { action, memoryUpdate } = await this.classifier.classify({ utterance, plans, recentTasks, nowIso: new Date().toISOString(), + convoCtx, }); + await this.applyMemoryUpdate(context, memoryUpdate); if (action.type === "ask_clarification") { await this.pendingAccessor.set(context, undefined); await context.sendActivity({ attachments: [buildClarificationCard(action.question)] }); + await this.pushTurn(context, utterance, `ask_clarification: "${action.question}"`); return; } @@ -256,6 +287,7 @@ export class PlannerBot extends TeamsActivityHandler { ), ], }); + await this.pushTurn(context, utterance, "ask_clarification(internal): 대상 task 미발견"); return; } plan = plans.find((p) => p.planId === task!.planId); @@ -264,6 +296,7 @@ export class PlannerBot extends TeamsActivityHandler { await context.sendActivity( "대상 Plan을 찾을 수 없었어요. 다시 한 번 말씀해 주시겠어요?", ); + await this.pushTurn(context, utterance, "error: 대상 Plan 미발견"); return; } @@ -342,6 +375,7 @@ export class PlannerBot extends TeamsActivityHandler { await context.sendActivity({ attachments: [buildPreviewCard({ action: pending.action, ctx })], }); + await this.pushTurn(context, utterance, `preview: ${digestAction(pending.action, ctx)}`); } // -------- Confirm: actually apply ---------------------------------------- @@ -443,12 +477,22 @@ export class PlannerBot extends TeamsActivityHandler { await context.sendActivity({ attachments: [buildResultCard({ action, ctx, status: "done" })], }); + await this.pushTurn( + context, + "[✅ 확인 버튼]", + `applied: ${digestAction(action, ctx)}`, + ); } catch (err) { await context.sendActivity({ attachments: [ buildResultCard({ action, ctx, status: "error", errorMessage: (err as Error).message }), ], }); + await this.pushTurn( + context, + "[✅ 확인 버튼]", + `error: ${(err as Error).message}`, + ); } } @@ -464,6 +508,66 @@ export class PlannerBot extends TeamsActivityHandler { buildResultCard({ action: pending.action, ctx: pending.ctx, status: "canceled" }), ], }); + await this.pushTurn( + context, + "[❌ 취소 버튼]", + `canceled: ${digestAction(pending.action, pending.ctx)}`, + ); + } + + // -------- Conversation memory helpers ------------------------------------ + + /** Loads memory + recent raw turns, ages out idle entries, and derives the + * pendingDigest from the pendingAccessor (so the LLM never sees a stale + * digest that lingered after a confirm/cancel cleared pending). */ + private async loadConvoCtx(context: TurnContext): Promise { + const now = Date.now(); + + const rawMem = await this.memoryAccessor.get(context); + const mem = pruneMemoryOnLoad(rawMem, now); + if (mem !== rawMem) { + await this.memoryAccessor.set(context, mem); + } + + const rawTurns = await this.rawTurnsAccessor.get(context); + const turns = pruneTurnsOnLoad(rawTurns, now); + if (!rawTurns || turns.length !== rawTurns.length) { + await this.rawTurnsAccessor.set(context, turns.length ? turns : undefined); + } + + const pending = await this.pendingAccessor.get(context); + const pendingDigest = pending ? digestAction(pending.action, pending.ctx) : undefined; + + return { workingMemory: mem, recentTurns: turns, pendingDigest }; + } + + private async pushTurn(context: TurnContext, user: string, botAction: string): Promise { + const now = Date.now(); + const cur = (await this.rawTurnsAccessor.get(context)) ?? []; + const next: RawTurn[] = [ + ...cur, + { + ts: now, + user: user.slice(0, MAX_USER_LEN), + botAction: botAction.slice(0, MAX_BOTACTION_LEN), + }, + ].slice(-MAX_RAW_TURNS); + await this.rawTurnsAccessor.set(context, next); + } + + private async applyMemoryUpdate( + context: TurnContext, + patch: MemoryPatch | undefined, + ): Promise { + if (!patch) return; + const current = await this.memoryAccessor.get(context); + const next = applyMemoryPatch(current, patch, Date.now()); + await this.memoryAccessor.set(context, next); + } + + private async wipeMemory(context: TurnContext): Promise { + await this.memoryAccessor.set(context, undefined); + await this.rawTurnsAccessor.set(context, undefined); } } @@ -502,3 +606,108 @@ function dedupePreserveOrder(arr: T[]): T[] { } return out; } + +/** Idle pruning: after 10 minutes the volatile fields (openLoops) decay; the + * semantic ones (topic/focusPlan/lastTaskTitle/notes) survive so a returning + * user doesn't have to re-establish what they were working on. */ +function pruneMemoryOnLoad( + mem: WorkingMemory | undefined, + now: number, +): WorkingMemory | undefined { + if (!mem) return undefined; + if (now - mem.updatedAt > IDLE_MS) { + return { ...mem, openLoops: undefined }; + } + return mem; +} + +function pruneTurnsOnLoad(turns: RawTurn[] | undefined, now: number): RawTurn[] { + if (!turns) return []; + return turns.filter((t) => now - t.ts < IDLE_MS).slice(-MAX_RAW_TURNS); +} + +function applyMemoryPatch( + mem: WorkingMemory | undefined, + patch: MemoryPatch, + now: number, +): WorkingMemory { + if (patch.clearAll) return { updatedAt: now }; + const base: WorkingMemory = mem ?? { updatedAt: now }; + const next: WorkingMemory = { ...base, updatedAt: now }; + + if (patch.setTopic !== undefined) next.topic = patch.setTopic || undefined; + if (patch.setFocusPlan !== undefined) next.focusPlan = patch.setFocusPlan || undefined; + if (patch.setLastTaskTitle !== undefined) + next.lastTaskTitle = patch.setLastTaskTitle || undefined; + if (patch.setNotes !== undefined) { + const s = patch.setNotes.slice(0, MAX_NOTES_LEN); + next.notes = s || undefined; + } + if (patch.addOpenLoop) { + const cur = next.openLoops ?? []; + if (!cur.includes(patch.addOpenLoop)) { + next.openLoops = [...cur, patch.addOpenLoop].slice(-MAX_OPEN_LOOPS); + } + } + if (patch.clearOpenLoop) { + const filtered = (next.openLoops ?? []).filter((l) => l !== patch.clearOpenLoop); + next.openLoops = filtered.length ? filtered : undefined; + } + return next; +} + +/** Compact one-liner describing an action — used for both pendingDigest + * (LLM-facing context) and raw-turn botAction summaries. Keep it short and + * parseable so the LLM can latch onto fields. */ +function digestAction( + action: Exclude, + ctx: ActionContext, +): string { + if (action.type === "create_task") { + const parts: string[] = [ + "create_task", + `Plan="${ctx.planTitle ?? "?"}"`, + `Bucket="${ctx.bucketTitle ?? "?"}"`, + `제목="${action.title}"`, + ]; + if (action.progress) parts.push(`진행=${action.progress}`); + if (action.priority) parts.push(`우선=${action.priority}`); + if (action.startDate) parts.push(`시작=${action.startDate}`); + if (action.dueDate) parts.push(`마감=${action.dueDate}`); + if (ctx.newAssigneeNames?.length) parts.push(`할당=${ctx.newAssigneeNames.join(",")}`); + if (ctx.appliedLabelNames?.length) parts.push(`라벨=${ctx.appliedLabelNames.join(",")}`); + if (ctx.missingExplicitLabels?.length) + parts.push(`라벨대기=${ctx.missingExplicitLabels.join(",")}`); + if (action.checklistItems?.length) parts.push(`체크리스트(${action.checklistItems.length})`); + return parts.join(" / "); + } + // update_task + const parts: string[] = ["update_task", `대상="${ctx.taskTitle ?? action.taskId}"`]; + if (action.newTitle) parts.push(`새제목="${action.newTitle}"`); + if (action.progress || action.percentComplete !== undefined) { + const newPct = + action.percentComplete ?? + (action.progress === "completed" + ? 100 + : action.progress === "inProgress" + ? 50 + : action.progress === "notStarted" + ? 0 + : undefined); + const oldStr = ctx.currentPercent !== undefined ? `${ctx.currentPercent}%` : "?"; + parts.push(`진행 ${oldStr}→${newPct !== undefined ? `${newPct}%` : "?"}`); + } + if (action.priority) parts.push(`우선→${action.priority}`); + if (action.startDate) parts.push(`시작→${action.startDate}`); + if (action.dueDate) parts.push(`마감→${action.dueDate}`); + if (ctx.bucketTitleTo) parts.push(`버킷 ${ctx.bucketTitleFrom ?? "?"}→${ctx.bucketTitleTo}`); + if (action.assigneeUserIds) + parts.push(`할당→${(ctx.newAssigneeNames ?? []).join(",")}`); + if (ctx.appliedLabelNames?.length) parts.push(`라벨=${ctx.appliedLabelNames.join(",")}`); + if (ctx.missingExplicitLabels?.length) + parts.push(`라벨대기=${ctx.missingExplicitLabels.join(",")}`); + if (action.addChecklistItems?.length) + parts.push(`체크리스트추가(${action.addChecklistItems.length})`); + if (action.appendNote) parts.push("메모추가"); + return parts.join(" / "); +} diff --git a/src/llm/azureOpenAiClassifier.ts b/src/llm/azureOpenAiClassifier.ts index 702da2c..0b12299 100644 --- a/src/llm/azureOpenAiClassifier.ts +++ b/src/llm/azureOpenAiClassifier.ts @@ -1,7 +1,7 @@ import { AzureOpenAI } from "openai"; import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt"; -import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types"; -import { coerceAction } from "./coerce"; +import { ClassifierInput, ClassifierResult, LlmClassifier } from "./types"; +import { coerceClassifierResult } from "./coerce"; export class AzureOpenAIClassifier implements LlmClassifier { private readonly client: AzureOpenAI; @@ -22,7 +22,7 @@ export class AzureOpenAIClassifier implements LlmClassifier { this.deployment = opts.deployment; } - async classify(input: ClassifierInput): Promise { + async classify(input: ClassifierInput): Promise { const response = await this.client.chat.completions.create({ model: this.deployment, temperature: 0, @@ -51,6 +51,6 @@ export class AzureOpenAIClassifier implements LlmClassifier { throw new Error("Azure OpenAI did not return a tool call"); } - return coerceAction(JSON.parse(call.function.arguments)); + return coerceClassifierResult(JSON.parse(call.function.arguments)); } } diff --git a/src/llm/claudeClassifier.ts b/src/llm/claudeClassifier.ts index 25198c8..1a09081 100644 --- a/src/llm/claudeClassifier.ts +++ b/src/llm/claudeClassifier.ts @@ -1,7 +1,7 @@ import Anthropic from "@anthropic-ai/sdk"; import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt"; -import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types"; -import { coerceAction } from "./coerce"; +import { ClassifierInput, ClassifierResult, LlmClassifier } from "./types"; +import { coerceClassifierResult } from "./coerce"; export class ClaudeClassifier implements LlmClassifier { private readonly client: Anthropic; @@ -12,7 +12,7 @@ export class ClaudeClassifier implements LlmClassifier { this.model = opts.model; } - async classify(input: ClassifierInput): Promise { + async classify(input: ClassifierInput): Promise { const response = await this.client.messages.create({ model: this.model, max_tokens: 1024, @@ -33,6 +33,6 @@ export class ClaudeClassifier implements LlmClassifier { throw new Error("Claude did not return a tool_use block"); } - return coerceAction(toolUse.input); + return coerceClassifierResult(toolUse.input); } } diff --git a/src/llm/coerce.ts b/src/llm/coerce.ts index 62b43c3..9a18c8e 100644 --- a/src/llm/coerce.ts +++ b/src/llm/coerce.ts @@ -1,4 +1,50 @@ -import { ClassifiedAction, Priority, Progress } from "./types"; +import { ClassifiedAction, ClassifierResult, MemoryPatch, Priority, Progress } from "./types"; + +/** Unwrap both the action and the optional memory patch from a single tool-call payload. */ +export function coerceClassifierResult(raw: unknown): ClassifierResult { + const action = coerceAction(raw); + const memoryUpdate = + raw && typeof raw === "object" + ? coerceMemoryPatch((raw as Record).memoryUpdate) + : undefined; + return memoryUpdate ? { action, memoryUpdate } : { action }; +} + +function coerceMemoryPatch(v: unknown): MemoryPatch | undefined { + if (!v || typeof v !== "object") return undefined; + const o = v as Record; + const out: MemoryPatch = {}; + let any = false; + if (typeof o.setTopic === "string") { + out.setTopic = o.setTopic; + any = true; + } + if (typeof o.setFocusPlan === "string") { + out.setFocusPlan = o.setFocusPlan; + any = true; + } + if (typeof o.setLastTaskTitle === "string") { + out.setLastTaskTitle = o.setLastTaskTitle; + any = true; + } + if (typeof o.addOpenLoop === "string" && o.addOpenLoop.length > 0) { + out.addOpenLoop = o.addOpenLoop; + any = true; + } + if (typeof o.clearOpenLoop === "string" && o.clearOpenLoop.length > 0) { + out.clearOpenLoop = o.clearOpenLoop; + any = true; + } + if (typeof o.setNotes === "string") { + out.setNotes = o.setNotes; + any = true; + } + if (o.clearAll === true) { + out.clearAll = true; + any = true; + } + return any ? out : undefined; +} /** * Validates and narrows the raw JSON returned by the LLM into a ClassifiedAction. diff --git a/src/llm/geminiClassifier.ts b/src/llm/geminiClassifier.ts index 6274190..7fd038b 100644 --- a/src/llm/geminiClassifier.ts +++ b/src/llm/geminiClassifier.ts @@ -1,7 +1,7 @@ import {FunctionCallingConfigMode, GoogleGenAI, ThinkingLevel, Type} from "@google/genai"; import {ACTION_TOOL_SCHEMA, renderUserMessage, SYSTEM_PROMPT} from "./prompt"; -import {ClassifiedAction, ClassifierInput, LlmClassifier} from "./types"; -import {coerceAction} from "./coerce"; +import {ClassifierInput, ClassifierResult, LlmClassifier} from "./types"; +import {coerceClassifierResult} from "./coerce"; /** * Gemini's function-declaration schema uses Type.* enums instead of JSON-Schema strings. @@ -59,6 +59,19 @@ const GEMINI_SCHEMA = { appendNote: { type: Type.STRING }, newTitle: { type: Type.STRING }, question: { type: Type.STRING, description: "ask_clarification일 때 필수" }, + memoryUpdate: { + type: Type.OBJECT, + description: "선택. 작업 메모리에 적용할 패치.", + properties: { + setTopic: { type: Type.STRING }, + setFocusPlan: { type: Type.STRING }, + setLastTaskTitle: { type: Type.STRING }, + addOpenLoop: { type: Type.STRING }, + clearOpenLoop: { type: Type.STRING }, + setNotes: { type: Type.STRING }, + clearAll: { type: Type.BOOLEAN }, + }, + }, }, required: ["type"], }; @@ -72,7 +85,7 @@ export class GeminiClassifier implements LlmClassifier { this.model = opts.model; } - async classify(input: ClassifierInput): Promise { + async classify(input: ClassifierInput): Promise { const response = await this.client.models.generateContent({ model: this.model, contents: [{ role: "user", parts: [{ text: renderUserMessage(input) }] }], @@ -107,6 +120,6 @@ export class GeminiClassifier implements LlmClassifier { throw new Error("Gemini did not return a function call with args"); } - return coerceAction(call.args); + return coerceClassifierResult(call.args); } } diff --git a/src/llm/prompt.ts b/src/llm/prompt.ts index 05f1fa0..73ea104 100644 --- a/src/llm/prompt.ts +++ b/src/llm/prompt.ts @@ -1,4 +1,4 @@ -import { ClassifierInput } from "./types"; +import { ClassifierInput, ConversationContext } from "./types"; export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 보고를 Microsoft Planner 액션 하나로 변환하는 분류기입니다. @@ -49,6 +49,22 @@ export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 - 사용자가 "X 버킷으로 옮겨", "검토 중 버킷으로" 같이 명시적으로 말하면 해당 plan 의 buckets 중 매칭되는 bucketId 를 newBucketId 에 채웁니다. - 또는 상태 변경 표현이 명확히 다른 버킷으로의 이동을 시사하면(예: "이제 완료 됐어" → "Done" 버킷이 있을 때) 같이 채워주세요. 단, 다른 plan 으로의 이동은 금지. +## 작업 메모리 (입력에 [작업 메모리] 블록이 있는 경우) +- topic / focusPlan / lastTaskTitle / openLoops / notes 는 이번 세션 동안 누적된 보조 정보입니다. +- pendingDigest 가 있으면 미리보기 카드가 떠 있다는 뜻입니다. 사용자가 그걸 수정/취소/확정하려는 발화로 보이면 그 맥락에 맞춰 분류하세요. 예) pendingDigest 가 "create_task / 제목=견적서" 인데 발화가 "마감 금요일로" 면 같은 create_task 를 새 마감으로 다시 출력합니다(미리보기가 갱신됩니다). +- 모호한 지시어("그거", "이거", "방금")가 나오면 lastTaskTitle / pendingDigest / [최근 turn] 을 우선 단서로 삼으세요. +- 메모리와 사용자의 가장 최근 발화가 충돌하면 발화를 우선합니다. 메모리는 hint 일 뿐 사실(특히 ID)은 [Plans/Buckets] 컨텍스트에서만 가져오세요. + +## 메모리 갱신 (memoryUpdate, 선택) +응답 tool call 에 memoryUpdate 객체를 같이 채워 메모리를 점진 갱신할 수 있습니다. 변경이 없으면 통째로 생략하세요. +- setTopic: 이번 세션의 큰 주제가 새로 잡히거나 바뀐 경우에만. +- setFocusPlan: 어느 Plan 에서 작업 중인지 명확해진 경우 (planTitle 그대로). +- setLastTaskTitle: 새 작업을 만들거나 기존 작업을 가리킨 경우 그 제목. +- addOpenLoop: 사용자가 확정 안 해서 다음 turn 에 다시 다뤄야 할 thread 가 있을 때 한 줄 요약. +- clearOpenLoop: 그 thread 가 해결됐을 때 (앞서 addOpenLoop 로 넣은 문자열과 정확히 일치해야 함). +- setNotes: 일관된 사용자 선호 패턴이 명확할 때만. 자주 바꾸지 마세요. +- clearAll: 토픽이 완전히 바뀐 경우만 true. + ## 출력 형식 - 단 한 개의 액션만 출력. 부가 설명 없이 도구 호출만 사용.`; @@ -95,8 +111,10 @@ export function renderUserMessage(input: ClassifierInput): string { .join("\n") : "(최근 작업 없음)"; - return `현재 시각(ISO): ${input.nowIso} + const convoBlock = renderConvoCtx(input.convoCtx); + return `현재 시각(ISO): ${input.nowIso} +${convoBlock} [사용 가능한 Plans / Buckets / Members / Labels] ${plansBlock} @@ -107,6 +125,35 @@ ${tasksBlock} ${input.utterance}`; } +function renderConvoCtx(ctx?: ConversationContext): string { + if (!ctx) return "\n"; + const blocks: string[] = []; + + if (ctx.workingMemory) { + const m = ctx.workingMemory; + const lines: string[] = []; + if (m.topic) lines.push(`topic: ${m.topic}`); + if (m.focusPlan) lines.push(`focusPlan: ${m.focusPlan}`); + if (m.lastTaskTitle) lines.push(`lastTaskTitle: ${m.lastTaskTitle}`); + if (m.openLoops?.length) lines.push(`openLoops: ${m.openLoops.map((l) => `"${l}"`).join(", ")}`); + if (m.notes) lines.push(`notes: ${m.notes}`); + if (lines.length) blocks.push(`[작업 메모리]\n${lines.join("\n")}`); + } + + if (ctx.pendingDigest) { + blocks.push(`[현재 미리보기 카드 (pendingDigest)]\n${ctx.pendingDigest}`); + } + + if (ctx.recentTurns.length) { + const turnLines = ctx.recentTurns.map( + (t, i) => `- turn ${i + 1}: user="${t.user}" → bot: ${t.botAction}`, + ); + blocks.push(`[최근 ${ctx.recentTurns.length} turn]\n${turnLines.join("\n")}`); + } + + return blocks.length ? `\n${blocks.join("\n\n")}\n\n` : "\n"; +} + /** * JSON Schema describing the ClassifiedAction discriminated union. * Used by both Claude (tool_use input_schema) and Azure OpenAI (function tool). @@ -167,6 +214,21 @@ export const ACTION_TOOL_SCHEMA = { appendNote: { type: "string", description: "기존 노트 뒤에 덧붙일 진행 메모" }, newTitle: { type: "string" }, question: { type: "string", description: "ask_clarification일 때 필수" }, + memoryUpdate: { + type: "object", + description: + "선택. 작업 메모리에 적용할 패치. 변경이 없으면 통째로 생략하세요.", + properties: { + setTopic: { type: "string" }, + setFocusPlan: { type: "string" }, + setLastTaskTitle: { type: "string" }, + addOpenLoop: { type: "string" }, + clearOpenLoop: { type: "string" }, + setNotes: { type: "string" }, + clearAll: { type: "boolean" }, + }, + additionalProperties: false, + }, }, required: ["type"], additionalProperties: false, diff --git a/src/llm/types.ts b/src/llm/types.ts index 0bbe940..d3501ee 100644 --- a/src/llm/types.ts +++ b/src/llm/types.ts @@ -85,14 +85,76 @@ export type ClassifiedAction = question: string; }; +/** + * Compact "working memory" the LLM maintains across turns. Stored in + * conversationState. The bot reads it before classifying and writes patches + * the LLM emits back into it. + * + * The bot deliberately keeps this short — every field is optional, and the + * combined JSON is truncated/capped at write time so it can never grow + * unbounded. + */ +export interface WorkingMemory { + /** What the user is broadly working on this session. */ + topic?: string; + /** Plan currently in focus (planTitle, not id — semantic memory). */ + focusPlan?: string; + /** Title of the last task created/touched, for "방금 그 작업" references. */ + lastTaskTitle?: string; + /** Threads not yet resolved (max 5; oldest dropped). */ + openLoops?: string[]; + /** Observed user preferences for the session (≤ 500 chars). */ + notes?: string; + /** Unix ms — used to age out volatile fields after idle. */ + updatedAt: number; +} + +/** A single raw turn captured verbatim — kept as a small ring buffer (max 2). */ +export interface RawTurn { + ts: number; + /** User utterance verbatim, truncated to 500 chars. */ + user: string; + /** One-line bot-side summary of the resulting action. */ + botAction: string; +} + +/** Bundle handed to the classifier each turn. */ +export interface ConversationContext { + workingMemory?: WorkingMemory; + recentTurns: RawTurn[]; + /** Derived each turn from PendingAction — never persisted in WorkingMemory. */ + pendingDigest?: string; +} + +/** Patch the LLM emits alongside its action to update working memory. */ +export interface MemoryPatch { + setTopic?: string; + setFocusPlan?: string; + setLastTaskTitle?: string; + /** Appends to openLoops (deduped). */ + addOpenLoop?: string; + /** Removes an existing openLoop by exact match. */ + clearOpenLoop?: string; + setNotes?: string; + /** Resets the whole memory (use only when topic completely changes). */ + clearAll?: boolean; +} + export interface ClassifierInput { utterance: string; plans: PlanContext[]; recentTasks: RecentTaskContext[]; /** ISO-8601 of "now" in the user's TZ, so the LLM can resolve "tomorrow" etc. */ nowIso: string; + /** Conversation memory + recent raw turns. */ + convoCtx?: ConversationContext; +} + +export interface ClassifierResult { + action: ClassifiedAction; + memoryUpdate?: MemoryPatch; } export interface LlmClassifier { - classify(input: ClassifierInput): Promise; + classify(input: ClassifierInput): Promise; }