opencode-claude-memory 2.3.0 → 2.3.1

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.
@@ -1,6 +1,7 @@
1
1
  import type { AgentEditor } from "@opencode/plugin/promise/agent";
2
- import type { MemoryAgents } from "../../config.js";
2
+ import { type MemoryAgents } from "../../config.js";
3
3
  import type { PermissionRule } from "./fork.js";
4
+ export declare function isMemoryAgent(agent: unknown, agents?: MemoryAgents): boolean;
4
5
  export type { PermissionRule };
5
6
  export declare const BASELINE_PROBE_AGENT = "opencode-memory-baseline-probe";
6
7
  export declare const PERMISSIONS_PROBE_AGENT = "opencode-memory-permissions-probe";
@@ -1,4 +1,8 @@
1
1
  import { memoryAgentDefaults } from "../../agents.js";
2
+ import { MEMORY_AGENTS } from "../../config.js";
3
+ export function isMemoryAgent(agent, agents = MEMORY_AGENTS) {
4
+ return typeof agent === "string" && (agent === agents.extract || agent === agents.dream || agent === agents.recall);
5
+ }
2
6
  // A throwaway id used to read the host's default ruleset inside the same draft.
3
7
  export const BASELINE_PROBE_AGENT = "opencode-memory-baseline-probe";
4
8
  // A hidden, deny-all agent that is kept: every rule after its own PROBE_RULES was appended to all
@@ -1,3 +1,4 @@
1
+ import { type MemoryAgents } from "../../config.js";
1
2
  import type { ExtractionCoordinator } from "../../extraction/ExtractionCoordinator.js";
2
3
  import type { RecalledMemory } from "../../recall/format.js";
3
4
  import { type RecallCoordinator } from "../../recall/RecallCoordinator.js";
@@ -9,19 +10,26 @@ export type V2Event = {
9
10
  } | null;
10
11
  data?: Record<string, unknown>;
11
12
  };
13
+ export declare const MAX_IGNORED_FORKS = 512;
12
14
  export type V2EventDeps = {
13
15
  directory: string;
14
16
  owned: OwnedSessions;
15
17
  recall: Pick<RecallCoordinator, "forget">;
16
18
  extraction: Pick<ExtractionCoordinator, "onSessionIdle" | "onSessionStatus" | "onSessionDeleted">;
19
+ agents?: MemoryAgents;
17
20
  now?: () => number;
18
21
  };
19
22
  export declare class V2SessionEvents {
20
23
  private readonly deps;
21
24
  private readonly sessions;
22
25
  private readonly recalled;
26
+ private readonly seenForks;
27
+ private readonly retainedForks;
23
28
  private readonly now;
24
29
  constructor(deps: V2EventDeps);
30
+ isIgnoredFork(sessionID: string): boolean;
31
+ ignoreFork(sessionID: string, persistent?: boolean): void;
32
+ untrack(sessionID: string): void;
25
33
  track(sessionID: string): void;
26
34
  isTracked(sessionID: string): boolean;
27
35
  surfacedKeys(sessionID: string): ReadonlySet<string>;
@@ -1,11 +1,33 @@
1
+ // V2 events → coordinator calls.
2
+ //
3
+ // The event stream is global across server locations, and the session execution events carry no
4
+ // location at all, so the adapter only acts on sessions it knows belong to its own location: those
5
+ // created here (`session.created` with this location) or seen by the location-scoped prompt and
6
+ // context hooks. Turn boundaries: `session.execution.started` = busy, `succeeded` / `failed` /
7
+ // `interrupted` = idle. The deprecated `session.status` / `session.idle` events are honoured too.
8
+ import { MEMORY_AGENTS } from "../../config.js";
1
9
  import { SESSION_STATE_TTL_MS } from "../../recall/RecallCoordinator.js";
2
10
  import { surfaceKey } from "../../store/scan.js";
11
+ import { isMemoryAgent } from "./agents.js";
12
+ import { isMemoryForkMetadata } from "./fork.js";
3
13
  const IDLE_EVENTS = [
4
14
  "session.execution.succeeded",
5
15
  "session.execution.failed",
6
16
  "session.execution.interrupted",
7
17
  "session.idle",
8
18
  ];
19
+ export const MAX_IGNORED_FORKS = 512;
20
+ function rememberBounded(set, id, maxSize = MAX_IGNORED_FORKS) {
21
+ if (set.has(id))
22
+ set.delete(id);
23
+ set.add(id);
24
+ while (set.size > maxSize) {
25
+ const oldest = set.values().next().value;
26
+ if (oldest === undefined)
27
+ break;
28
+ set.delete(oldest);
29
+ }
30
+ }
9
31
  export class V2SessionEvents {
10
32
  deps;
11
33
  // Tracked sessions → last time a hook or event saw them. Entries idle for longer than
@@ -16,13 +38,32 @@ export class V2SessionEvents {
16
38
  // model request and does not keep it in the history, so the recalled section is re-injected on
17
39
  // every request (not only the first one of a turn) and its keys are what the selector skips.
18
40
  recalled = new Map();
41
+ // Bounded sets of plugin forks that carry no `parentID` on V2: `seenForks` captures forks
42
+ // announced on `session.created` (including when it races ahead of `session.create()`), while
43
+ // `retainedForks` keeps forks whose cleanup delete failed or that a context hook observed under
44
+ // a memory agent so successful forks cannot evict them after the 60 s `owned` grace expires.
45
+ seenForks = new Set();
46
+ retainedForks = new Set();
19
47
  now;
20
48
  constructor(deps) {
21
49
  this.deps = deps;
22
50
  this.now = deps.now ?? Date.now;
23
51
  }
52
+ isIgnoredFork(sessionID) {
53
+ return this.seenForks.has(sessionID) || this.retainedForks.has(sessionID);
54
+ }
55
+ // Marks a session as a plugin-owned fork and clears any accidental user-session state.
56
+ ignoreFork(sessionID, persistent = false) {
57
+ rememberBounded(persistent ? this.retainedForks : this.seenForks, sessionID);
58
+ this.forget(sessionID);
59
+ }
60
+ // Drops any user-session tracking recorded before `session.create()` resolved (e.g., from an
61
+ // unannotated `session.created` race), without permanently retaining normal deleted forks.
62
+ untrack(sessionID) {
63
+ this.forget(sessionID);
64
+ }
24
65
  track(sessionID) {
25
- if (this.deps.owned.has(sessionID))
66
+ if (this.deps.owned.has(sessionID) || this.isIgnoredFork(sessionID))
26
67
  return;
27
68
  const now = this.now();
28
69
  this.evictStale(now);
@@ -65,16 +106,22 @@ export class V2SessionEvents {
65
106
  return;
66
107
  if (type === "session.deleted") {
67
108
  this.deps.owned.release(sessionID, 5_000);
109
+ this.seenForks.delete(sessionID);
110
+ this.retainedForks.delete(sessionID);
68
111
  this.forget(sessionID);
69
112
  return;
70
113
  }
71
114
  if (type === "session.created") {
115
+ if (isMemoryForkMetadata(data.metadata) || isMemoryAgent(data.agent, this.deps.agents ?? MEMORY_AGENTS)) {
116
+ this.ignoreFork(sessionID);
117
+ return;
118
+ }
72
119
  const directory = data.location?.directory ?? event.location?.directory;
73
120
  if (directory === this.deps.directory && !data.parentID)
74
121
  this.track(sessionID);
75
122
  return;
76
123
  }
77
- if (this.deps.owned.has(sessionID) || !this.sessions.has(sessionID))
124
+ if (this.deps.owned.has(sessionID) || this.isIgnoredFork(sessionID) || !this.sessions.has(sessionID))
78
125
  return;
79
126
  this.sessions.set(sessionID, this.now());
80
127
  if (type === "session.execution.started") {
@@ -3,6 +3,8 @@ export declare const FORK_CREATE_TIMEOUT_MS = 30000;
3
3
  export declare const FORK_CLEANUP_TIMEOUT_MS = 15000;
4
4
  export declare const FORK_READ_TIMEOUT_MS = 30000;
5
5
  export declare const FORK_SETTLE_POLL_MS = 250;
6
+ export declare const FORK_METADATA_KEY = "opencodeClaudeMemoryFork";
7
+ export declare function isMemoryForkMetadata(metadata: unknown): boolean;
6
8
  export type PermissionRule = {
7
9
  action: string;
8
10
  resource: string;
@@ -31,6 +33,7 @@ export type V2SessionApi = {
31
33
  title?: string;
32
34
  agent?: string;
33
35
  model?: ModelRef;
36
+ metadata?: Record<string, unknown>;
34
37
  permissions?: PermissionRule[];
35
38
  }): Promise<{
36
39
  id: string;
@@ -11,6 +11,14 @@ export const FORK_CLEANUP_TIMEOUT_MS = 15_000;
11
11
  export const FORK_READ_TIMEOUT_MS = 30_000;
12
12
  // `wait` can return before an enqueued prompt was admitted; re-check this often until the deadline.
13
13
  export const FORK_SETTLE_POLL_MS = 250;
14
+ // Durable session annotation so V2 forks stay identifiable without `parentID` (setting `parentID`
15
+ // causes OpenCode 2.x to post a `<subagent>` report into the parent chat and wake its model).
16
+ export const FORK_METADATA_KEY = "opencodeClaudeMemoryFork";
17
+ export function isMemoryForkMetadata(metadata) {
18
+ return (typeof metadata === "object" &&
19
+ metadata !== null &&
20
+ metadata[FORK_METADATA_KEY] === true);
21
+ }
14
22
  export class ForkSessionTimeoutError extends Error {
15
23
  constructor(title, timeoutMs) {
16
24
  super(`${title} timed out after ${timeoutMs}ms`);
@@ -66,10 +74,10 @@ export async function runV2Fork(session, input) {
66
74
  const cleanupTimeoutMs = input.cleanupTimeoutMs ?? FORK_CLEANUP_TIMEOUT_MS;
67
75
  const pollMs = input.pollMs ?? FORK_SETTLE_POLL_MS;
68
76
  const created = await deadline(`${input.title} session.create`, createTimeoutMs, () => session.create({
69
- ...(input.parentSessionID ? { parentID: input.parentSessionID } : {}),
70
77
  title: input.title,
71
78
  agent: input.agent,
72
79
  ...(input.model ? { model: input.model } : {}),
80
+ metadata: { [FORK_METADATA_KEY]: true },
73
81
  permissions: input.permissions,
74
82
  }));
75
83
  const forkID = created?.id;
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from "../../util/log.js";
2
- import { type GenerateInput, type MemoryHost } from "../types.js";
2
+ import { type ForkCleanupStage, type GenerateInput, type MemoryHost } from "../types.js";
3
3
  import { type ModelRef, type PermissionRule, type V2SessionApi } from "./fork.js";
4
4
  export type V2AgentInfo = {
5
5
  model?: ModelRef;
@@ -15,6 +15,8 @@ export type V2HostOptions = {
15
15
  }>;
16
16
  getAgent: (name: string) => Promise<V2AgentInfo | undefined>;
17
17
  sandboxFor: (name: string) => PermissionRule[];
18
+ onForkCreated?: (sessionID: string) => void;
19
+ onForkCleanupFailed?: (sessionID: string, stage: ForkCleanupStage) => void;
18
20
  log?: Logger;
19
21
  };
20
22
  export declare class V2Host implements MemoryHost {
@@ -69,12 +69,17 @@ export class V2Host {
69
69
  onCreated: (id) => {
70
70
  if (input.system)
71
71
  this.forkSystems.set(id, input.system);
72
+ this.options.onForkCreated?.(id);
72
73
  input.onCreated?.(id);
73
74
  },
74
75
  onFinished: (id) => {
75
76
  this.forkSystems.delete(id);
76
77
  input.onFinished?.(id);
77
78
  },
79
+ onCleanupFailed: (id, stage, error) => {
80
+ this.options.onForkCleanupFailed?.(id, stage);
81
+ input.onCleanupFailed?.(id, stage, error);
82
+ },
78
83
  });
79
84
  }
80
85
  }
@@ -11,7 +11,7 @@ import { buildMemoryToolSpecs } from "../../tools.js";
11
11
  import { getErrorMessage } from "../../util/log.js";
12
12
  import { OwnedSessions } from "../../util/ownedSessions.js";
13
13
  import { withDeadline } from "../../util/timeout.js";
14
- import { applyMemoryAgents, sandboxRules } from "./agents.js";
14
+ import { applyMemoryAgents, isMemoryAgent, sandboxRules } from "./agents.js";
15
15
  import { consumeEvents, V2SessionEvents } from "./events.js";
16
16
  import { V2Host } from "./host.js";
17
17
  import { createFileLogger } from "./log.js";
@@ -45,6 +45,7 @@ export const createV2Setup = (env, homeDir) => async (ctx) => {
45
45
  const log = config.readOnly ? () => { } : createFileLogger(store.stateDir);
46
46
  const owned = new OwnedSessions();
47
47
  const defaults = memoryAgentDefaults(config.agents, config.readOnly);
48
+ let events;
48
49
  const host = new V2Host({
49
50
  session: ctx.session,
50
51
  generateText: (input) => ctx.generate.text(input),
@@ -53,12 +54,17 @@ export const createV2Setup = (env, homeDir) => async (ctx) => {
53
54
  return agent?.data;
54
55
  },
55
56
  sandboxFor: (name) => sandboxRules(defaults[name]?.allowedTools ?? []),
57
+ onForkCreated: (id) => events?.untrack(id),
58
+ onForkCleanupFailed: (id, stage) => {
59
+ if (stage === "delete")
60
+ events?.ignoreFork(id, true);
61
+ },
56
62
  log,
57
63
  });
58
64
  const deps = { store, config, host, owned, log };
59
65
  const recall = new RecallCoordinator(deps);
60
66
  const extraction = new ExtractionCoordinator(deps);
61
- const events = new V2SessionEvents({ directory, owned, recall, extraction });
67
+ events = new V2SessionEvents({ directory, owned, recall, extraction, agents: config.agents });
62
68
  const registrations = [];
63
69
  const register = async (what, call) => {
64
70
  registrations.push(await bounded(what, SETUP_CALL_TIMEOUT_MS, call));
@@ -83,7 +89,7 @@ export const createV2Setup = (env, homeDir) => async (ctx) => {
83
89
  // Before the prompt is admitted: start the recall selector for this turn.
84
90
  await register("session.hook(prompt)", () => ctx.session.hook("prompt", (event) => {
85
91
  const { sessionID } = event;
86
- if (owned.has(sessionID) || host.systemFor(sessionID) !== undefined)
92
+ if (owned.has(sessionID) || events.isIgnoredFork(sessionID) || host.systemFor(sessionID) !== undefined)
87
93
  return;
88
94
  events.track(sessionID);
89
95
  const text = event.prompt?.text;
@@ -105,8 +111,12 @@ export const createV2Setup = (env, homeDir) => async (ctx) => {
105
111
  event.system.push({ type: "text", text: forkSystem });
106
112
  return;
107
113
  }
108
- if (owned.has(sessionID))
114
+ if (owned.has(sessionID) || events.isIgnoredFork(sessionID))
115
+ return;
116
+ if (isMemoryAgent(event.agent, config.agents)) {
117
+ events.ignoreFork(sessionID, true);
109
118
  return;
119
+ }
110
120
  events.track(sessionID);
111
121
  // Everything recalled in this session so far, not only this turn's selection: the system
112
122
  // prompt is rebuilt for every request, so a memory shown once would otherwise vanish after
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
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",