opencode-claude-memory 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -144,7 +144,7 @@ The V2 plugin API exposes less than V1, so a few behaviours differ:
144
144
  | Memory directory | `~/.claude/projects/<sanitized canonical git root>/memory/` | identical (`sanitizePath`, worktree → main repo resolution ported byte for byte) |
145
145
  | File format | Markdown + `name` / `description` / `type` frontmatter | identical; frontmatter parsed only within the first 30 lines, as in Claude Code |
146
146
  | Taxonomy | `user`, `feedback`, `project`, `reference` | identical |
147
- | `MEMORY.md` | one-line pointers, hand-organisable | read with the same truncation rules; written with minimal line-level edits |
147
+ | `MEMORY.md` | one-line pointers, hand-organisable | read with the same truncation rules; written with minimal line-level edits, hand-written pointer lines are never replaced |
148
148
  | Sub-directories | `team/x.md` etc. | scanned, recalled and addressable from every tool |
149
149
  | System prompt | memory instructions + index + recalled memories | ported sections (`memoryTypes.ts`, `memdir.ts`) |
150
150
  | Recall | LLM side query | LLM side query (`findRelevantMemories.ts` port): a hidden child session on 1.x, a `generate.text` call on 2.x |
@@ -3,6 +3,13 @@ import type { MemoryAgents } from "../../config.js";
3
3
  import type { PermissionRule } from "./fork.js";
4
4
  export type { PermissionRule };
5
5
  export declare const BASELINE_PROBE_AGENT = "opencode-memory-baseline-probe";
6
+ export declare const PERMISSIONS_PROBE_AGENT = "opencode-memory-permissions-probe";
6
7
  export declare function sandboxRules(allowedTools: readonly string[]): PermissionRule[];
8
+ export declare const PROBE_RULES: readonly PermissionRule[];
7
9
  export declare function userRules(rules: readonly PermissionRule[], baseline: readonly PermissionRule[]): PermissionRule[];
8
10
  export declare function applyMemoryAgents(editor: AgentEditor, agents: MemoryAgents): void;
11
+ export declare function ownAgentRules(resolved: readonly PermissionRule[], sandbox: readonly PermissionRule[], probe: readonly PermissionRule[]): PermissionRule[] | undefined;
12
+ export declare function forkSessionRules(sandbox: readonly PermissionRule[], resolved: readonly PermissionRule[] | undefined, probe: readonly PermissionRule[] | undefined): {
13
+ rules: PermissionRule[];
14
+ own: boolean;
15
+ };
@@ -1,19 +1,26 @@
1
1
  import { memoryAgentDefaults } from "../../agents.js";
2
2
  // A throwaway id used to read the host's default ruleset inside the same draft.
3
3
  export const BASELINE_PROBE_AGENT = "opencode-memory-baseline-probe";
4
+ // A hidden, deny-all agent that is kept: every rule after its own PROBE_RULES was appended to all
5
+ // agents by a later transform (the global `permissions`, the browser plugin's `browser` deny). It
6
+ // tells the fork which part of a memory agent's resolved ruleset is global and which is its own.
7
+ export const PERMISSIONS_PROBE_AGENT = "opencode-memory-permissions-probe";
4
8
  export function sandboxRules(allowedTools) {
5
9
  return [
6
10
  { action: "*", resource: "*", effect: "deny" },
7
11
  ...allowedTools.map((tool) => ({ action: tool, resource: "*", effect: "allow" })),
8
12
  ];
9
13
  }
14
+ export const PROBE_RULES = sandboxRules([]);
10
15
  function ruleKey(rule) {
11
16
  return `${rule.action}\u0000${rule.resource}\u0000${rule.effect}`;
12
17
  }
18
+ function startsWith(rules, prefix) {
19
+ return prefix.every((rule, index) => rules[index] !== undefined && ruleKey(rules[index]) === ruleKey(rule));
20
+ }
13
21
  // The user's own rules: the agent's ruleset minus the host defaults every agent starts with.
14
22
  export function userRules(rules, baseline) {
15
- const prefix = baseline.every((rule, index) => rules[index] && ruleKey(rules[index]) === ruleKey(rule));
16
- if (prefix)
23
+ if (startsWith(rules, baseline))
17
24
  return rules.slice(baseline.length).map((rule) => ({ ...rule }));
18
25
  const base = new Set(baseline.map(ruleKey));
19
26
  return rules.filter((rule) => !base.has(ruleKey(rule))).map((rule) => ({ ...rule }));
@@ -22,6 +29,12 @@ export function applyMemoryAgents(editor, agents) {
22
29
  editor.update(BASELINE_PROBE_AGENT, () => { });
23
30
  const baseline = (editor.get(BASELINE_PROBE_AGENT)?.permissions ?? []).map((rule) => ({ ...rule }));
24
31
  editor.remove(BASELINE_PROBE_AGENT);
32
+ editor.update(PERMISSIONS_PROBE_AGENT, (agent) => {
33
+ agent.mode = "subagent";
34
+ agent.hidden = true;
35
+ agent.description = "opencode-claude-memory internal: permission probe, never runs";
36
+ agent.permissions = PROBE_RULES.map((rule) => ({ ...rule }));
37
+ });
25
38
  for (const [name, defaults] of Object.entries(memoryAgentDefaults(agents))) {
26
39
  applyAgent(editor, name, defaults, baseline);
27
40
  }
@@ -39,3 +52,32 @@ function applyAgent(editor, name, defaults, baseline) {
39
52
  agent.permissions = [...sandboxRules(defaults.allowedTools), ...configured];
40
53
  });
41
54
  }
55
+ // The memory agent's own rules: what follows the sandbox in its resolved ruleset, minus the rules
56
+ // appended to every agent (the probe's tail, removed as an ordered subsequence). These are the
57
+ // user's `agents.<name>.permissions` (and rules of a plugin that configured the agent first).
58
+ // undefined when either ruleset does not have the layout this plugin wrote.
59
+ export function ownAgentRules(resolved, sandbox, probe) {
60
+ if (!startsWith(resolved, sandbox) || !startsWith(probe, PROBE_RULES))
61
+ return undefined;
62
+ const shared = probe.slice(PROBE_RULES.length);
63
+ const own = [];
64
+ let next = 0;
65
+ for (const rule of resolved.slice(sandbox.length)) {
66
+ const expected = shared[next];
67
+ if (expected && ruleKey(expected) === ruleKey(rule))
68
+ next += 1;
69
+ else
70
+ own.push({ ...rule });
71
+ }
72
+ return next === shared.length ? own : undefined;
73
+ }
74
+ // The fork's session ruleset. The host evaluates `[...agent rules, ...session rules]` last match
75
+ // wins, and checks deny before any saved "always" approval, so a session ruleset that starts with the
76
+ // sandbox overrides every global rule (allow, ask, or a rule for `shell`, `read`,
77
+ // `external_directory`, ...). The agent's own rules follow it, so a user override in
78
+ // `agents.<name>.permissions` still widens or narrows the sandbox. Global rules no longer reach the
79
+ // fork; without a recoverable layout the fork runs under the bare sandbox.
80
+ export function forkSessionRules(sandbox, resolved, probe) {
81
+ const own = resolved && probe ? ownAgentRules(resolved, sandbox, probe) : undefined;
82
+ return { rules: [...sandbox.map((rule) => ({ ...rule })), ...(own ?? [])], own: own !== undefined };
83
+ }
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "../../util/log.js";
1
2
  import { type GenerateInput, type MemoryHost } from "../types.js";
2
3
  import { type ModelRef, type PermissionRule, type V2SessionApi } from "./fork.js";
3
4
  export type V2AgentInfo = {
@@ -14,6 +15,7 @@ export type V2HostOptions = {
14
15
  }>;
15
16
  getAgent: (name: string) => Promise<V2AgentInfo | undefined>;
16
17
  sandboxFor: (name: string) => PermissionRule[];
18
+ log?: Logger;
17
19
  };
18
20
  export declare function buildGeneratePrompt(input: Pick<GenerateInput, "system" | "text" | "schema">): string;
19
21
  export declare class V2Host implements MemoryHost {
@@ -1,13 +1,6 @@
1
- // MemoryHost over the V2 plugin context (`setup` entry).
2
- //
3
- // Differences from V1 that the coordinators see through the interface:
4
- // - readTranscript only has the messages after the last compaction (`session.context`), so a
5
- // watermark message that was compacted away yields `truncated: true`;
6
- // - listSessions is unavailable (the plugin API cannot list sessions);
7
- // - generate is a one-shot `generate.text` call: no system prompt, no structured output, so both
8
- // are folded into the prompt and the caller parses the text.
9
1
  import { withDeadline } from "../../util/timeout.js";
10
2
  import { SDK_READ_TIMEOUT_MS } from "../types.js";
3
+ import { forkSessionRules, PERMISSIONS_PROBE_AGENT } from "./agents.js";
11
4
  import { runV2Fork } from "./fork.js";
12
5
  import { toTranscript } from "./transcript.js";
13
6
  // The V2 plugin calls take no AbortSignal; withDeadline only bounds the wait.
@@ -63,12 +56,18 @@ export class V2Host {
63
56
  }
64
57
  }
65
58
  async fork(input) {
66
- // The resolved agent's ruleset (sandbox + the user's overrides) is also passed as the session
67
- // ruleset; without one the child would inherit its parent's session permissions.
68
- const agent = await this.agent(input.agent);
69
- const permissions = agent?.permissions?.length
70
- ? agent.permissions.map((rule) => ({ ...rule }))
71
- : this.options.sandboxFor(input.agent);
59
+ // The resolved agent ruleset carries the global `permissions` after the sandbox (see agents.ts),
60
+ // so it is never passed through: the session ruleset is the sandbox followed by the agent's own
61
+ // rules only. Without an explicit one the child would also inherit its parent's session rules.
62
+ const sandbox = this.options.sandboxFor(input.agent);
63
+ const [agent, probe] = await Promise.all([this.agent(input.agent), this.agent(PERMISSIONS_PROBE_AGENT)]);
64
+ const { rules: permissions, own } = forkSessionRules(sandbox, agent?.permissions, probe?.permissions);
65
+ if (!own && agent?.permissions) {
66
+ this.options.log?.("warn", "Memory agent overrides could not be told apart from global rules; forking with the bare sandbox", {
67
+ agent: input.agent,
68
+ });
69
+ }
70
+ this.options.log?.("debug", "Memory fork session permissions", { agent: input.agent, permissions });
72
71
  return runV2Fork(this.options.session, {
73
72
  ...input,
74
73
  permissions,
@@ -52,6 +52,7 @@ export const createV2Setup = (env, homeDir) => async (ctx) => {
52
52
  return agent?.data;
53
53
  },
54
54
  sandboxFor: (name) => sandboxRules(defaults[name]?.allowedTools ?? []),
55
+ log,
55
56
  });
56
57
  const deps = { store, config, host, owned, log };
57
58
  const recall = new RecallCoordinator(deps);
@@ -43,5 +43,4 @@ export declare class MemoryStore {
43
43
  search(query: string): MemoryEntry[];
44
44
  readIndex(): string;
45
45
  private writeIndex;
46
- private isUnchanged;
47
46
  }
@@ -2,7 +2,7 @@ import { cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, unl
2
2
  import { dirname, join } from "node:path";
3
3
  import { writeFileAtomicSync } from "../util/exclusiveFile.js";
4
4
  import { buildFrontmatter, editFrontmatter, FRONTMATTER_MAX_LINES, frontmatterKeys, ORIGIN, parseFrontmatter, parseMemoryType, } from "./frontmatter.js";
5
- import { buildIndexPointer, indexHasPointer, readIndexFile, removeIndexLine, upsertIndexLine } from "./indexFile.js";
5
+ import { buildIndexPointer, findIndexPointerLine, readIndexFile, removeIndexLine, upsertIndexLine, } from "./indexFile.js";
6
6
  import { ENTRYPOINT_NAME, findCanonicalGitRoot, findGitRoot, MAX_MEMORY_FILE_BYTES, resolveMemoryFilePath, sanitizePath, } from "./paths.js";
7
7
  import { formatMemoryManifest, nameFromFilename, readMemoryEntry, scanMemoryFiles, } from "./scan.js";
8
8
  // Owns the resolved memory paths for one project. Path resolution (git root, worktree canonical
@@ -65,9 +65,14 @@ export class MemoryStore {
65
65
  }
66
66
  const existing = readTextFile(filePath);
67
67
  const parsed = existing === null ? null : parseFrontmatter(existing);
68
- const pointer = buildIndexPointer(relativePath, input.name, input.description);
69
- if (parsed !== null && this.isUnchanged(parsed, relativePath, input, pointer)) {
70
- return { filePath, fileName: relativePath, unchanged: true };
68
+ const index = this.readIndex();
69
+ const nextIndex = indexAfterSave(index, relativePath, input, parsed);
70
+ if (parsed !== null && isUnchanged(parsed, relativePath, input)) {
71
+ // The same memory: at most a missing index pointer is added, the file itself is not rewritten.
72
+ if (nextIndex === index)
73
+ return { filePath, fileName: relativePath, unchanged: true };
74
+ this.writeIndex(nextIndex);
75
+ return { filePath, fileName: relativePath, unchanged: false };
71
76
  }
72
77
  const modified = this.now().toISOString();
73
78
  const fileContent = existing === null || parsed === null
@@ -83,7 +88,8 @@ export class MemoryStore {
83
88
  throw new Error(`Memory "${relativePath}" frontmatter would exceed ${FRONTMATTER_MAX_LINES} lines; trim its frontmatter first`);
84
89
  }
85
90
  writeFileAtomicSync(filePath, fileContent);
86
- this.writeIndex(upsertIndexLine(this.readIndex(), relativePath, pointer));
91
+ if (nextIndex !== index)
92
+ this.writeIndex(nextIndex);
87
93
  return { filePath, fileName: relativePath, unchanged: false };
88
94
  }
89
95
  // Deleting a memory that is not purely this plugin's (Claude Code's, dsh's, a hand-written one, or
@@ -123,19 +129,41 @@ export class MemoryStore {
123
129
  writeIndex(content) {
124
130
  writeFileAtomicSync(this.entrypoint, content);
125
131
  }
126
- // Unchanged means the same name, description, type and body, whatever else the frontmatter
127
- // holds, so an identical re-save neither bumps `modified` nor stamps provenance.
128
- isUnchanged(parsed, relativePath, input, pointer) {
129
- const { frontmatter, body, hasFrontmatter } = parsed;
130
- if (!hasFrontmatter)
131
- return false;
132
- // Missing fields compare as the defaults the scanner (and `read()`) reports for them.
133
- const same = (frontmatter.name ?? nameFromFilename(relativePath)) === input.name &&
134
- (frontmatter.description ?? "") === input.description &&
135
- (parseMemoryType(frontmatter.type) ?? "user") === input.type &&
136
- body.replace(/\r\n/g, "\n") === input.content.trim().replace(/\r\n/g, "\n");
137
- return same && indexHasPointer(this.readIndex(), pointer);
138
- }
132
+ }
133
+ // A memory's name and description as the scanner (and `read()`) reports them: missing fields
134
+ // count as their defaults.
135
+ function previousFields(parsed, relativePath) {
136
+ return {
137
+ name: parsed.frontmatter.name ?? nameFromFilename(relativePath),
138
+ description: parsed.frontmatter.description ?? "",
139
+ };
140
+ }
141
+ // Unchanged means the same name, description, type and body, whatever else the frontmatter holds,
142
+ // so an identical re-save neither bumps `modified` nor stamps provenance.
143
+ function isUnchanged(parsed, relativePath, input) {
144
+ if (!parsed.hasFrontmatter)
145
+ return false;
146
+ const before = previousFields(parsed, relativePath);
147
+ return (before.name === input.name &&
148
+ before.description === input.description &&
149
+ (parseMemoryType(parsed.frontmatter.type) ?? "user") === input.type &&
150
+ parsed.body.replace(/\r\n/g, "\n") === input.content.trim().replace(/\r\n/g, "\n"));
151
+ }
152
+ // MEMORY.md after a save. Index lines are often hand-written (Claude Code's format is
153
+ // `- [Title](file.md) — one-line hook`, and neither part has to repeat the frontmatter), so an
154
+ // existing pointer is kept unless it is still exactly the line this plugin generates from the
155
+ // memory's previous name and description: only such a line follows a new name or description. A
156
+ // memory without a pointer gains one. No other line is touched.
157
+ function indexAfterSave(raw, relativePath, input, parsed) {
158
+ const pointer = buildIndexPointer(relativePath, input.name, input.description);
159
+ const current = findIndexPointerLine(raw, relativePath);
160
+ if (current === undefined)
161
+ return upsertIndexLine(raw, relativePath, pointer);
162
+ if (current === pointer || parsed === null)
163
+ return raw;
164
+ const before = previousFields(parsed, relativePath);
165
+ const generatedBefore = buildIndexPointer(relativePath, before.name, before.description);
166
+ return current === generatedBefore ? upsertIndexLine(raw, relativePath, pointer) : raw;
139
167
  }
140
168
  // The frontmatter keys of a memory this plugin creates (buildFrontmatter), each on one line; its own
141
169
  // later saves keep exactly this set.
@@ -2,6 +2,7 @@ export declare const POINTER_RE: RegExp;
2
2
  export declare function readIndexFile(entrypoint: string): string;
3
3
  export declare function buildIndexPointer(fileName: string, name: string, description: string): string;
4
4
  export declare function indexHasPointer(raw: string, pointer: string): boolean;
5
+ export declare function findIndexPointerLine(raw: string, fileName: string): string | undefined;
5
6
  export declare function pointerTarget(line: string): string | undefined;
6
7
  export declare function upsertIndexLine(raw: string, fileName: string, pointer: string): string;
7
8
  export declare function removeIndexLine(raw: string, fileName: string): string;
@@ -20,6 +20,14 @@ export function buildIndexPointer(fileName, name, description) {
20
20
  export function indexHasPointer(raw, pointer) {
21
21
  return raw.split(/\r?\n/).some((line) => line.trimEnd() === pointer);
22
22
  }
23
+ // The first pointer line for `fileName` without its indentation and trailing whitespace, or
24
+ // undefined when the index has none.
25
+ export function findIndexPointerLine(raw, fileName) {
26
+ return raw
27
+ .split(/\r?\n/)
28
+ .find((line) => pointerTarget(line) === fileName)
29
+ ?.trim();
30
+ }
23
31
  function detectEol(raw) {
24
32
  return raw.includes("\r\n") ? "\r\n" : "\n";
25
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Claude Code memory: persistent, local-first shared memory with Claude Code-compatible Markdown files, LLM recall, in-process auto extraction, and auto-dream consolidation",
6
6
  "main": "dist/index.js",