diff --git a/index.ts b/index.ts index 868a770..b598be9 100644 --- a/index.ts +++ b/index.ts @@ -434,6 +434,85 @@ export default function register(api: OpenClawPluginApi) { { name: "tdai_memory_search" }, ); + api.registerTool( + { + name: "tdai_memory_write", + label: "Memory Write", + description: + "Write an explicit, user-confirmed entry into long-term memory. " + + "Use sparingly for durable facts, preferences, or instructions that should be remembered across sessions. " + + "Do not use for transient task progress or unverified guesses.", + parameters: { + type: "object", + properties: { + content: { + type: "string", + description: "The memory content to store", + }, + type: { + type: "string", + enum: ["persona", "episodic", "instruction"], + description: "Memory type. Defaults to episodic.", + }, + scene_name: { + type: "string", + description: "Optional scene/category name. Defaults to manual.", + }, + }, + required: ["content"], + }, + async execute(_toolCallId: string, params: Record) { + const startMs = Date.now(); + const content = String(params.content ?? ""); + const type = typeof params.type === "string" ? params.type : undefined; + const sceneName = typeof params.scene_name === "string" ? params.scene_name : undefined; + + api.logger.debug?.( + `${TAG} [tool] tdai_memory_write called: ` + + `contentLen=${content.length}, type=${type ?? "(default)"}, scene=${sceneName ?? "(default)"}`, + ); + + try { + const result = await core.writeMemory({ content, type, sceneName }); + const elapsedMs = Date.now() - startMs; + api.logger.debug?.( + `${TAG} [tool] tdai_memory_write completed (${elapsedMs}ms): id=${result.record.id}`, + ); + report("tool_call", { + tool: "tdai_memory_write", + type: result.record.type, + sceneName: result.record.scene_name, + durationMs: elapsedMs, + success: true, + }); + return { + content: [{ type: "text" as const, text: result.text }], + details: { + id: result.record.id, + type: result.record.type, + scene_name: result.record.scene_name, + }, + }; + } catch (err) { + const elapsedMs = Date.now() - startMs; + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.error(`${TAG} [tool] tdai_memory_write failed (${elapsedMs}ms): ${errMsg}`); + report("tool_call", { + tool: "tdai_memory_write", + durationMs: elapsedMs, + success: false, + error: errMsg, + }); + return { + content: [{ type: "text" as const, text: `Memory write failed: ${errMsg}` }], + details: { error: errMsg }, + }; + } + }, + }, + { name: "tdai_memory_write" }, + ); + // tdai_conversation_search — Agent-callable L0 conversation search tool // TODO: implement hard per-turn call limit via before_tool_call hook + execute early-return (方案 D) api.registerTool( diff --git a/src/core/tdai-core.ts b/src/core/tdai-core.ts index 977d4a2..0d7362a 100644 --- a/src/core/tdai-core.ts +++ b/src/core/tdai-core.ts @@ -36,6 +36,7 @@ import { performAutoRecall } from "./hooks/auto-recall.js"; import { performAutoCapture } from "./hooks/auto-capture.js"; import { executeMemorySearch, formatSearchResponse } from "./tools/memory-search.js"; import { executeConversationSearch, formatConversationSearchResponse } from "./tools/conversation-search.js"; +import { executeMemoryWrite, type MemoryWriteParams, type MemoryWriteResult } from "./tools/memory-write.js"; import { initDataDirectories, initStores, @@ -325,6 +326,25 @@ export class TdaiCore { }; } + /** + * Explicitly write a long-term L1 memory. + * + * This supports agent/tool-initiated writes for contexts that cannot rely on + * L0 auto-capture. It stores a new record only; it deliberately does not + * update/merge existing records because that would bypass the L1 dedup prompt. + */ + async writeMemory(params: MemoryWriteParams): Promise { + await this.storeReady?.catch(() => {}); + + return executeMemoryWrite({ + params, + dataDir: this.dataDir, + vectorStore: this.vectorStore, + embeddingService: this.embeddingService, + logger: this.logger, + }); + } + /** * Handle end-of-conversation for a single session. * diff --git a/src/core/tools/memory-write.test.ts b/src/core/tools/memory-write.test.ts new file mode 100644 index 0000000..9d9db63 --- /dev/null +++ b/src/core/tools/memory-write.test.ts @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { executeMemoryWrite } from "./memory-write.js"; + +const tempDirs: string[] = []; + +describe("executeMemoryWrite", () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + }); + + it("stores an explicit memory in the L1 JSONL schema", async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-memory-write-")); + tempDirs.push(dataDir); + + const result = await executeMemoryWrite({ + dataDir, + params: { + content: "User prefers concise status updates.", + type: "instruction", + sceneName: "collaboration", + sessionKey: "subagent-session", + }, + }); + + expect(result.record.content).toBe("User prefers concise status updates."); + expect(result.record.type).toBe("instruction"); + expect(result.record.scene_name).toBe("collaboration"); + expect(result.record.sessionKey).toBe("subagent-session"); + expect(result.record.source_message_ids[0]).toMatch(/^manual:m_/); + expect(result.text).toContain(result.record.id); + + const files = await fs.readdir(path.join(dataDir, "records")); + expect(files).toHaveLength(1); + const lines = (await fs.readFile(path.join(dataDir, "records", files[0]!), "utf-8")).trim().split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!).id).toBe(result.record.id); + }); + + it("rejects empty content", async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-memory-write-")); + tempDirs.push(dataDir); + + await expect(executeMemoryWrite({ dataDir, params: { content: " " } })).rejects.toThrow("content is required"); + }); + + it("rejects invalid memory types", async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-memory-write-")); + tempDirs.push(dataDir); + + await expect( + executeMemoryWrite({ dataDir, params: { content: "Remember this.", type: "temporary" } }), + ).rejects.toThrow("Invalid memory type"); + }); +}); diff --git a/src/core/tools/memory-write.ts b/src/core/tools/memory-write.ts new file mode 100644 index 0000000..8838919 --- /dev/null +++ b/src/core/tools/memory-write.ts @@ -0,0 +1,117 @@ +/** + * tdai_memory_write tool: explicit long-term memory insertion. + * + * This is intentionally store-only. It does not update or merge existing + * memories, so direct writes cannot silently rewrite L1 records while bypassing + * the L1 extraction conflict-resolution prompt. + */ + +import type { IMemoryStore } from "../store/types.js"; +import type { EmbeddingService } from "../store/embedding.js"; +import type { Logger } from "../types.js"; +import { generateMemoryId, writeMemory, type MemoryRecord, type MemoryType } from "../record/l1-writer.js"; + +const VALID_TYPES: MemoryType[] = ["persona", "episodic", "instruction"]; +const DEFAULT_TYPE: MemoryType = "episodic"; +const DEFAULT_SCENE = "manual"; +const DEFAULT_PRIORITY = 50; +const MAX_CONTENT_CHARS = 4000; +const TAG = "[memory-tdai][tdai_memory_write]"; + +export interface MemoryWriteParams { + content: string; + type?: string; + sceneName?: string; + sessionKey?: string; + sessionId?: string; +} + +export interface MemoryWriteResult { + record: MemoryRecord; + text: string; +} + +export async function executeMemoryWrite(opts: { + params: MemoryWriteParams; + dataDir: string; + vectorStore?: IMemoryStore; + embeddingService?: EmbeddingService; + logger?: Logger; +}): Promise { + const content = normalizeContent(opts.params.content); + const type = normalizeType(opts.params.type); + const sceneName = normalizeSceneName(opts.params.sceneName); + const sessionKey = normalizeSessionKey(opts.params.sessionKey); + const sessionId = opts.params.sessionId?.trim() ?? ""; + const now = new Date().toISOString(); + const recordId = generateMemoryId(); + + const record = await writeMemory({ + memory: { + content, + type, + priority: DEFAULT_PRIORITY, + scene_name: sceneName, + source_message_ids: [`manual:${recordId}`], + metadata: { + source: "tdai_memory_write", + written_at: now, + }, + }, + decision: { + record_id: recordId, + action: "store", + target_ids: [], + }, + baseDir: opts.dataDir, + sessionKey, + sessionId, + logger: opts.logger, + vectorStore: opts.vectorStore, + embeddingService: opts.embeddingService, + }); + + if (!record) { + throw new Error("Memory write returned no record"); + } + + opts.logger?.debug?.( + `${TAG} Stored memory id=${record.id} type=${record.type} scene=${record.scene_name} ` + + `sessionKey=${record.sessionKey} contentLen=${record.content.length}`, + ); + + return { + record, + text: `Memory written: ${record.id} (${record.type}, scene=${record.scene_name})`, + }; +} + +function normalizeContent(raw: string): string { + const content = String(raw ?? "").trim(); + if (!content) { + throw new Error("content is required"); + } + if (content.length > MAX_CONTENT_CHARS) { + throw new Error(`content is too long (max ${MAX_CONTENT_CHARS} characters)`); + } + return content; +} + +function normalizeType(raw: string | undefined): MemoryType { + if (!raw) return DEFAULT_TYPE; + const normalized = raw.trim().toLowerCase(); + if (VALID_TYPES.includes(normalized as MemoryType)) { + return normalized as MemoryType; + } + throw new Error(`Invalid memory type "${raw}". Expected one of: ${VALID_TYPES.join(", ")}`); +} + +function normalizeSceneName(raw: string | undefined): string { + const sceneName = raw?.trim(); + return sceneName || DEFAULT_SCENE; +} + +function normalizeSessionKey(raw: string | undefined): string { + const sessionKey = raw?.trim(); + return sessionKey || "manual-write"; +}