|
| 1 | +import * as fs from "node:fs"; |
| 2 | +import * as path from "node:path"; |
| 3 | + |
| 4 | +const SKILL_LOCATIONS = [ |
| 5 | + (dir) => path.join(dir, ".opencode", "skill", "project-memory", "SKILL.md"), |
| 6 | + () => |
| 7 | + path.join( |
| 8 | + process.env.HOME || process.env.USERPROFILE || "", |
| 9 | + ".config", |
| 10 | + "opencode", |
| 11 | + "skill", |
| 12 | + "project-memory", |
| 13 | + "SKILL.md" |
| 14 | + ), |
| 15 | + () => |
| 16 | + path.join( |
| 17 | + process.env.HOME || process.env.USERPROFILE || "", |
| 18 | + ".claude", |
| 19 | + "skills", |
| 20 | + "project-memory", |
| 21 | + "SKILL.md" |
| 22 | + ), |
| 23 | +]; |
| 24 | + |
| 25 | +function hasMemorySkill(projectDir) { |
| 26 | + return SKILL_LOCATIONS.some((loc) => { |
| 27 | + try { |
| 28 | + return fs.existsSync(loc(projectDir)); |
| 29 | + } catch { |
| 30 | + return false; |
| 31 | + } |
| 32 | + }); |
| 33 | +} |
| 34 | + |
| 35 | +export const MemoryHook = async (ctx) => { |
| 36 | + const loadedSessions = new Set(); |
| 37 | + const subagentSessions = new Set(); |
| 38 | + |
| 39 | + async function isSubagent(sessionID) { |
| 40 | + if (subagentSessions.has(sessionID)) return true; |
| 41 | + try { |
| 42 | + const session = await ctx.client.session.get({ path: { id: sessionID } }); |
| 43 | + if (session.data?.parentID) { |
| 44 | + subagentSessions.add(sessionID); |
| 45 | + return true; |
| 46 | + } |
| 47 | + } catch { |
| 48 | + return false; |
| 49 | + } |
| 50 | + return false; |
| 51 | + } |
| 52 | + |
| 53 | + return { |
| 54 | + "chat.message": async (input, output) => { |
| 55 | + if (!hasMemorySkill(ctx.directory)) return; |
| 56 | + if (loadedSessions.has(input.sessionID)) return; |
| 57 | + loadedSessions.add(input.sessionID); |
| 58 | + if (await isSubagent(input.sessionID)) return; |
| 59 | + |
| 60 | + const textPart = output.parts.find((p) => p.type === "text"); |
| 61 | + if (textPart?.text) { |
| 62 | + textPart.text = `<system-reminder>Load project memory: read .memory/SUMMARY.md before starting work. If .memory/ doesn't exist, initialize it per the project-memory skill.</system-reminder>\n\n${textPart.text}`; |
| 63 | + } |
| 64 | + }, |
| 65 | + |
| 66 | + "tool.execute.after": async (input, output) => { |
| 67 | + if (ctx.client.app?.log) { |
| 68 | + await ctx.client.app.log({ |
| 69 | + body: { |
| 70 | + service: "memory-hook", |
| 71 | + level: "debug", |
| 72 | + message: `tool.execute.after fired: tool="${input.tool}"`, |
| 73 | + }, |
| 74 | + query: { directory: ctx.directory }, |
| 75 | + }); |
| 76 | + } |
| 77 | + |
| 78 | + const toolLower = (input.tool || "").toLowerCase().replace("mcp_", ""); |
| 79 | + if (toolLower !== "todowrite") return; |
| 80 | + if (await isSubagent(input.sessionID)) return; |
| 81 | + |
| 82 | + const todos = input.args?.todos; |
| 83 | + if (!Array.isArray(todos)) return; |
| 84 | + if (!todos.some((t) => t.status === "completed")) return; |
| 85 | + |
| 86 | + output.output += `\n\n<system-reminder>Task completed. If this work produced knowledge worth remembering (re-discovering would cost meaningful time), capture it to .memory/ following the project-memory skill. Only save decisions, patterns, bug root causes, or preferences — skip routine changes.</system-reminder>`; |
| 87 | + }, |
| 88 | + |
| 89 | + "event": async ({ event }) => { |
| 90 | + if ( |
| 91 | + event.type === "session.deleted" || |
| 92 | + event.type === "session.compacted" |
| 93 | + ) { |
| 94 | + const sid = |
| 95 | + event.properties?.info?.id || event.properties?.sessionID; |
| 96 | + if (sid) { |
| 97 | + loadedSessions.delete(sid); |
| 98 | + subagentSessions.delete(sid); |
| 99 | + } |
| 100 | + } |
| 101 | + }, |
| 102 | + }; |
| 103 | +}; |
0 commit comments