Skill: dcprevere/org-memory (version 1.0.2)

--- name: org-memory version: 1.0.2 description: "Extends the org-cli skill so the agent also persists its own memory (knowledge, observations, daily notes) to an org workspace." metadata: {"openclaw":{"emoji":"🐘","homepage":"https://github.com/dcprevere/org-cli","requires":{"bins":["org"],"env":["ORG_MEMORY_DIR","ORG_MEMORY_ROAM_DIR","ORG_MEMORY_DB","ORG_CLI_BIN"]},"install":[{"kind":"download","label":"Download from GitHub releases: https://github.com/dcprevere/org-cli/releases"}],"scope":{"reads":["$ORG_CLI_DIR","$ORG_CLI_ROAM_DIR","$ORG_CLI_DB","$ORG_MEMORY_DIR","$ORG_MEMORY_ROAM_DIR","$ORG_MEMORY_DB"],"writes":["$ORG_CLI_DIR","$ORG_CLI_ROAM_DIR","$ORG_CLI_DB","$ORG_MEMORY_DIR","$ORG_MEMORY_ROAM_DIR","$ORG_MEMORY_DB"]}}} --- # org-memory **This skill extends [`org-cli`](../org-cli/SKILL.md).** You must have the `org-cli` skill installed and loaded — everything about the CLI contract, command safety, identifiers, shortcut grammar, and the user's workspace lives there. This skill only adds what's different. ## What this skill adds Two things: 1. **A second workspace** — yours. Via `@a`-prefixed shortcuts, you can capture your own memory (observations, things you've learned, daily notes, a knowledge graph about the user or their work) into a dedicated org workspace. Bare shortcuts (`t:`, `n:`, `k:`, `d:`, `s:`, `f:`) still target the *user*'s workspace as defined by `org-cli`. 2. **Memory-wiki override.** When this skill is active, graph-structured knowledge routes through org-roam rather than OpenClaw's memory-wiki. See below. ## Memory-wiki override When `org-memory` is active, **store all persistent graph-structured knowledge in your org workspace, not in memory-wiki.** The auto-memory types (`user`, `feedback`, `project`, `reference`) can still go to `MEMORY.md` as usual — those are flat, typed notes and memory-wiki's graph features don't add much. But anything you'd model as entities + relationships (a person's preferences, a project's stakeholders, the history of a system) goes to `@ak` instead of memory-wiki. Why: one knowledge graph, not two. The user can browse your memory in the same tooling they use for their own. Your graph can link into their graph when the subject is shared. ## Targeting `@a` = agent. Append any action letter: `@at:` `@an:` `@ak:` `@ad:` `@as:` `@af:`. | Bare (user, from org-cli) | Agent extension | What it does | |---|---|---| | `t:` | `@at:` | Create TODO in your inbox | | `n:` | `@an:` | Plain headline in your inbox | | `k:` | `@ak:` | Knowledge in your roam graph | | `d:` | `@ad:` | Mark one of your TODOs DONE | | `s:` | `@as:` | Reschedule one of your TODOs | | `f:` | `@af:` | Search your org files and roam | ## Action details The agent-side commands are exact mirrors of the `org-cli` actions, with `$DIR`/`$ROAM`/`$DB` substituted to point at your workspace (`$ORG_MEMORY_DIR`, `$ORG_MEMORY_ROAM_DIR`, `$ORG_MEMORY_DB`). See the `org-cli` skill for the full specs of each action — the commands here are identical, just targeted differently. Worked example for `@ak`: ```bash # Find existing node for the subject org roam node find '<subject>' -d "$ORG_MEMORY_ROAM_DIR" --db "$ORG_MEMORY_DB" -f json # If it exists, append the fact org append <custom_id> '<fact>' -d "$ORG_MEMORY_DIR" --db "$ORG_MEMORY_DB" -f json # If not, create then append org roam node create '<subject>' -d "$ORG_MEMORY_ROAM_DIR" --db "$ORG_MEMORY_DB" -f json # then append ``` The same substitution pattern applies to every other action. ## Session-start context When this skill is active, two things get injected into the agent's session-start context from your workspace: - `$ORG_MEMORY_DIR/memory.org` — your persistent memory file. Read this first; it's the continuity you've built up. - `$ORG_MEMORY_DIR/daily/YYYY-MM-DD.org` — today's and yesterday's daily notes, if they exist. The `org-memory` plugin's `before_agent_start` hook loads these; you don't have to fetch them manually. ## Write-log format Use `org-memory:` (not `org-cli:`) as the prefix when the write went to *your* workspace. The user reads both prefixes to know which workspace changed: ``` org-memory: added TODO [k4t] ~/org/agent/inbox.org org-memory: created node [3f2a-…] ~/org/agent/roam/self.org ``` Bare shortcuts still produce `org-cli:` lines (they're handled by the `org-cli` skill). Only `@a*` shortcuts produce `org-memory:` lines. ## Configuration Additional env vars beyond what `org-cli` declares: | Variable | Default | Purpose | |---|---|---| | `ORG_MEMORY_DIR` | `~/org/agent` | Your workspace (memory.org, daily/, tasks) | | `ORG_MEMORY_DB` | `$ORG_MEMORY_DIR/.org.db` | SQLite database | | `ORG_MEMORY_ROAM_DIR` | `$ORG_MEMORY_DIR/roam` | Your roam nodes | `ORG_CLI_BIN` (the org binary path) is shared — both skills use the same binary. `ORG_CLI_INBOX_FILE` is *not* shared — your inbox is always `inbox.org` in `$ORG_MEMORY_DIR` unless you set a separate plugin config. ## First-time setup In addition to the `org-cli` first-time setup for the user's workspace, run: ```bash # Sync any existing agent files org roam sync -d "$ORG_MEMORY_DIR" --db "$ORG_MEMORY_DB" # Seed the knowledge base org roam node create 'Index' -d "$ORG_MEMORY_ROAM_DIR" --db "$ORG_MEMORY_DB" -f json # Build the headline index org index -d "$ORG_MEMORY_DIR" --db "$ORG_MEMORY_DB" ``` ## References - **Memory architecture** (`{baseDir}/references/memory-architecture.md`) — why the dual-workspace split exists, how memory.org + daily notes fit in, and the migration from MEMORY.md-only.

path: plugin/index.test.ts
content: import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { homedir } from "node:os";
import {
  resolveConfig,
  formatAddedTodo,
  formatAddedNote,
  formatCreatedNode,
  formatOrgError,
  buildAddTodoArgs,
  buildAddNoteArgs,
} from "./lib.ts";
import type { OrgMemoryConfig } from "./lib.ts";

const envKeys = [
  "ORG_MEMORY_DIR",
  "ORG_MEMORY_ROAM_DIR",
  "ORG_MEMORY_DB",
  "ORG_CLI_BIN",
];

let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
  savedEnv = {};
  for (const k of envKeys) {
    savedEnv[k] = process.env[k];
    delete process.env[k];
  }
});

afterEach(() => {
  for (const k of envKeys) {
    if (savedEnv[k] === undefined) {
      delete process.env[k];
    } else {
      process.env[k] = savedEnv[k];
    }
  }
});

const home = homedir();

describe("resolveConfig", () => {
  describe("defaults", () => {
    it("uses default agent directory", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.dir, join(home, "org/agent"));
    });

    it("roam dir defaults to <dir>/roam", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.roamDir, join(home, "org/agent/roam"));
    });

    it("db defaults to <dir>/.org.db", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.db, join(home, "org/agent/.org.db"));
    });

    it("orgBin defaults to org", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.orgBin, "org");
    });

    it("inboxFile defaults to inbox.org", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.inboxFile, "inbox.org");
    });
  });

  describe("plugin config overrides", () => {
    it("overrides dir", () => {
      const cfg = resolveConfig({ dir: "/custom/agent" });
      assert.equal(cfg.dir, "/custom/agent");
    });

    it("roam dir derives from overridden dir", () => {
      const cfg = resolveConfig({ dir: "/custom/agent" });
      assert.equal(cfg.roamDir, "/custom/agent/roam");
      assert.equal(cfg.db, "/custom/agent/.org.db");
    });

    it("roam dir can be overridden independently", () => {
      const cfg = resolveConfig({ dir: "/custom/agent", roamDir: "/notes" });
      assert.equal(cfg.roamDir, "/notes");
    });
  });

  describe("env var overrides", () => {
    it("env vars take precedence over plugin config", () => {
      process.env.ORG_MEMORY_DIR = "/env/agent";
      const cfg = resolveConfig({ dir: "/config" });
      assert.equal(cfg.dir, "/env/agent");
    });

    it("ORG_CLI_BIN is shared with org-cli", () => {
      process.env.ORG_CLI_BIN = "/usr/local/bin/org";
      const cfg = resolveConfig();
      assert.equal(cfg.orgBin, "/usr/local/bin/org");
    });

    it("roam dir derives from env dir when not set", () => {
      process.env.ORG_MEMORY_DIR = "/env/agent";
      const cfg = resolveConfig();
      assert.equal(cfg.roamDir, "/env/agent/roam");
      assert.equal(cfg.db, "/env/agent/.org.db");
    });

    it("db env var overrides derived default", () => {
      process.env.ORG_MEMORY_DB = "/env/custom.db";
      const cfg = resolveConfig();
      assert.equal(cfg.db, "/env/custom.db");
    });
  });

  describe("roam dir distinct from workspace dir", () => {
    it("default config has distinct dirs", () => {
      const cfg = resolveConfig();
      assert.notEqual(cfg.dir, cfg.roamDir);
    });

    it("roam dir is a subdirectory of workspace dir by default", () => {
      const cfg = resolveConfig();
      assert.ok(cfg.roamDir.startsWith(cfg.dir + "/"));
    });
  });
});

describe("formatAddedTodo", () => {
  it("prefixes custom_id when present", () => {
    const stdout = JSON.stringify({ ok: true, data: { custom_id: "abc", title: "X" } });
    assert.ok(formatAddedTodo(stdout).startsWith("TODO created with ID: abc\n\n"));
  });

  it("returns stdout unchanged when custom_id is absent", () => {
    const stdout = JSON.stringify({ ok: true, data: { title: "X" } });
    assert.equal(formatAddedTodo(stdout), stdout);
  });

  it("returns stdout unchanged for non-JSON", () => {
    assert.equal(formatAddedTodo("plain"), "plain");
  });
});

describe("formatAddedNote", () => {
  it("prefixes custom_id when present", () => {
    const stdout = JSON.stringify({ ok: true, data: { custom_id: "abc", title: "Thought" } });
    assert.ok(formatAddedNote(stdout).startsWith("Note created with ID: abc\n\n"));
  });
});

describe("formatCreatedNode", () => {
  it("prefixes id", () => {
    const stdout = JSON.stringify({ ok: true, data: { id: "uuid-x", title: "Node" } });
    assert.ok(formatCreatedNode(stdout).startsWith("Node created with ID: uuid-x\n\n"));
  });

  it("falls back to custom_id", () => {
    const stdout = JSON.stringify({ ok: true, data: { custom_id: "k4t", title: "Node" } });
    assert.ok(formatCreatedNode(stdout).startsWith("Node created with ID: k4t\n\n"));
  });
});

describe("formatOrgError", () => {
  it("extracts JSON error.message when -f json is present", () => {
    const stdout = JSON.stringify({ ok: false, error: { message: "nope" } });
    const msg = formatOrgError(["todo", "set", "x", "DONE", "-f", "json"], stdout, "", "exit 1");
    assert.equal(msg, "org todo failed: nope");
  });

  it("falls back to stderr when JSON parse fails", () => {
    const msg = formatOrgError(["fts", "q", "-f", "json"], "nope", "stderr msg", "fb");
    assert.equal(msg, "org fts failed: stderr msg");
  });
});

const cfg: OrgMemoryConfig = {
  dir: "/agent",
  roamDir: "/agent/roam",
  db: "/agent/.org.db",
  orgBin: "org",
  inboxFile: "inbox.org",
};

describe("buildAddTodoArgs", () => {
  it("defaults to agent inbox with TODO state and json output", () => {
    const args = buildAddTodoArgs(cfg, { title: "Note to self" });
    assert.deepEqual(args, [
      "add",
      "/agent/inbox.org",
      "Note to self",
      "--todo",
      "TODO",
      "--db",
      "/agent/.org.db",
      "-f",
      "json",
    ]);
  });

  it("honors custom file relative to dir", () => {
    const args = buildAddTodoArgs(cfg, { title: "X", file: "learnings.org" });
    assert.ok(args.includes("/agent/learnings.org"));
  });

  it("appends --scheduled when provided", () => {
    const args = buildAddTodoArgs(cfg, { title: "X", scheduled: "2026-05-01" });
    assert.equal(args[args.indexOf("--scheduled") + 1], "2026-05-01");
  });

  it("passes title literally", () => {
    const title = "Weird: \"quotes\" 'and' $stuff";
    const args = buildAddTodoArgs(cfg, { title });
    assert.ok(args.includes(title));
  });
});

describe("buildAddNoteArgs", () => {
  it("omits --todo and date flags", () => {
    const args = buildAddNoteArgs(cfg, { text: "An observation" });
    assert.ok(!args.includes("--todo"));
    assert.ok(!args.includes("--scheduled"));
    assert.ok(!args.includes("--deadline"));
    assert.ok(args.includes("/agent/inbox.org"));
  });
});


path: plugin/index.ts
content: import { join } from "node:path";
import { Type } from "@sinclair/typebox";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import {
  DATE_PATTERN,
  DATE_OR_EMPTY_PATTERN,
  READ_TIMEOUT_MS,
  MAX_FILE_BYTES,
  buildAddTodoArgs,
  buildAddNoteArgs,
  formatAddedTodo,
  formatAddedNote,
  formatCreatedNode,
  readOrgFile,
  resolveConfig,
  runOrg,
  todayStr,
  yesterdayStr,
} from "./lib.ts";

export {
  resolveConfig,
  formatAddedTodo,
  formatAddedNote,
  formatCreatedNode,
  formatOrgError,
  buildAddTodoArgs,
  buildAddNoteArgs,
} from "./lib.ts";
export type { OrgMemoryConfig } from "./lib.ts";

export default definePluginEntry({
  id: "org-memory",
  name: "org-memory",
  description:
    "Agent-memory extension for org-cli: persists agent knowledge and daily notes to an org workspace, overriding memory-wiki.",
  kind: "memory",

  register(api: OpenClawPluginApi) {
    const cfg = resolveConfig(api.pluginConfig);

    api.logger.info(
      `org-memory: dir=${cfg.dir}, roamDir=${cfg.roamDir}, orgBin=${cfg.orgBin}`,
    );

    // ======================================================================
    // Session-start hook: inject the @a shortcut grammar plus agent memory.
    //
    // Requires the org-cli skill for the base shortcut grammar and CLI
    // contract. This hook only adds the @a-prefixed extensions and the
    // agent-workspace context files (memory.org, daily/*.org).
    // ======================================================================

    api.on("before_agent_start", async () => {
      const parts: string[] = [];

      parts.push(`<org-memory-instructions>
## Agent-side shortcut extensions

These extend the \`org-cli\` shortcut grammar. Bare shortcuts (\`t:\`, \`n:\`, \`k:\`, \`d:\`, \`s:\`, \`f:\`) target the **user's** workspace (see org-cli). The \`@a\` prefix targets *your* workspace.

| Bare (user) | Agent extension | Action |
|---|---|---|
| \`t:\` | \`@at:\` | Create TODO in your inbox |
| \`n:\` | \`@an:\` | Plain headline in your inbox |
| \`k:\` | \`@ak:\` | Knowledge in your roam graph |
| \`d:\` | \`@ad:\` | Mark one of your TODOs DONE |
| \`s:\` | \`@as:\` | Reschedule one of your TODOs |
| \`f:\` | \`@af:\` | Search your org files and roam |

## Memory-wiki override

When this skill is active, store all persistent graph-structured knowledge (entities, relationships) in your org workspace — not in memory-wiki. Flat typed memory (user/feedback/project/reference) can still go to MEMORY.md as usual; graph-structured memory goes to \`@ak\`.

## Your workspace
- Directory: \`${cfg.dir}\` (memory.org, daily/, tasks)
- Roam nodes: \`${cfg.roamDir}\`
- Daily notes: \`${cfg.dir}/daily/YYYY-MM-DD.org\`

### Rules
- **After every write**: print \`org-memory: <action> [<id>] <file-path>\`.
- **Argument passing**: These plugin tools invoke org via execFile — pass raw text, do not quote or escape.
- **Ambient facts about the user** → offer to save via \`@ak\` against the relevant subject.
- **Working notes** → append to today's daily file.
- **Things worth keeping permanently** → also update \`memory.org\`.
</org-memory-instructions>`);

      // Load memory.org + today/yesterday daily so the agent starts with context.
      const memoryOrg = await readOrgFile(join(cfg.dir, "memory.org"), MAX_FILE_BYTES);
      if (memoryOrg) {
        parts.push(`<org-memory-file path="memory.org">\n${memoryOrg}\n</org-memory-file>`);
      }

      const today = todayStr();
      const yesterday = yesterdayStr();

      const todayContent = await readOrgFile(
        join(cfg.dir, "daily", `${today}.org`),
        MAX_FILE_BYTES,
      );
      if (todayContent) {
        parts.push(
          `<org-memory-file path="daily/${today}.org">\n${todayContent}\n</org-memory-file>`,
        );
      }

      const yesterdayContent = await readOrgFile(
        join(cfg.dir, "daily", `${yesterday}.org`),
        MAX_FILE_BYTES,
      );
      if (yesterdayContent) {
        parts.push(
          `<org-memory-file path="daily/${yesterday}.org">\n${yesterdayContent}\n</org-memory-file>`,
        );
      }

      return {
        prependContext: `<org-memory>\n${parts.join("\n")}\n</org-memory>`,
      };
    });

    // ======================================================================
    // Tool: org_memory_find — full-text search across agent org files
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_find",
        label: "Org Memory Find",
        description:
          "Full-text search across the agent's own org workspace (titles + bodies). Returns matching headlines with IDs. No mutation.",
        parameters: Type.Object({
          query: Type.String({ description: "FTS5 search query" }),
        }),
        async execute(_id, params) {
          const { query } = params as { query: string };
          try {
            const { stdout } = await runOrg(
              cfg.orgBin,
              ["fts", query, "-d", cfg.dir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: {},
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Find failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_read_node — read a roam node
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_read_node",
        label: "Org Memory Read Node",
        description:
          "Read a roam node by title or ID from the agent's knowledge base. Returns the full node content.",
        parameters: Type.Object({
          identifier: Type.String({
            description: "Node title, org-id (UUID), or CUSTOM_ID",
          }),
        }),
        async execute(_id, params) {
          const { identifier } = params as { identifier: string };

          try {
            const { stdout: findOut } = await runOrg(
              cfg.orgBin,
              ["roam", "node", "find", identifier, "-d", cfg.roamDir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );

            const result = JSON.parse(findOut);
            if (!result.ok || !result.data) {
              return {
                content: [
                  { type: "text" as const, text: `Node not found: ${identifier}` },
                ],
                details: { found: false },
              };
            }

            const filePath = result.data.file;
            if (filePath) {
              const { stdout: readOut } = await runOrg(
                cfg.orgBin,
                ["read", filePath, identifier, "-d", cfg.dir, "--db", cfg.db, "-f", "json"],
                READ_TIMEOUT_MS,
              );
              return {
                content: [{ type: "text" as const, text: readOut }],
                details: { node: result.data },
              };
            }

            return {
              content: [{ type: "text" as const, text: findOut }],
              details: { node: result.data },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Read node failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_add_todo — @at
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_add_todo",
        label: "Org Memory Add TODO",
        description:
          "Add a TODO headline to the agent's workspace (defaults to the agent's inbox.org). Optionally schedule or set a deadline.",
        parameters: Type.Object({
          title: Type.String({ description: "TODO title" }),
          scheduled: Type.Optional(
            Type.String({
              description: "Scheduled date (YYYY-MM-DD)",
              pattern: DATE_PATTERN,
            }),
          ),
          deadline: Type.Optional(
            Type.String({
              description: "Deadline date (YYYY-MM-DD)",
              pattern: DATE_PATTERN,
            }),
          ),
          file: Type.Optional(
            Type.String({
              description: "Filename relative to the workspace dir (default: inboxFile)",
            }),
          ),
        }),
        async execute(_id, params) {
          const typed = params as {
            title: string;
            scheduled?: string;
            deadline?: string;
            file?: string;
          };
          const args = buildAddTodoArgs(cfg, typed);
          try {
            const { stdout } = await runOrg(cfg.orgBin, args);
            return {
              content: [{ type: "text" as const, text: formatAddedTodo(stdout) }],
              details: { action: "added-todo" },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to add TODO: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_add_note — @an
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_add_note",
        label: "Org Memory Add Note",
        description:
          "Add a plain headline (no TODO, no date) to the agent's workspace. Use for captured thoughts, observations, working notes.",
        parameters: Type.Object({
          text: Type.String({ description: "Note text (becomes the headline title)" }),
          file: Type.Optional(
            Type.String({
              description: "Filename relative to the workspace dir (default: inboxFile)",
            }),
          ),
        }),
        async execute(_id, params) {
          const typed = params as { text: string; file?: string };
          const args = buildAddNoteArgs(cfg, typed);
          try {
            const { stdout } = await runOrg(cfg.orgBin, args);
            return {
              content: [{ type: "text" as const, text: formatAddedNote(stdout) }],
              details: { action: "added-note" },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to add note: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_mark_done — @ad
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_mark_done",
        label: "Org Memory Mark Done",
        description:
          "Mark one of the agent's own TODOs as DONE by its CUSTOM_ID. For other states use org_memory_set_state.",
        parameters: Type.Object({
          customId: Type.String({
            description: "The CUSTOM_ID of the headline to mark DONE",
          }),
        }),
        async execute(_id, params) {
          const { customId } = params as { customId: string };
          try {
            const { stdout } = await runOrg(cfg.orgBin, [
              "todo", "set", customId, "DONE",
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "done", customId },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to mark DONE: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_set_state — any state
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_set_state",
        label: "Org Memory Set State",
        description:
          "Set an agent TODO to any state (DONE, CANCELLED, WAITING, or custom #+TODO keywords) by CUSTOM_ID.",
        parameters: Type.Object({
          customId: Type.String({ description: "The CUSTOM_ID of the headline" }),
          state: Type.String({ description: "Target TODO state" }),
        }),
        async execute(_id, params) {
          const { customId, state } = params as { customId: string; state: string };
          try {
            const { stdout } = await runOrg(cfg.orgBin, [
              "todo", "set", customId, state,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "state-set", customId, state },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to set state: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_reschedule — @as
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_reschedule",
        label: "Org Memory Reschedule",
        description:
          'Reschedule one of the agent\'s TODOs by its CUSTOM_ID. Pass a date in YYYY-MM-DD, or "" to clear.',
        parameters: Type.Object({
          customId: Type.String({
            description: "The CUSTOM_ID of the headline to reschedule",
          }),
          date: Type.String({
            description: 'New scheduled date (YYYY-MM-DD) or "" to clear',
            pattern: DATE_OR_EMPTY_PATTERN,
          }),
          repeater: Type.Optional(
            Type.String({
              description: "Repeater: +N[hdwmy], ++N[hdwmy], or .+N[hdwmy]",
            }),
          ),
          delay: Type.Optional(
            Type.String({
              description: "Warning delay: N[hdwmy] (e.g. 2d for -2d)",
            }),
          ),
        }),
        async execute(_id, params) {
          const { customId, date, repeater, delay } = params as {
            customId: string;
            date: string;
            repeater?: string;
            delay?: string;
          };
          try {
            const args = [
              "schedule", customId, date,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ];
            if (repeater) args.push("--repeater", repeater);
            if (delay) args.push("--delay", delay);
            const { stdout } = await runOrg(cfg.orgBin, args);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "rescheduled", customId, date },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Reschedule failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_list_todos — today's agenda (agent)
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_list_todos",
        label: "Org Memory List TODOs",
        description:
          "Show the agent's own TODOs for today + overdue.",
        parameters: Type.Object({}),
        async execute() {
          try {
            const { stdout } = await runOrg(
              cfg.orgBin,
              ["today", "-d", cfg.dir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: {},
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `List TODOs failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_append — append to any headline body
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_append",
        label: "Org Memory Append",
        description:
          "Append text to an agent headline's body by its CUSTOM_ID. Use for adding observations to existing knowledge nodes.",
        parameters: Type.Object({
          customId: Type.String({
            description: "The CUSTOM_ID of the headline to append to",
          }),
          text: Type.String({ description: "Text to append to the headline body" }),
        }),
        async execute(_id, params) {
          const { customId, text } = params as { customId: string; text: string };
          try {
            const { stdout } = await runOrg(cfg.orgBin, [
              "append", customId, text,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "appended", customId },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Append failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_memory_roam_upsert — @ak — find-or-create node, append fact
    // ======================================================================

    api.registerTool(
      {
        name: "org_memory_roam_upsert",
        label: "Org Memory Roam Upsert",
        description:
          "Store a fact in the agent's roam graph against a subject. If a node for the subject exists, appends to it; otherwise creates a new node and appends. This is the `@ak` shortcut.",
        parameters: Type.Object({
          subject: Type.String({
            description: "Subject of the knowledge (e.g. person/concept name)",
          }),
          fact: Type.String({
            description: "The fact/note to record against the subject",
          }),
          tags: Type.Optional(
            Type.Array(Type.String(), {
              description: "Tags to apply if the node is newly created",
            }),
          ),
        }),
        async execute(_id, params) {
          const { subject, fact, tags } = params as {
            subject: string;
            fact: string;
            tags?: string[];
          };

          try {
            const { stdout: findOut } = await runOrg(
              cfg.orgBin,
              ["roam", "node", "find", subject, "-d", cfg.roamDir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );
            const found = JSON.parse(findOut);

            let customId: string | undefined = found?.data?.custom_id;

            if (!found?.ok || !found?.data) {
              const createArgs = ["roam", "node", "create", subject];
              if (tags) {
                for (const tag of tags) createArgs.push("-t", tag);
              }
              createArgs.push("-d", cfg.roamDir, "--db", cfg.db, "-f", "json");
              const { stdout: createOut } = await runOrg(cfg.orgBin, createArgs);
              const created = JSON.parse(createOut);
              customId = created?.data?.custom_id;
              if (!customId) {
                return {
                  content: [{ type: "text" as const, text: formatCreatedNode(createOut) }],
                  details: { action: "created-no-id" },
                };
              }
            }

            if (!customId) {
              return {
                content: [
                  { type: "text" as const, text: `Node lookup returned no custom_id for: ${subject}` },
                ],
                details: { error: true },
              };
            }

            const { stdout: appendOut } = await runOrg(cfg.orgBin, [
              "append", customId, fact,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: appendOut }],
              details: { action: "upserted", subject, customId },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Roam upsert failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );
  },
});


path: plugin/lib.ts
content: import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

export const DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$";
export const DATE_OR_EMPTY_PATTERN = "^(\\d{4}-\\d{2}-\\d{2})?$";
export const DEFAULT_TIMEOUT_MS = 10_000;
export const READ_TIMEOUT_MS = 30_000;
export const MAX_FILE_BYTES = 32_768;

// ---------------------------------------------------------------------------
// Config resolution: env vars > plugin config > defaults
// ---------------------------------------------------------------------------

export type OrgMemoryConfig = {
  dir: string;
  roamDir: string;
  db: string;
  orgBin: string;
  inboxFile: string;
};

export function resolveConfig(pluginConfig?: Record<string, unknown>): OrgMemoryConfig {
  const home = homedir();
  const cfg = pluginConfig ?? {};

  const dir =
    process.env.ORG_MEMORY_DIR ??
    (cfg.dir as string | undefined) ??
    join(home, "org/agent");

  return {
    dir,
    roamDir:
      process.env.ORG_MEMORY_ROAM_DIR ??
      (cfg.roamDir as string | undefined) ??
      join(dir, "roam"),
    db:
      process.env.ORG_MEMORY_DB ??
      (cfg.db as string | undefined) ??
      join(dir, ".org.db"),
    // Share the org binary with org-cli — one binary, one env var.
    orgBin:
      process.env.ORG_CLI_BIN ??
      (cfg.orgBin as string | undefined) ??
      "org",
    inboxFile:
      (cfg.inboxFile as string | undefined) ??
      "inbox.org",
  };
}

// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------

export function formatOrgError(
  args: string[],
  stdout: string,
  stderr: string,
  fallback: string,
): string {
  const cmd = args[0] ?? "org";
  const fIdx = args.indexOf("-f");
  const wantsJson = fIdx !== -1 && args[fIdx + 1] === "json";
  if (wantsJson && stdout) {
    try {
      const parsed = JSON.parse(stdout);
      if (parsed?.ok === false && parsed.error?.message) {
        return `org ${cmd} failed: ${parsed.error.message}`;
      }
    } catch {
      // fall through
    }
  }
  const detail = stderr.trim() || stdout.trim() || fallback;
  return `org ${cmd} failed: ${detail}`;
}

export function formatAddedTodo(stdout: string): string {
  try {
    const parsed = JSON.parse(stdout);
    const customId = parsed?.data?.custom_id;
    if (customId) {
      return `TODO created with ID: ${customId}\n\n${stdout}`;
    }
  } catch {
    // non-JSON
  }
  return stdout;
}

export function formatAddedNote(stdout: string): string {
  try {
    const parsed = JSON.parse(stdout);
    const customId = parsed?.data?.custom_id;
    if (customId) {
      return `Note created with ID: ${customId}\n\n${stdout}`;
    }
  } catch {
    // non-JSON
  }
  return stdout;
}

export function formatCreatedNode(stdout: string): string {
  try {
    const parsed = JSON.parse(stdout);
    const id = parsed?.data?.id ?? parsed?.data?.custom_id;
    if (id) {
      return `Node created with ID: ${id}\n\n${stdout}`;
    }
  } catch {
    // non-JSON
  }
  return stdout;
}

// ---------------------------------------------------------------------------
// Argument builders (pure — testable without spawning org)
// ---------------------------------------------------------------------------

export function buildAddTodoArgs(
  cfg: OrgMemoryConfig,
  params: {
    title: string;
    scheduled?: string;
    deadline?: string;
    file?: string;
  },
): string[] {
  const filePath = join(cfg.dir, params.file ?? cfg.inboxFile);
  const args = [
    "add",
    filePath,
    params.title,
    "--todo",
    "TODO",
    "--db",
    cfg.db,
    "-f",
    "json",
  ];
  if (params.scheduled) {
    args.push("--scheduled", params.scheduled);
  }
  if (params.deadline) {
    args.push("--deadline", params.deadline);
  }
  return args;
}

export function buildAddNoteArgs(
  cfg: OrgMemoryConfig,
  params: {
    text: string;
    file?: string;
  },
): string[] {
  const filePath = join(cfg.dir, params.file ?? cfg.inboxFile);
  return ["add", filePath, params.text, "--db", cfg.db, "-f", "json"];
}

// ---------------------------------------------------------------------------
// Runtime helpers
// ---------------------------------------------------------------------------

export function runOrg(
  bin: string,
  args: string[],
  timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<{ stdout: string; stderr: string }> {
  return new Promise((resolve, reject) => {
    execFile(bin, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
      if (err) {
        reject(new Error(formatOrgError(args, stdout, stderr, err.message)));
      } else {
        resolve({ stdout, stderr });
      }
    });
  });
}

export async function readOrgFile(
  path: string,
  maxBytes = Number.POSITIVE_INFINITY,
): Promise<string | null> {
  try {
    const content = await readFile(path, "utf-8");
    if (content.length <= maxBytes) {
      return content;
    }
    return content.slice(0, maxBytes) + `\n\n... (truncated at ${maxBytes} bytes)`;
  } catch {
    return null;
  }
}

export function todayStr(): string {
  const d = new Date();
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

export function yesterdayStr(): string {
  const d = new Date();
  d.setDate(d.getDate() - 1);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}


path: plugin/openclaw.plugin.json
content: {
  "id": "org-memory",
  "kind": "memory",
  "name": "org-memory",
  "description": "Agent-memory extension for org-cli: persists agent knowledge and daily notes to an org workspace, overriding memory-wiki.",
  "version": "1.0.2",
  "contracts": {
    "tools": [
      "org_memory_find",
      "org_memory_read_node",
      "org_memory_add_todo",
      "org_memory_add_note",
      "org_memory_mark_done",
      "org_memory_set_state",
      "org_memory_reschedule",
      "org_memory_list_todos",
      "org_memory_append",
      "org_memory_roam_upsert"
    ]
  },
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "dir": {
        "type": "string",
        "description": "Agent workspace directory"
      },
      "roamDir": {
        "type": "string",
        "description": "Agent roam nodes directory (defaults to <dir>/roam)"
      },
      "db": {
        "type": "string",
        "description": "Agent SQLite database (defaults to <dir>/.org.db)"
      },
      "orgBin": {
        "type": "string",
        "description": "Path to the org binary (shared with org-cli via ORG_CLI_BIN)"
      },
      "inboxFile": {
        "type": "string",
        "description": "Filename for new agent captures (relative to dir)"
      }
    }
  },
  "uiHints": {
    "dir": {
      "label": "Agent Workspace Directory",
      "placeholder": "~/org/agent",
      "help": "Where the agent keeps its own org files (memory.org, daily notes, tasks)."
    },
    "roamDir": {
      "label": "Agent Roam Directory",
      "placeholder": "~/org/agent/roam",
      "help": "Where the agent's knowledge-graph nodes live. Defaults to <dir>/roam.",
      "advanced": true
    },
    "db": {
      "label": "Agent Database",
      "placeholder": "~/org/agent/.org.db",
      "help": "SQLite database for the agent's roam + index. Defaults to <dir>/.org.db.",
      "advanced": true
    },
    "orgBin": {
      "label": "org CLI Binary",
      "placeholder": "org",
      "help": "Path to the org binary. Shares ORG_CLI_BIN with the org-cli plugin.",
      "advanced": true
    }
  }
}


path: plugin/package-lock.json
content: {
  "name": "@dcprevere/org-memory",
  "version": "1.0.2",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "@dcprevere/org-memory",
      "version": "1.0.2",
      "license": "MIT",
      "dependencies": {
        "@sinclair/typebox": "^0.34.48"
      },
      "devDependencies": {
        "@types/node": "^22.10.0"
      }
    },
    "node_modules/@sinclair/typebox": {
      "version": "0.34.49",
      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@types/node": {
      "version": "22.19.17",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.21.0"
      }
    },
    "node_modules/undici-types": {
      "version": "6.21.0",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    }
  }
}


path: plugin/package.json
content: {
  "name": "@dcprevere/org-memory",
  "version": "1.0.2",
  "type": "module",
  "description": "Agent-memory extension for org-cli: persists agent knowledge and daily notes to an org workspace, overriding memory-wiki.",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/DCPRevere/org-cli.git",
    "directory": "integrations/openclaw/org-memory/plugin"
  },
  "files": [
    "index.ts",
    "lib.ts",
    "openclaw.plugin.json"
  ],
  "openclaw": {
    "extensions": ["./index.ts"],
    "compat": {
      "pluginApi": ">=2026.3.24-beta.2",
      "minGatewayVersion": "2026.3.24-beta.2"
    },
    "build": {
      "openclawVersion": "2026.3.24-beta.2",
      "pluginSdkVersion": "2026.3.24-beta.2"
    }
  },
  "scripts": {
    "test": "node --test --experimental-strip-types index.test.ts"
  },
  "dependencies": {
    "@sinclair/typebox": "^0.34.48"
  },
  "devDependencies": {
    "@types/node": "^22.10.0"
  }
}


path: references/memory-architecture.md
content: # Memory architecture

`$ORG_MEMORY_DIR` is your primary long-term memory. It replaces flat memory files (like MEMORY.md) with a structured, searchable knowledge graph.

When `org-memory` is loaded, persistent graph-structured knowledge routes through org-roam — not memory-wiki, not ad-hoc appending to MEMORY.md. See the SKILL.md "Memory-wiki override" section for the boundary (flat typed memory can still go to MEMORY.md; graph-structured memory goes here).

## File structure

```
$ORG_MEMORY_DIR/
├── memory.org          # Curated long-term memory (read every session)
├── daily/
│   ├── 2026-04-18.org  # Today's raw log
│   ├── 2026-04-17.org  # Yesterday's raw log
│   └── ...
└── roam/*.org          # Entity nodes (people, projects, etc.)
```

**`memory.org`** — your permanent memory. Curated, concise, always loaded at session start. Contains who the user is, active projects, lessons learned, conventions, and anything needed every session. Keep it tight — move detail into entity nodes and keep memory.org as a summary with links.

**`daily/YYYY-MM-DD.org`** — raw daily logs. What happened, decisions made, ambient facts captured, things learned. Working notes, not curated. Write freely.

**Entity nodes** (`roam/*.org`) — structured roam nodes for people, projects, concepts. Tagged, linked, and queryable on demand.

## Session start routine

The `org-memory` plugin's `before_agent_start` hook injects `memory.org` and today's + yesterday's daily files into your session context automatically. In addition:

1. **Load today's agenda** from the user's workspace: `org today -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json`
2. **Query entity nodes on demand** as the conversation surfaces them. Don't preload everything.

## During the session

- **Ambient facts** → append to today's daily file (`@an` into `daily/YYYY-MM-DD.org`)
- **New entity** → `@ak <subject> <fact>` (creates or updates a roam node)
- **Update to existing entity** → `@ak <subject> <new fact>` (upserts into the existing node)
- **Something worth keeping permanently** → also update `memory.org`

## Memory maintenance

Periodically (every few days, during a quiet moment):

1. Review recent daily files
2. Promote important facts to entity nodes or `memory.org`
3. Remove outdated info from `memory.org`
4. Daily files can accumulate — they're cheap and searchable via `@af`

Daily files are raw notes; memory.org is curated wisdom; entity nodes are structured knowledge.

## Memory migration (optional, user-initiated)

If the user has been using OpenClaw's default memory (MEMORY.md) and wants to move it into org, this migration reads and writes files outside the declared `$ORG_MEMORY_*` directories:

- **Reads:** `~/.openclaw/workspace/MEMORY.md`, `~/.openclaw/workspace/memory/*.md`
- **Writes:** `$ORG_MEMORY_DIR/memory.org`, `$ORG_MEMORY_DIR/daily/*.org`, `~/.openclaw/openclaw.json`

**Never start migration automatically.** Only proceed if the user explicitly requests it.

1. **Ask the user**: confirm they want to migrate and that `~/.openclaw/openclaw.json` and `~/.openclaw/workspace/MEMORY.md` are backed up.

2. **Migrate MEMORY.md** → `$ORG_MEMORY_DIR/memory.org`. Convert markdown headings (`# ` → `* `, `## ` → `** `, etc.). If empty or missing, create a minimal `memory.org` with one top-level heading.

3. **Migrate daily files**. For each `~/.openclaw/workspace/memory/YYYY-MM-DD.md`, convert headings and write to `$ORG_MEMORY_DIR/daily/YYYY-MM-DD.org`.

4. **Disable the default memory plugin**. Update `~/.openclaw/openclaw.json` — set `plugins.slots.memory` to `"none"`. This stops OpenClaw from auto-flushing to MEMORY.md during compaction.

   ```json
   {
     "plugins": {
       "slots": {
         "memory": "none"
       }
     }
   }
   ```

   Merge into the existing JSON — don't overwrite other keys.

5. **Sync**. Run `org roam sync` and `org index` against `$ORG_MEMORY_DIR`.

Ambient capture still applies — when the user mentions durable facts in passing, offer to save them. Process: complete the explicit request first, then flag the ambient fact and confirm before writing.


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)