feat: hybrid conversation memory (working memory + last-2 raw turns)
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 32s

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) <noreply@anthropic.com>
This commit is contained in:
윤정민 2026-05-16 15:30:38 +09:00
parent 01616c4526
commit d3271fa1e8
7 changed files with 410 additions and 18 deletions

View File

@ -10,7 +10,16 @@ import { UserTokenClient } from "botframework-connector";
import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs"; import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs";
import { createGraphClient } from "../graph/graphClientFactory"; import { createGraphClient } from "../graph/graphClientFactory";
import { PlannerClient } from "../graph/plannerClient"; 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 { import {
ActionContext, ActionContext,
buildClarificationCard, buildClarificationCard,
@ -36,6 +45,15 @@ async function signOutUser(context: TurnContext, connectionName: string): Promis
const OAUTH_PROMPT = "graphOAuthPrompt"; const OAUTH_PROMPT = "graphOAuthPrompt";
const MAIN_DIALOG = "mainDialog"; 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 { interface PendingAction {
action: Exclude<ClassifiedAction, { type: "ask_clarification" }>; action: Exclude<ClassifiedAction, { type: "ask_clarification" }>;
ctx: ActionContext; ctx: ActionContext;
@ -70,6 +88,8 @@ export class PlannerBot extends TeamsActivityHandler {
private readonly dialogs: DialogSet; private readonly dialogs: DialogSet;
private readonly dialogStateAccessor: StatePropertyAccessor; private readonly dialogStateAccessor: StatePropertyAccessor;
private readonly pendingAccessor: StatePropertyAccessor<PendingAction | undefined>; private readonly pendingAccessor: StatePropertyAccessor<PendingAction | undefined>;
private readonly memoryAccessor: StatePropertyAccessor<WorkingMemory | undefined>;
private readonly rawTurnsAccessor: StatePropertyAccessor<RawTurn[] | undefined>;
private readonly connectionName: string; private readonly connectionName: string;
constructor(deps: PlannerBotDeps) { constructor(deps: PlannerBotDeps) {
@ -83,6 +103,12 @@ export class PlannerBot extends TeamsActivityHandler {
this.pendingAccessor = this.conversationState.createProperty<PendingAction | undefined>( this.pendingAccessor = this.conversationState.createProperty<PendingAction | undefined>(
"PendingAction", "PendingAction",
); );
this.memoryAccessor = this.conversationState.createProperty<WorkingMemory | undefined>(
"WorkingMemory",
);
this.rawTurnsAccessor = this.conversationState.createProperty<RawTurn[] | undefined>(
"RawTurns",
);
this.dialogs = new DialogSet(this.dialogStateAccessor); this.dialogs = new DialogSet(this.dialogStateAccessor);
this.dialogs.add( this.dialogs.add(
@ -139,6 +165,7 @@ export class PlannerBot extends TeamsActivityHandler {
if (text.toLowerCase() === "logout") { if (text.toLowerCase() === "logout") {
await signOutUser(context, this.connectionName); await signOutUser(context, this.connectionName);
await this.pendingAccessor.set(context, undefined); await this.pendingAccessor.set(context, undefined);
await this.wipeMemory(context);
await context.sendActivity("로그아웃 완료."); await context.sendActivity("로그아웃 완료.");
await next(); await next();
return; return;
@ -227,16 +254,20 @@ export class PlannerBot extends TeamsActivityHandler {
const validPlanIds = new Set(plans.map((p) => p.planId)); const validPlanIds = new Set(plans.map((p) => p.planId));
recentTasks = recentTasks.filter((t) => validPlanIds.has(t.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, utterance,
plans, plans,
recentTasks, recentTasks,
nowIso: new Date().toISOString(), nowIso: new Date().toISOString(),
convoCtx,
}); });
await this.applyMemoryUpdate(context, memoryUpdate);
if (action.type === "ask_clarification") { if (action.type === "ask_clarification") {
await this.pendingAccessor.set(context, undefined); await this.pendingAccessor.set(context, undefined);
await context.sendActivity({ attachments: [buildClarificationCard(action.question)] }); await context.sendActivity({ attachments: [buildClarificationCard(action.question)] });
await this.pushTurn(context, utterance, `ask_clarification: "${action.question}"`);
return; return;
} }
@ -256,6 +287,7 @@ export class PlannerBot extends TeamsActivityHandler {
), ),
], ],
}); });
await this.pushTurn(context, utterance, "ask_clarification(internal): 대상 task 미발견");
return; return;
} }
plan = plans.find((p) => p.planId === task!.planId); plan = plans.find((p) => p.planId === task!.planId);
@ -264,6 +296,7 @@ export class PlannerBot extends TeamsActivityHandler {
await context.sendActivity( await context.sendActivity(
"대상 Plan을 찾을 수 없었어요. 다시 한 번 말씀해 주시겠어요?", "대상 Plan을 찾을 수 없었어요. 다시 한 번 말씀해 주시겠어요?",
); );
await this.pushTurn(context, utterance, "error: 대상 Plan 미발견");
return; return;
} }
@ -342,6 +375,7 @@ export class PlannerBot extends TeamsActivityHandler {
await context.sendActivity({ await context.sendActivity({
attachments: [buildPreviewCard({ action: pending.action, ctx })], attachments: [buildPreviewCard({ action: pending.action, ctx })],
}); });
await this.pushTurn(context, utterance, `preview: ${digestAction(pending.action, ctx)}`);
} }
// -------- Confirm: actually apply ---------------------------------------- // -------- Confirm: actually apply ----------------------------------------
@ -443,12 +477,22 @@ export class PlannerBot extends TeamsActivityHandler {
await context.sendActivity({ await context.sendActivity({
attachments: [buildResultCard({ action, ctx, status: "done" })], attachments: [buildResultCard({ action, ctx, status: "done" })],
}); });
await this.pushTurn(
context,
"[✅ 확인 버튼]",
`applied: ${digestAction(action, ctx)}`,
);
} catch (err) { } catch (err) {
await context.sendActivity({ await context.sendActivity({
attachments: [ attachments: [
buildResultCard({ action, ctx, status: "error", errorMessage: (err as Error).message }), 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" }), 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<ConversationContext> {
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<void> {
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<void> {
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<void> {
await this.memoryAccessor.set(context, undefined);
await this.rawTurnsAccessor.set(context, undefined);
} }
} }
@ -502,3 +606,108 @@ function dedupePreserveOrder<T>(arr: T[]): T[] {
} }
return out; 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<ClassifiedAction, { type: "ask_clarification" }>,
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(" / ");
}

View File

@ -1,7 +1,7 @@
import { AzureOpenAI } from "openai"; import { AzureOpenAI } from "openai";
import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt"; import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt";
import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types"; import { ClassifierInput, ClassifierResult, LlmClassifier } from "./types";
import { coerceAction } from "./coerce"; import { coerceClassifierResult } from "./coerce";
export class AzureOpenAIClassifier implements LlmClassifier { export class AzureOpenAIClassifier implements LlmClassifier {
private readonly client: AzureOpenAI; private readonly client: AzureOpenAI;
@ -22,7 +22,7 @@ export class AzureOpenAIClassifier implements LlmClassifier {
this.deployment = opts.deployment; this.deployment = opts.deployment;
} }
async classify(input: ClassifierInput): Promise<ClassifiedAction> { async classify(input: ClassifierInput): Promise<ClassifierResult> {
const response = await this.client.chat.completions.create({ const response = await this.client.chat.completions.create({
model: this.deployment, model: this.deployment,
temperature: 0, temperature: 0,
@ -51,6 +51,6 @@ export class AzureOpenAIClassifier implements LlmClassifier {
throw new Error("Azure OpenAI did not return a tool call"); 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));
} }
} }

View File

@ -1,7 +1,7 @@
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk";
import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt"; import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt";
import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types"; import { ClassifierInput, ClassifierResult, LlmClassifier } from "./types";
import { coerceAction } from "./coerce"; import { coerceClassifierResult } from "./coerce";
export class ClaudeClassifier implements LlmClassifier { export class ClaudeClassifier implements LlmClassifier {
private readonly client: Anthropic; private readonly client: Anthropic;
@ -12,7 +12,7 @@ export class ClaudeClassifier implements LlmClassifier {
this.model = opts.model; this.model = opts.model;
} }
async classify(input: ClassifierInput): Promise<ClassifiedAction> { async classify(input: ClassifierInput): Promise<ClassifierResult> {
const response = await this.client.messages.create({ const response = await this.client.messages.create({
model: this.model, model: this.model,
max_tokens: 1024, max_tokens: 1024,
@ -33,6 +33,6 @@ export class ClaudeClassifier implements LlmClassifier {
throw new Error("Claude did not return a tool_use block"); throw new Error("Claude did not return a tool_use block");
} }
return coerceAction(toolUse.input); return coerceClassifierResult(toolUse.input);
} }
} }

View File

@ -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<string, unknown>).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<string, unknown>;
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. * Validates and narrows the raw JSON returned by the LLM into a ClassifiedAction.

View File

@ -1,7 +1,7 @@
import {FunctionCallingConfigMode, GoogleGenAI, ThinkingLevel, Type} from "@google/genai"; import {FunctionCallingConfigMode, GoogleGenAI, ThinkingLevel, Type} from "@google/genai";
import {ACTION_TOOL_SCHEMA, renderUserMessage, SYSTEM_PROMPT} from "./prompt"; import {ACTION_TOOL_SCHEMA, renderUserMessage, SYSTEM_PROMPT} from "./prompt";
import {ClassifiedAction, ClassifierInput, LlmClassifier} from "./types"; import {ClassifierInput, ClassifierResult, LlmClassifier} from "./types";
import {coerceAction} from "./coerce"; import {coerceClassifierResult} from "./coerce";
/** /**
* Gemini's function-declaration schema uses Type.* enums instead of JSON-Schema strings. * Gemini's function-declaration schema uses Type.* enums instead of JSON-Schema strings.
@ -59,6 +59,19 @@ const GEMINI_SCHEMA = {
appendNote: { type: Type.STRING }, appendNote: { type: Type.STRING },
newTitle: { type: Type.STRING }, newTitle: { type: Type.STRING },
question: { type: Type.STRING, description: "ask_clarification일 때 필수" }, 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"], required: ["type"],
}; };
@ -72,7 +85,7 @@ export class GeminiClassifier implements LlmClassifier {
this.model = opts.model; this.model = opts.model;
} }
async classify(input: ClassifierInput): Promise<ClassifiedAction> { async classify(input: ClassifierInput): Promise<ClassifierResult> {
const response = await this.client.models.generateContent({ const response = await this.client.models.generateContent({
model: this.model, model: this.model,
contents: [{ role: "user", parts: [{ text: renderUserMessage(input) }] }], 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"); throw new Error("Gemini did not return a function call with args");
} }
return coerceAction(call.args); return coerceClassifierResult(call.args);
} }
} }

View File

@ -1,4 +1,4 @@
import { ClassifierInput } from "./types"; import { ClassifierInput, ConversationContext } from "./types";
export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 보고를 Microsoft Planner 액션 하나로 변환하는 분류기입니다. export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 보고를 Microsoft Planner 액션 하나로 변환하는 분류기입니다.
@ -49,6 +49,22 @@ export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업
- "X 버킷으로 옮겨", "검토 중 버킷으로" plan buckets bucketId newBucketId . - "X 버킷으로 옮겨", "검토 중 버킷으로" plan buckets bucketId newBucketId .
- (: "이제 완료 됐어" "Done" ) . , plan . - (: "이제 완료 됐어" "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") .join("\n")
: "(최근 작업 없음)"; : "(최근 작업 없음)";
return `현재 시각(ISO): ${input.nowIso} const convoBlock = renderConvoCtx(input.convoCtx);
return `현재 시각(ISO): ${input.nowIso}
${convoBlock}
[ Plans / Buckets / Members / Labels] [ Plans / Buckets / Members / Labels]
${plansBlock} ${plansBlock}
@ -107,6 +125,35 @@ ${tasksBlock}
${input.utterance}`; ${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. * JSON Schema describing the ClassifiedAction discriminated union.
* Used by both Claude (tool_use input_schema) and Azure OpenAI (function tool). * 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: "기존 노트 뒤에 덧붙일 진행 메모" }, appendNote: { type: "string", description: "기존 노트 뒤에 덧붙일 진행 메모" },
newTitle: { type: "string" }, newTitle: { type: "string" },
question: { type: "string", description: "ask_clarification일 때 필수" }, 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"], required: ["type"],
additionalProperties: false, additionalProperties: false,

View File

@ -85,14 +85,76 @@ export type ClassifiedAction =
question: string; 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 { export interface ClassifierInput {
utterance: string; utterance: string;
plans: PlanContext[]; plans: PlanContext[];
recentTasks: RecentTaskContext[]; recentTasks: RecentTaskContext[];
/** ISO-8601 of "now" in the user's TZ, so the LLM can resolve "tomorrow" etc. */ /** ISO-8601 of "now" in the user's TZ, so the LLM can resolve "tomorrow" etc. */
nowIso: string; nowIso: string;
/** Conversation memory + recent raw turns. */
convoCtx?: ConversationContext;
}
export interface ClassifierResult {
action: ClassifiedAction;
memoryUpdate?: MemoryPatch;
} }
export interface LlmClassifier { export interface LlmClassifier {
classify(input: ClassifierInput): Promise<ClassifiedAction>; classify(input: ClassifierInput): Promise<ClassifierResult>;
} }