Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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(
Expand Down
20 changes: 20 additions & 0 deletions src/core/tdai-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<MemoryWriteResult> {
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.
*
Expand Down
57 changes: 57 additions & 0 deletions src/core/tools/memory-write.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
117 changes: 117 additions & 0 deletions src/core/tools/memory-write.ts
Original file line number Diff line number Diff line change
@@ -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<MemoryWriteResult> {
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";
}