From d3271fa1e8b43e6edd94acc973ab570734fc7610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Sat, 16 May 2026 15:30:38 +0900 Subject: [PATCH] feat: hybrid conversation memory (working memory + last-2 raw turns) Classifier now receives a ConversationContext: a compact LLM-maintained WorkingMemory (topic/focusPlan/lastTaskTitle/openLoops/notes), the last 2 raw turns, and a pendingDigest derived each turn from the pending action. The LLM emits an optional memoryUpdate patch alongside its action in the same tool call (no extra API hop). Volatile fields decay after 10 min idle, notes truncate at 500 chars, raw turns ring-buffer at 2, openLoops cap at 5. Logout wipes everything. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bot/PlannerBot.ts | 213 ++++++++++++++++++++++++++++++- src/llm/azureOpenAiClassifier.ts | 8 +- src/llm/claudeClassifier.ts | 8 +- src/llm/coerce.ts | 48 ++++++- src/llm/geminiClassifier.ts | 21 ++- src/llm/prompt.ts | 66 +++++++++- src/llm/types.ts | 64 +++++++++- 7 files changed, 410 insertions(+), 18 deletions(-) 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; }