diff --git a/src/slack-agent-tools.ts b/src/slack-agent-tools.ts index 0d8c63d..52ac458 100644 --- a/src/slack-agent-tools.ts +++ b/src/slack-agent-tools.ts @@ -18,10 +18,17 @@ import type Database from "better-sqlite3"; import { + createMemory, + createChildMemory, listMemories, + readMemoryById, readMemoryWithTeamAccess, listTeamMemories, listChildren, + shareMemoryToTeam, + softDeleteMemory, + unshareMemory, + updateMemory, type MemoryEntry, } from "./memory-service.js"; import { getGraphAround } from "./memory-graph.js"; @@ -176,6 +183,95 @@ export function buildAgentTools(ctx: AgentToolContext): AgentTools { required: ["memory_id"], }, }, + { + name: "write_memory", + description: + "Create a new memory. Personal by default. Set share_with_team=true ONLY if the user explicitly asks to share it with the team (\"write a team note...\", \"share this with the team\", etc.) — otherwise default to false. Pass tags as a flat string array. Returns the new memory id.", + input_schema: { + type: "object", + properties: { + title: { type: "string", description: "Short title (1-500 chars)" }, + content: { type: "string", description: "The memory content" }, + tags: { + type: "array", + items: { type: "string" }, + description: "Tag list. Follow the user's existing tagging conventions (call get_memory_briefing first if unsure).", + }, + share_with_team: { + type: "boolean", + description: "If true, share with the user's team after creation. Default false.", + }, + }, + required: ["title", "content", "tags"], + }, + }, + { + name: "write_child_memory", + description: + "Create a reply (child) memory under an existing parent memory. Threads are one level deep — you can reply to a top-level memory but not to a reply. Children inherit team sharing from the parent automatically. Use for status updates, resolutions, follow-ups on a thread.", + input_schema: { + type: "object", + properties: { + parent_id: { type: "string", description: "UUID of the parent memory to reply to" }, + title: { type: "string", description: "Short title for the reply" }, + content: { type: "string", description: "The reply content" }, + tags: { + type: "array", + items: { type: "string" }, + description: "Tag list", + }, + }, + required: ["parent_id", "title", "content", "tags"], + }, + }, + { + name: "update_memory", + description: + "Edit one of the user's existing memories — REPLACES title, content, and tags wholesale. Use ONLY for memories the user authored. NEVER use to add a status update on a teammate's memory or on a thread someone else is participating in — that destroys their text. For follow-ups, use write_child_memory instead. Pass the COMPLETE new title/content/tags (not a diff).", + input_schema: { + type: "object", + properties: { + id: { type: "string", description: "UUID of the memory to update" }, + title: { type: "string", description: "New full title" }, + content: { type: "string", description: "New full content" }, + tags: { + type: "array", + items: { type: "string" }, + description: "New full tag list (replaces existing)", + }, + }, + required: ["id", "title", "content", "tags"], + }, + }, + { + name: "share_memory", + description: + "Share or unshare one of the user's personal memories with their team. Set share=true to share, share=false to unshare (the personal copy is preserved either way). User must be on a team.", + input_schema: { + type: "object", + properties: { + id: { type: "string", description: "UUID of the user's memory" }, + share: { type: "boolean", description: "true to share with team, false to unshare" }, + }, + required: ["id", "share"], + }, + }, + { + name: "delete_memory", + description: + "Soft-delete one of the user's memories (movable to Trash; restorable from the dashboard). DESTRUCTIVE — REQUIRES TWO-STEP CONFIRMATION:\n 1. First call: pass confirm=false. The tool will return a preview of the memory's title/tags. SHOW THIS PREVIEW TO THE USER and ask them to reply 'yes' to confirm.\n 2. Only after the user has explicitly confirmed in this conversation, call again with confirm=true to actually delete.\nNever set confirm=true on the first call. Never delete without showing the preview first.", + input_schema: { + type: "object", + properties: { + id: { type: "string", description: "UUID of the memory to delete" }, + confirm: { + type: "boolean", + description: "false (default) returns a preview; true actually deletes. Only set true after explicit user confirmation in chat.", + }, + }, + required: ["id"], + }, + }, ]; async function execute(name: string, input: Record): Promise { @@ -285,6 +381,143 @@ export function buildAgentTools(ctx: AgentToolContext): AgentTools { if (!graph) return JSON.stringify({ error: "Memory not found" }); return JSON.stringify(graph); } + case "write_memory": { + const title = String(input.title ?? "").trim(); + const content = String(input.content ?? "").trim(); + const tags = Array.isArray(input.tags) + ? (input.tags as unknown[]).map((t) => String(t).trim()).filter(Boolean) + : []; + if (!title || !content) { + return JSON.stringify({ error: "title and content are required" }); + } + const created = createMemory(db, reflectUserId, { + title, + content, + tags, + allowed_vendors: ["*"], + memory_type: "semantic", + origin: "slack", + }); + let shared: string | null = null; + if (input.share_with_team === true) { + const teamRow = db + .prepare(`SELECT team_id FROM users WHERE id = ?`) + .get(reflectUserId) as { team_id: string | null } | undefined; + if (teamRow?.team_id) { + const sharedMemory = shareMemoryToTeam(db, created.id, reflectUserId, teamRow.team_id); + if (sharedMemory) shared = teamRow.team_id; + } + } + return JSON.stringify({ + ok: true, + memory: trimMemoryForLlm(created), + shared_with_team_id: shared, + }); + } + case "write_child_memory": { + const parentId = String(input.parent_id ?? "").trim(); + const title = String(input.title ?? "").trim(); + const content = String(input.content ?? "").trim(); + const tags = Array.isArray(input.tags) + ? (input.tags as unknown[]).map((t) => String(t).trim()).filter(Boolean) + : []; + if (!parentId || !title || !content) { + return JSON.stringify({ error: "parent_id, title, and content are required" }); + } + try { + const child = createChildMemory(db, reflectUserId, parentId, { + title, + content, + tags, + allowed_vendors: ["*"], + memory_type: "semantic", + origin: "slack", + }); + return JSON.stringify({ ok: true, memory: trimMemoryForLlm(child) }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return JSON.stringify({ error: `Could not write child: ${msg}` }); + } + } + case "update_memory": { + const id = String(input.id ?? "").trim(); + const title = String(input.title ?? "").trim(); + const content = String(input.content ?? "").trim(); + const tags = Array.isArray(input.tags) + ? (input.tags as unknown[]).map((t) => String(t).trim()).filter(Boolean) + : []; + if (!id || !title || !content) { + return JSON.stringify({ error: "id, title, and content are required" }); + } + // Must own it (memory-service checks; we double-check for a clean error message). + const existing = readMemoryById(db, reflectUserId, id); + if (!existing) { + return JSON.stringify({ + error: "Memory not found, or you don't own it. Use write_child_memory to reply on a teammate's memory instead.", + }); + } + const updated = updateMemory(db, reflectUserId, id, { + title, + content, + tags, + allowed_vendors: existing.allowed_vendors, + }); + if (!updated) return JSON.stringify({ error: "Update failed" }); + return JSON.stringify({ ok: true, memory: trimMemoryForLlm(updated) }); + } + case "share_memory": { + const id = String(input.id ?? "").trim(); + const share = input.share === true; + if (!id) return JSON.stringify({ error: "id is required" }); + const existing = readMemoryById(db, reflectUserId, id); + if (!existing) { + return JSON.stringify({ error: "Memory not found or you don't own it" }); + } + if (share) { + const teamRow = db + .prepare(`SELECT team_id FROM users WHERE id = ?`) + .get(reflectUserId) as { team_id: string | null } | undefined; + if (!teamRow?.team_id) { + return JSON.stringify({ error: "You are not on a team — nothing to share with" }); + } + const sharedMemory = shareMemoryToTeam(db, id, reflectUserId, teamRow.team_id); + if (!sharedMemory) return JSON.stringify({ error: "Share failed" }); + return JSON.stringify({ ok: true, shared: true, memory: trimMemoryForLlm(sharedMemory) }); + } + const unsharedMemory = unshareMemory(db, id, reflectUserId); + if (!unsharedMemory) return JSON.stringify({ error: "Unshare failed" }); + return JSON.stringify({ ok: true, shared: false, memory: trimMemoryForLlm(unsharedMemory) }); + } + case "delete_memory": { + const id = String(input.id ?? "").trim(); + const confirm = input.confirm === true; + if (!id) return JSON.stringify({ error: "id is required" }); + const existing = readMemoryById(db, reflectUserId, id); + if (!existing) { + return JSON.stringify({ error: "Memory not found or you don't own it" }); + } + if (!confirm) { + return JSON.stringify({ + ok: true, + preview: { + id: existing.id, + title: existing.title, + tags: existing.tags, + content_preview: existing.content.slice(0, 200), + }, + instruction: + "DESTRUCTIVE: show this preview to the user and ask them to reply 'yes' to delete. Only call delete_memory again with confirm=true after they have explicitly confirmed.", + }); + } + const deleted = softDeleteMemory(db, reflectUserId, id); + if (!deleted) return JSON.stringify({ error: "Delete failed" }); + return JSON.stringify({ + ok: true, + deleted: true, + id, + note: "Soft-deleted (moved to Trash). Restorable from the dashboard.", + }); + } default: return JSON.stringify({ error: `Unknown tool: ${name}` }); } diff --git a/src/slack-agent.ts b/src/slack-agent.ts index 54e9fd7..93cc3bc 100644 --- a/src/slack-agent.ts +++ b/src/slack-agent.ts @@ -66,14 +66,27 @@ function buildSystemPrompt(args: { speakerLine, modeLine, "", - "You have read-only tools to read and search the user's personal memories AND any memories shared with their team. You operate as that user — what they can see, you can see; you cannot read other users' personal memories.", + "You have tools to read, search, and (carefully) write the user's personal memories AND memories shared with their team. You operate as that user — what they can see, you can see; you cannot read other users' personal memories.", "", - "Guidelines:", + "Reading guidelines:", "- Be concise. Slack messages should be short — usually under ~1500 characters. If a list is long, summarise and offer to dig into specifics on request.", "- When you need information, prefer a tool call over guessing. For open-ended questions (\"what's going on\", \"summarise X\", \"what did I work on this week\"), call get_memory_briefing first — it gives you the topic clusters, active tags, and open threads in one shot.", "- When citing a specific memory, include its title and (in parentheses) its short id (first 8 chars of the UUID).", "- If you can't find something, say so plainly. Don't invent.", - "- Markdown: Slack supports *bold* (single asterisks), _italic_ (single underscores), `code`, and bullet lists. Don't use **double** asterisks for bold.", + "", + "Writing guidelines:", + "- write_memory creates a personal memory by default. Set share_with_team=true ONLY when the user explicitly asks (\"write a team note...\", \"share this with the team\"). When unsure, default to personal.", + "- For follow-ups on an existing memory or thread, use write_child_memory — never use update_memory to add a status update on a teammate's memory (that destroys their text).", + "- update_memory does a wholesale title/content/tags REPLACE — pass the COMPLETE new values, not a diff. Only use on memories the user authored.", + "- Match the user's existing tagging conventions. Call get_memory_briefing if you don't already know them (it lists active tags + detected conventions).", + "", + "Destructive actions (delete_memory):", + "- ALWAYS first call delete_memory with confirm=false to get the preview.", + "- Show the preview (title + a snippet) in your reply and ask the user to reply 'yes' to confirm.", + "- ONLY after they have explicitly typed 'yes' (or equivalent affirmative) in this conversation, call delete_memory again with confirm=true.", + "- If they say no, say something else, or change topic — do not delete.", + "", + "Markdown: Slack supports *bold* (single asterisks), _italic_ (single underscores), `code`, and bullet lists. Don't use **double** asterisks for bold.", "", `Today's date: ${today}.`, ].join("\n"); diff --git a/tests/integration/slack-agent.test.ts b/tests/integration/slack-agent.test.ts index fbc3b33..62b98da 100644 --- a/tests/integration/slack-agent.test.ts +++ b/tests/integration/slack-agent.test.ts @@ -13,7 +13,7 @@ import { getTestServer } from "../helpers"; import { _resetMasterKeyCacheForTests } from "../../src/llm-key-crypto"; import { buildAgentTools } from "../../src/slack-agent-tools"; import { runSlackAgentTurn } from "../../src/slack-agent"; -import { createMemory } from "../../src/memory-service"; +import { createMemory, readMemoryById } from "../../src/memory-service"; process.env.RM_LLM_KEY_ENCRYPTION_KEY = getTestServer().llmKeyMasterKey; _resetMasterKeyCacheForTests(); @@ -174,6 +174,172 @@ describe("buildAgentTools.execute (unit, real DB)", () => { db.close(); expect(JSON.parse(out)).toEqual({ error: "Unknown tool: definitely_not_a_tool" }); }); + + // ------------------------------------------------------------------------- + // Write tools + // ------------------------------------------------------------------------- + + it("write_memory creates a personal memory by default (not shared)", async () => { + const db = openDb(); + const tools = buildAgentTools({ db, reflectUserId: userId }); + const out = await tools.execute("write_memory", { + title: `Slack write test ${randomUUID()}`, + content: `Test content ${randomUUID()}`, + tags: ["test", "slack-write-test"], + }); + db.close(); + const parsed = JSON.parse(out) as { + ok: boolean; + memory: { id: string; shared_with_team_id: string | null }; + shared_with_team_id: string | null; + }; + expect(parsed.ok).toBe(true); + expect(parsed.memory.id).toBeTruthy(); + expect(parsed.shared_with_team_id).toBeNull(); + expect(parsed.memory.shared_with_team_id).toBeNull(); + }); + + it("write_memory rejects empty title or content", async () => { + const db = openDb(); + const tools = buildAgentTools({ db, reflectUserId: userId }); + const out = await tools.execute("write_memory", { title: "", content: "ok", tags: [] }); + db.close(); + expect(JSON.parse(out)).toEqual({ error: "title and content are required" }); + }); + + it("write_child_memory creates a child under a parent", async () => { + const db = openDb(); + const tools = buildAgentTools({ db, reflectUserId: userId }); + const out = await tools.execute("write_child_memory", { + parent_id: seededIds[0], + title: `Child reply ${randomUUID()}`, + content: `Child content ${randomUUID()}`, + tags: ["test", "reply"], + }); + db.close(); + const parsed = JSON.parse(out) as { + ok: boolean; + memory: { id: string; parent_memory_id: string | null }; + }; + expect(parsed.ok).toBe(true); + expect(parsed.memory.parent_memory_id).toBe(seededIds[0]); + }); + + it("update_memory replaces title + content + tags wholesale", async () => { + const db = openDb(); + // First create something we can mutate. + const created = await buildAgentTools({ db, reflectUserId: userId }).execute( + "write_memory", + { + title: `To update ${randomUUID()}`, + content: `Original content ${randomUUID()}`, + tags: ["test", "to-update"], + }, + ); + const createdId = (JSON.parse(created) as { memory: { id: string } }).memory.id; + + const tools = buildAgentTools({ db, reflectUserId: userId }); + const out = await tools.execute("update_memory", { + id: createdId, + title: "Updated title", + content: "Updated content body", + tags: ["test", "updated"], + }); + db.close(); + const parsed = JSON.parse(out) as { + ok: boolean; + memory: { title: string; content: string; tags: string[] }; + }; + expect(parsed.ok).toBe(true); + expect(parsed.memory.title).toBe("Updated title"); + expect(parsed.memory.content).toBe("Updated content body"); + expect(parsed.memory.tags).toContain("updated"); + }); + + it("update_memory refuses to update a memory the user doesn't own", async () => { + const db = openDb(); + const tools = buildAgentTools({ db, reflectUserId: userId }); + const out = await tools.execute("update_memory", { + id: randomUUID(), + title: "x", + content: "x", + tags: [], + }); + db.close(); + expect(JSON.parse(out)).toMatchObject({ error: expect.stringMatching(/not found/i) }); + }); + + it("share_memory returns 'not on team' when the user is solo", async () => { + const db = openDb(); + // Owner test user has no team. + const created = await buildAgentTools({ db, reflectUserId: userId }).execute( + "write_memory", + { + title: `To share ${randomUUID()}`, + content: `Share me ${randomUUID()}`, + tags: ["test", "to-share"], + }, + ); + const createdId = (JSON.parse(created) as { memory: { id: string } }).memory.id; + + const tools = buildAgentTools({ db, reflectUserId: userId }); + const out = await tools.execute("share_memory", { id: createdId, share: true }); + db.close(); + expect(JSON.parse(out)).toMatchObject({ error: expect.stringMatching(/not on a team/i) }); + }); + + it("delete_memory returns a preview when confirm=false (does NOT delete)", async () => { + const db = openDb(); + const created = await buildAgentTools({ db, reflectUserId: userId }).execute( + "write_memory", + { + title: `To preview-delete ${randomUUID()}`, + content: `Some body ${randomUUID()}`, + tags: ["test", "to-delete"], + }, + ); + const createdId = (JSON.parse(created) as { memory: { id: string } }).memory.id; + + const tools = buildAgentTools({ db, reflectUserId: userId }); + const previewOut = await tools.execute("delete_memory", { id: createdId, confirm: false }); + const preview = JSON.parse(previewOut) as { + ok: boolean; + preview: { id: string; title: string }; + instruction: string; + }; + expect(preview.ok).toBe(true); + expect(preview.preview.id).toBe(createdId); + expect(preview.instruction).toMatch(/destructive/i); + + // Memory still exists and is not soft-deleted. + const stillThere = readMemoryById(db, userId, createdId); + expect(stillThere?.deleted_at).toBeFalsy(); + db.close(); + }); + + it("delete_memory with confirm=true actually soft-deletes", async () => { + const db = openDb(); + const created = await buildAgentTools({ db, reflectUserId: userId }).execute( + "write_memory", + { + title: `To really delete ${randomUUID()}`, + content: `Some body ${randomUUID()}`, + tags: ["test", "to-delete"], + }, + ); + const createdId = (JSON.parse(created) as { memory: { id: string } }).memory.id; + + const tools = buildAgentTools({ db, reflectUserId: userId }); + const out = await tools.execute("delete_memory", { id: createdId, confirm: true }); + const parsed = JSON.parse(out) as { ok: boolean; deleted: boolean; id: string }; + expect(parsed.ok).toBe(true); + expect(parsed.deleted).toBe(true); + + // Memory is soft-deleted (readMemoryById still returns the row, deleted_at set). + const post = readMemoryById(db, userId, createdId); + expect(post?.deleted_at).toBeTruthy(); + db.close(); + }); }); // ---------------------------------------------------------------------------