palace-rs 0.20.0

Local memory palace for AI assistants — Rust edition
Documentation
// Palace memory plugin for OpenCode, generated by `palace install`.
// Rerun `palace install --client opencode` to update it; `palace uninstall`
// removes it. Every hook fails open: if Palace is unavailable, OpenCode runs
// exactly as it would without this plugin.
import { spawn } from "node:child_process"

const PALACE = "__PALACE_BINARY__"
const INVESTIGATION_TOOLS = new Set(["read", "grep", "glob", "bash"])

// Run `palace hook <event> --client opencode` with `payload` on stdin and
// resolve to its JSON response, or `{}` on any failure.
function palaceHook(event, payload) {
  return new Promise((resolve) => {
    let out = ""
    let child
    try {
      child = spawn(PALACE, ["hook", event, "--client", "opencode"], {
        stdio: ["pipe", "pipe", "ignore"],
        windowsHide: true,
      })
    } catch {
      resolve({})
      return
    }
    child.stdout.on("data", (chunk) => (out += chunk))
    child.on("error", () => resolve({}))
    child.on("close", () => {
      try {
        resolve(JSON.parse(out.trim() || "{}"))
      } catch {
        resolve({})
      }
    })
    child.stdin.on("error", () => {})
    child.stdin.end(JSON.stringify(payload))
  })
}

export const PalacePlugin = async ({ client, directory }) => {
  // Context waiting to be added to a session's next system prompt.
  const pending = new Map()
  // Sessions already nudged to save, so the stop nudge never loops.
  const nudged = new Set()

  const queue = (sessionID, text) => {
    if (!sessionID || !text) return
    const prior = pending.get(sessionID)
    pending.set(sessionID, prior ? `${prior}\n\n${text}` : text)
  }

  return {
    event: async ({ event }) => {
      if (event.type === "session.created") {
        const id = event.properties?.info?.id
        const res = await palaceHook("session-start", { cwd: directory, session_id: id })
        queue(id, res.additional_context)
      } else if (event.type === "session.idle") {
        const id = event.properties?.sessionID
        if (!id || nudged.has(id)) return
        const res = await palaceHook("stop", { cwd: directory, session_id: id })
        if (res.followup_message) {
          nudged.add(id)
          client.session
            .prompt({ path: { id }, body: { parts: [{ type: "text", text: res.followup_message }] } })
            .catch(() => {})
        }
      }
    },

    "chat.message": async (input, output) => {
      const prompt = (output.parts ?? [])
        .filter((part) => part.type === "text")
        .map((part) => part.text)
        .join("\n")
      const res = await palaceHook("user-prompt-submit", {
        cwd: directory,
        session_id: input.sessionID,
        prompt,
      })
      queue(input.sessionID, res.additional_context)
    },

    "experimental.chat.system.transform": async (input, output) => {
      const text = input.sessionID && pending.get(input.sessionID)
      if (text) {
        output.system.push(text)
        pending.delete(input.sessionID)
      }
    },

    "tool.execute.after": async (input, output) => {
      if (!INVESTIGATION_TOOLS.has(input.tool) || typeof output?.output !== "string") return
      const res = await palaceHook("post-tool-use", {
        cwd: directory,
        session_id: input.sessionID,
        tool_name: input.tool,
        tool_input: input.args ?? {},
      })
      if (res.additional_context) output.output += `\n\n${res.additional_context}`
    },
  }
}