opencode-claude-memory 2.0.2 → 2.1.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.
Files changed (59) hide show
  1. package/README.md +67 -10
  2. package/dist/agents.d.ts +8 -10
  3. package/dist/agents.js +4 -43
  4. package/dist/extraction/ExtractionCoordinator.d.ts +10 -12
  5. package/dist/extraction/ExtractionCoordinator.js +66 -50
  6. package/dist/extraction/autodream.d.ts +2 -5
  7. package/dist/extraction/autodream.js +3 -7
  8. package/dist/host/types.d.ts +52 -0
  9. package/dist/host/types.js +6 -0
  10. package/dist/host/v1/agents.d.ts +11 -0
  11. package/dist/host/v1/agents.js +48 -0
  12. package/dist/host/v1/coordinators.d.ts +12 -0
  13. package/dist/host/v1/coordinators.js +43 -0
  14. package/dist/{extraction/forkSession.d.ts → host/v1/fork.d.ts} +3 -2
  15. package/dist/{extraction/forkSession.js → host/v1/fork.js} +2 -2
  16. package/dist/host/v1/host.d.ts +9 -0
  17. package/dist/host/v1/host.js +79 -0
  18. package/dist/{hooks → host/v1}/ignore.d.ts +2 -3
  19. package/dist/host/v1/ignore.js +29 -0
  20. package/dist/host/v1/log.d.ts +4 -0
  21. package/dist/host/v1/log.js +20 -0
  22. package/dist/{hooks → host/v1}/messages.d.ts +3 -2
  23. package/dist/{hooks → host/v1}/messages.js +3 -17
  24. package/dist/host/v1/plugin.d.ts +2 -0
  25. package/dist/host/v1/plugin.js +58 -0
  26. package/dist/host/v1/selector.d.ts +18 -0
  27. package/dist/host/v1/selector.js +12 -0
  28. package/dist/host/v1/tools.d.ts +5 -0
  29. package/dist/host/v1/tools.js +17 -0
  30. package/dist/host/v2/agents.d.ts +15 -0
  31. package/dist/host/v2/agents.js +83 -0
  32. package/dist/host/v2/events.d.ts +40 -0
  33. package/dist/host/v2/events.js +137 -0
  34. package/dist/host/v2/fork.d.ts +76 -0
  35. package/dist/host/v2/fork.js +136 -0
  36. package/dist/host/v2/host.d.ts +35 -0
  37. package/dist/host/v2/host.js +86 -0
  38. package/dist/host/v2/log.d.ts +4 -0
  39. package/dist/host/v2/log.js +40 -0
  40. package/dist/host/v2/plugin.d.ts +26 -0
  41. package/dist/host/v2/plugin.js +149 -0
  42. package/dist/host/v2/transcript.d.ts +3 -0
  43. package/dist/host/v2/transcript.js +50 -0
  44. package/dist/index.d.ts +7 -2
  45. package/dist/index.js +5 -55
  46. package/dist/recall/RecallCoordinator.d.ts +15 -10
  47. package/dist/recall/RecallCoordinator.js +26 -35
  48. package/dist/recall/selector.d.ts +14 -19
  49. package/dist/recall/selector.js +37 -47
  50. package/dist/recall/turn.d.ts +4 -0
  51. package/dist/recall/turn.js +49 -0
  52. package/dist/tools.d.ts +16 -3
  53. package/dist/tools.js +59 -41
  54. package/dist/util/log.d.ts +0 -2
  55. package/dist/util/log.js +2 -18
  56. package/package.json +16 -4
  57. package/dist/hooks/ignore.js +0 -53
  58. /package/dist/{sdk.d.ts → host/v1/sdk.d.ts} +0 -0
  59. /package/dist/{sdk.js → host/v1/sdk.js} +0 -0
@@ -0,0 +1,149 @@
1
+ import { z } from "zod/v4";
2
+ import { memoryAgentDefaults } from "../../agents.js";
3
+ import { parseConfig } from "../../config.js";
4
+ import { ExtractionCoordinator } from "../../extraction/ExtractionCoordinator.js";
5
+ import { buildMemorySystemPrompt } from "../../prompt/systemPrompt.js";
6
+ import { formatRecalledMemories } from "../../recall/format.js";
7
+ import { RecallCoordinator } from "../../recall/RecallCoordinator.js";
8
+ import { MemoryStore } from "../../store/MemoryStore.js";
9
+ import { resolveMemoryRoot } from "../../store/paths.js";
10
+ import { buildMemoryToolSpecs } from "../../tools.js";
11
+ import { getErrorMessage } from "../../util/log.js";
12
+ import { OwnedSessions } from "../../util/ownedSessions.js";
13
+ import { withDeadline } from "../../util/timeout.js";
14
+ import { applyMemoryAgents, sandboxRules } from "./agents.js";
15
+ import { consumeEvents, V2SessionEvents } from "./events.js";
16
+ import { V2Host } from "./host.js";
17
+ import { createFileLogger } from "./log.js";
18
+ // Deadline for host calls made while setting up and tearing down (registrations, disposal).
19
+ export const SETUP_CALL_TIMEOUT_MS = 30_000;
20
+ export const CLEANUP_CALL_TIMEOUT_MS = 5_000;
21
+ // The V2 plugin calls take no AbortSignal; withDeadline only bounds the wait.
22
+ function bounded(what, timeoutMs, call) {
23
+ return withDeadline(what, timeoutMs, () => call());
24
+ }
25
+ // `codemode: false` exposes the tool to the model under its own name (and permission action);
26
+ // otherwise V2 only reaches it through the Code Mode `execute` tool.
27
+ export function toV2Tool(spec) {
28
+ return {
29
+ name: spec.name,
30
+ description: spec.description,
31
+ input: z.object(spec.args),
32
+ options: { codemode: false },
33
+ async execute(input, context) {
34
+ const result = await spec.execute((input ?? {}), { sessionID: context.sessionID });
35
+ return { content: result.output, ...(result.title ? { metadata: { title: result.title } } : {}) };
36
+ },
37
+ };
38
+ }
39
+ export const createV2Setup = (env, homeDir) => async (ctx) => {
40
+ const config = parseConfig(ctx.options, env, homeDir);
41
+ const directory = ctx.location.directory;
42
+ const worktree = ctx.location.project?.directory ?? directory;
43
+ const store = new MemoryStore(resolveMemoryRoot(worktree, directory), config);
44
+ const log = createFileLogger(store.stateDir);
45
+ const owned = new OwnedSessions();
46
+ const defaults = memoryAgentDefaults(config.agents);
47
+ const host = new V2Host({
48
+ session: ctx.session,
49
+ generateText: (input) => ctx.generate.text(input),
50
+ getAgent: async (name) => {
51
+ const agent = (await ctx.agent.get({ agentID: name }));
52
+ return agent?.data;
53
+ },
54
+ sandboxFor: (name) => sandboxRules(defaults[name]?.allowedTools ?? []),
55
+ log,
56
+ });
57
+ const deps = { store, config, host, owned, log };
58
+ const recall = new RecallCoordinator(deps);
59
+ const extraction = new ExtractionCoordinator(deps);
60
+ const events = new V2SessionEvents({ directory, owned, recall, extraction });
61
+ const registrations = [];
62
+ const register = async (what, call) => {
63
+ registrations.push(await bounded(what, SETUP_CALL_TIMEOUT_MS, call));
64
+ };
65
+ const disposeRegistrations = async () => {
66
+ for (const registration of registrations.splice(0).reverse()) {
67
+ try {
68
+ await bounded("registration.dispose", CLEANUP_CALL_TIMEOUT_MS, () => registration.dispose());
69
+ }
70
+ catch {
71
+ // the host tears the plugin scope down anyway
72
+ }
73
+ }
74
+ };
75
+ const registerHooks = async () => {
76
+ await register("agent.transform", () => ctx.agent.transform((editor) => applyMemoryAgents(editor, config.agents)));
77
+ await register("tool.transform", () => ctx.tool.transform((editor) => {
78
+ for (const spec of buildMemoryToolSpecs(store, extraction)) {
79
+ editor.add(toV2Tool(spec));
80
+ }
81
+ }));
82
+ // Before the prompt is admitted: start the recall selector for this turn.
83
+ await register("session.hook(prompt)", () => ctx.session.hook("prompt", (event) => {
84
+ const { sessionID } = event;
85
+ if (owned.has(sessionID) || host.systemFor(sessionID) !== undefined)
86
+ return;
87
+ events.track(sessionID);
88
+ const text = event.prompt?.text;
89
+ recall.onTurn({
90
+ sessionID,
91
+ turnID: event.messageID,
92
+ query: typeof text === "string" ? text : undefined,
93
+ // Prompts reach the plugin one by one; there is no history to replay here.
94
+ ignoredInHistory: () => false,
95
+ surfaced: () => events.surfacedKeys(sessionID),
96
+ recentTools: () => [],
97
+ });
98
+ }));
99
+ // Before every model request: the fork's own system prompt, or the memory prompt.
100
+ await register("session.hook(context)", () => ctx.session.hook("context", async (event) => {
101
+ const { sessionID } = event;
102
+ const forkSystem = host.systemFor(sessionID);
103
+ if (forkSystem !== undefined) {
104
+ event.system.push({ type: "text", text: forkSystem });
105
+ return;
106
+ }
107
+ if (owned.has(sessionID))
108
+ return;
109
+ events.track(sessionID);
110
+ // Everything recalled in this session so far, not only this turn's selection: the system
111
+ // prompt is rebuilt for every request, so a memory shown once would otherwise vanish after
112
+ // the first tool call while the selector keeps skipping it as already surfaced.
113
+ const recalled = events.remember(sessionID, await recall.takeRecalled(sessionID));
114
+ const ignored = recall.isIgnored(sessionID);
115
+ const text = buildMemorySystemPrompt(store, ignored ? "" : formatRecalledMemories(recalled), {
116
+ includeIndex: !ignored,
117
+ });
118
+ event.system.push({ type: "text", text });
119
+ }));
120
+ };
121
+ try {
122
+ await registerHooks();
123
+ }
124
+ catch (error) {
125
+ // A registration that failed or timed out fails the whole setup: undo the ones that succeeded
126
+ // so the host is not left with hidden agents or hooks of a plugin it reports as failed.
127
+ extraction.dispose();
128
+ owned.dispose();
129
+ await disposeRegistrations();
130
+ throw error;
131
+ }
132
+ const abort = new AbortController();
133
+ const consuming = consumeEvents((options) => ctx.event.subscribe(options), abort.signal, (event) => events.handle(event), (error) => log("warn", "Event stream failed; re-subscribing", { error: getErrorMessage(error) }));
134
+ // Runs once the agent sandbox is registered; failures are logged inside catchUp().
135
+ void extraction.catchUp();
136
+ log("info", "opencode-claude-memory loaded", { directory, memoryDir: store.memoryDir });
137
+ return async () => {
138
+ abort.abort();
139
+ extraction.dispose();
140
+ owned.dispose();
141
+ await disposeRegistrations();
142
+ try {
143
+ await bounded("event stream", CLEANUP_CALL_TIMEOUT_MS, () => consuming);
144
+ }
145
+ catch {
146
+ // an iterator that ignores the abort is left to the host
147
+ }
148
+ };
149
+ };
@@ -0,0 +1,3 @@
1
+ import type { TranscriptMessage } from "../types.js";
2
+ import type { V2Message } from "./fork.js";
3
+ export declare function toTranscript(messages: readonly V2Message[]): TranscriptMessage[];
@@ -0,0 +1,50 @@
1
+ function toolOutput(content) {
2
+ if (!Array.isArray(content))
3
+ return undefined;
4
+ const text = content
5
+ .map((item) => (item && typeof item === "object" ? item.text : undefined))
6
+ .filter((value) => typeof value === "string")
7
+ .join("\n");
8
+ return text || undefined;
9
+ }
10
+ function assistantParts(content) {
11
+ if (!Array.isArray(content))
12
+ return [];
13
+ const parts = [];
14
+ for (const raw of content) {
15
+ if (!raw || typeof raw !== "object")
16
+ continue;
17
+ if (raw.type === "text" && typeof raw.text === "string") {
18
+ parts.push({ type: "text", text: raw.text });
19
+ }
20
+ else if (raw.type === "tool" && typeof raw.name === "string") {
21
+ const status = typeof raw.state?.status === "string" ? raw.state.status : undefined;
22
+ parts.push({ type: "tool", tool: raw.name, state: { status, output: toolOutput(raw.state?.content) } });
23
+ }
24
+ }
25
+ return parts;
26
+ }
27
+ // User and assistant messages only: synthetic, system, compaction and bookkeeping messages carry no
28
+ // conversation the user had.
29
+ export function toTranscript(messages) {
30
+ const transcript = [];
31
+ for (const message of messages) {
32
+ if (message.type === "user") {
33
+ transcript.push({
34
+ info: { id: message.id, role: "user", time: { created: message.time?.created } },
35
+ parts: typeof message.text === "string" ? [{ type: "text", text: message.text }] : [],
36
+ });
37
+ }
38
+ else if (message.type === "assistant") {
39
+ transcript.push({
40
+ info: {
41
+ id: message.id,
42
+ role: "assistant",
43
+ time: { created: message.time?.created, completed: message.time?.completed },
44
+ },
45
+ parts: assistantParts(message.content),
46
+ });
47
+ }
48
+ }
49
+ return transcript;
50
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type { Plugin, PluginModule } from "@opencode-ai/plugin";
2
+ import { createMemoryPlugin } from "./host/v1/plugin.js";
3
+ import { createV2Setup, type V2Cleanup, type V2Context } from "./host/v2/plugin.js";
2
4
  export declare const PLUGIN_ID = "opencode-claude-memory";
3
- export declare const createMemoryPlugin: (env?: NodeJS.ProcessEnv, homeDir?: string) => Plugin;
5
+ export { createMemoryPlugin, createV2Setup };
4
6
  export declare const MemoryPlugin: Plugin;
5
- declare const plugin: PluginModule;
7
+ export declare const MemorySetup: (context: V2Context) => Promise<V2Cleanup>;
8
+ declare const plugin: PluginModule & {
9
+ setup: typeof MemorySetup;
10
+ };
6
11
  export default plugin;
7
12
  export { MEMORY_AGENTS, type MemoryConfig, type MemoryOptions, MemoryOptionsSchema } from "./config.js";
8
13
  export { MemoryStore } from "./store/MemoryStore.js";
package/dist/index.js CHANGED
@@ -1,60 +1,10 @@
1
- import { AgentRegistry } from "./agents.js";
2
- import { parseConfig } from "./config.js";
3
- import { ExtractionCoordinator } from "./extraction/ExtractionCoordinator.js";
4
- import { buildMemorySystemPrompt } from "./prompt/systemPrompt.js";
5
- import { formatRecalledMemories } from "./recall/format.js";
6
- import { RecallCoordinator } from "./recall/RecallCoordinator.js";
7
- import { MemoryStore } from "./store/MemoryStore.js";
8
- import { resolveMemoryRoot } from "./store/paths.js";
9
- import { buildMemoryTools } from "./tools.js";
10
- import { createLogger } from "./util/log.js";
11
- import { OwnedSessions } from "./util/ownedSessions.js";
1
+ import { createMemoryPlugin } from "./host/v1/plugin.js";
2
+ import { createV2Setup } from "./host/v2/plugin.js";
12
3
  export const PLUGIN_ID = "opencode-claude-memory";
13
- // Assembly only. Every piece of mutable state lives on the coordinators created here, so OpenCode's
14
- // multi-directory `serve` gets fully isolated instances. `env` and `homeDir` are injectable so tests
15
- // never touch the real environment (CLAUDE_CONFIG_DIR is the only variable read, see config.ts).
16
- export const createMemoryPlugin = (env, homeDir) => async ({ worktree, directory, client }, options) => {
17
- const config = parseConfig(options, env, homeDir);
18
- const dir = directory ?? worktree;
19
- const store = new MemoryStore(resolveMemoryRoot(worktree, dir), config);
20
- const log = createLogger(client, dir);
21
- const owned = new OwnedSessions();
22
- const agents = new AgentRegistry(config.agents);
23
- const deps = { store, config, client, directory: dir, owned, agents, log };
24
- const recall = new RecallCoordinator(deps);
25
- const extraction = new ExtractionCoordinator(deps);
26
- return {
27
- config: async (cfg) => {
28
- agents.register(cfg);
29
- // Runs once the agent sandbox is known; failures are logged inside catchUp().
30
- void extraction.catchUp();
31
- },
32
- event: async ({ event }) => {
33
- if (event.type === "session.deleted")
34
- owned.release(event.properties.info.id, 5_000);
35
- recall.onEvent(event);
36
- extraction.onEvent(event);
37
- },
38
- "experimental.chat.messages.transform": async (_input, output) => {
39
- recall.onMessagesTransform(output);
40
- },
41
- "experimental.chat.system.transform": async ({ sessionID }, output) => {
42
- if (owned.has(sessionID))
43
- return;
44
- const recalled = await recall.takeRecalled(sessionID);
45
- output.system.push(buildMemorySystemPrompt(store, formatRecalledMemories(recalled), {
46
- includeIndex: !recall.isIgnored(sessionID),
47
- }));
48
- },
49
- tool: buildMemoryTools(store, extraction),
50
- dispose: async () => {
51
- extraction.dispose();
52
- owned.dispose();
53
- },
54
- };
55
- };
4
+ export { createMemoryPlugin, createV2Setup };
56
5
  export const MemoryPlugin = createMemoryPlugin();
57
- const plugin = { id: PLUGIN_ID, server: MemoryPlugin };
6
+ export const MemorySetup = createV2Setup();
7
+ const plugin = { id: PLUGIN_ID, server: MemoryPlugin, setup: MemorySetup };
58
8
  export default plugin;
59
9
  export { MEMORY_AGENTS, MemoryOptionsSchema } from "./config.js";
60
10
  export { MemoryStore } from "./store/MemoryStore.js";
@@ -1,34 +1,39 @@
1
- import type { AgentRegistry } from "../agents.js";
2
1
  import type { MemoryConfig } from "../config.js";
3
- import type { ChatMessage, OpencodeClient, PluginEvent } from "../sdk.js";
2
+ import type { MemoryHost } from "../host/types.js";
4
3
  import type { MemoryStore } from "../store/MemoryStore.js";
5
4
  import type { Logger } from "../util/log.js";
6
5
  import type { OwnedSessions } from "../util/ownedSessions.js";
7
6
  import { type RecalledMemory } from "./format.js";
8
7
  export declare const SESSION_STATE_TTL_MS: number;
9
8
  export declare const SELECTOR_GRACE_MS = 60000;
9
+ export type RecallTurn = {
10
+ sessionID: string;
11
+ turnID: string;
12
+ query: string | undefined;
13
+ ignoredInHistory: () => boolean;
14
+ surfaced: () => ReadonlySet<string>;
15
+ recentTools: () => readonly string[];
16
+ };
10
17
  export type RecallCoordinatorDeps = {
11
18
  store: MemoryStore;
12
19
  config: MemoryConfig;
13
- client: OpencodeClient | undefined;
14
- directory: string;
20
+ host: MemoryHost | undefined;
15
21
  owned: OwnedSessions;
16
- agents: AgentRegistry;
17
22
  log: Logger;
18
23
  now?: () => number;
19
24
  };
20
25
  export declare class RecallCoordinator {
21
- private readonly deps;
26
+ protected readonly deps: RecallCoordinatorDeps;
22
27
  private readonly sessions;
23
28
  private warnedMissingSessionID;
24
29
  private readonly now;
25
30
  constructor(deps: RecallCoordinatorDeps);
26
- onMessagesTransform(output: {
27
- messages: ChatMessage[];
28
- }): void;
31
+ onTurn(turn: RecallTurn): {
32
+ ignored: boolean;
33
+ };
29
34
  takeRecalled(sessionID: string | undefined): Promise<RecalledMemory[]>;
30
35
  isIgnored(sessionID: string | undefined): boolean;
31
- onEvent(event: PluginEvent): void;
36
+ forget(sessionID: string): void;
32
37
  get trackedSessions(): number;
33
38
  private race;
34
39
  private evictStale;
@@ -1,8 +1,7 @@
1
- import { deriveIgnoredFromHistory, detectIgnoreMemory, detectResumeMemory, stripAutoMemoryParts, } from "../hooks/ignore.js";
2
- import { buildTurnID, collectSurfacedMemoryKeys, extractRecentTools, getLastUserQuery } from "../hooks/messages.js";
3
1
  import { surfaceKey } from "../store/scan.js";
4
2
  import { recallSelectedMemories } from "./format.js";
5
- import { selectRelevantMemoryFilenames } from "./selector.js";
3
+ import { selectRelevantMemories } from "./selector.js";
4
+ import { detectIgnoreMemory, detectResumeMemory } from "./turn.js";
6
5
  export const SESSION_STATE_TTL_MS = 60 * 60 * 1000;
7
6
  export const SELECTOR_GRACE_MS = 60_000;
8
7
  const TIMEOUT = Symbol("recall-timeout");
@@ -23,39 +22,32 @@ export class RecallCoordinator {
23
22
  this.deps = deps;
24
23
  this.now = deps.now ?? Date.now;
25
24
  }
26
- // `experimental.chat.messages.transform`: derive the turn state and start the selector prefetch.
27
- onMessagesTransform(output) {
28
- const turn = getLastUserQuery(output.messages);
25
+ // Derives the turn state and starts the selector prefetch. Returns whether memory is ignored for
26
+ // this session, so the host can drop the plugin's own prompt segment from the history.
27
+ onTurn(turn) {
29
28
  const { sessionID } = turn;
30
- if (!sessionID) {
31
- if (detectIgnoreMemory(turn.query))
32
- output.messages = stripAutoMemoryParts(output.messages);
33
- return;
34
- }
35
29
  if (this.deps.owned.has(sessionID))
36
- return;
30
+ return { ignored: false };
37
31
  const now = this.now();
38
32
  this.evictStale(now);
39
33
  const state = this.sessions.get(sessionID) ?? {
40
34
  updatedAt: now,
41
- ignored: deriveIgnoredFromHistory(output.messages),
35
+ ignored: turn.ignoredInHistory(),
42
36
  };
43
37
  state.updatedAt = now;
44
- const turnID = buildTurnID(sessionID, turn);
45
- if (state.turnID !== turnID) {
38
+ if (state.turnID !== turn.turnID) {
46
39
  if (detectIgnoreMemory(turn.query))
47
40
  state.ignored = true;
48
41
  else if (state.ignored && detectResumeMemory(turn.query))
49
42
  state.ignored = false;
50
- state.turnID = turnID;
51
- state.prefetch = state.ignored ? undefined : this.startPrefetch(sessionID, turnID, turn.query, output.messages);
43
+ state.turnID = turn.turnID;
44
+ state.prefetch = state.ignored ? undefined : this.startPrefetch(turn);
52
45
  }
53
46
  this.sessions.set(sessionID, state);
54
- if (state.ignored)
55
- output.messages = stripAutoMemoryParts(output.messages);
47
+ return { ignored: state.ignored };
56
48
  }
57
- // `experimental.chat.system.transform`: wait for the prefetch (bounded by recall.waitMs) and hand
58
- // the result over exactly once. On timeout the prefetch keeps running for the next LLM call.
49
+ // Waits for the prefetch (bounded by recall.waitMs) and hands the result over exactly once. On
50
+ // timeout the prefetch keeps running for the next model call.
59
51
  async takeRecalled(sessionID) {
60
52
  if (!sessionID) {
61
53
  if (!this.warnedMissingSessionID) {
@@ -77,9 +69,9 @@ export class RecallCoordinator {
77
69
  isIgnored(sessionID) {
78
70
  return sessionID !== undefined && this.sessions.get(sessionID)?.ignored === true;
79
71
  }
80
- onEvent(event) {
81
- if (event.type === "session.deleted")
82
- this.sessions.delete(event.properties.info.id);
72
+ // The session was deleted: its preference and turn cache go with it.
73
+ forget(sessionID) {
74
+ this.sessions.delete(sessionID);
83
75
  }
84
76
  get trackedSessions() {
85
77
  return this.sessions.size;
@@ -110,24 +102,23 @@ export class RecallCoordinator {
110
102
  }
111
103
  }
112
104
  }
113
- startPrefetch(sessionID, turnID, query, messages) {
114
- const { client, config, store, owned, agents, directory } = this.deps;
115
- if (!config.recall.enabled || !client || !isUsefulRecallQuery(query))
105
+ startPrefetch(turn) {
106
+ const { host, config, store, owned } = this.deps;
107
+ const { query } = turn;
108
+ if (!config.recall.enabled || !host || !isUsefulRecallQuery(query))
116
109
  return undefined;
117
- const alreadySurfaced = collectSurfacedMemoryKeys(messages);
118
- const recentTools = extractRecentTools(messages);
110
+ const alreadySurfaced = turn.surfaced();
111
+ const recentTools = turn.recentTools();
119
112
  const headers = store.scan().filter((header) => !alreadySurfaced.has(surfaceKey(header)));
120
113
  if (headers.length === 0)
121
114
  return undefined;
122
- const promise = selectRelevantMemoryFilenames({
123
- client,
124
- directory,
125
- parentSessionID: sessionID,
115
+ const promise = selectRelevantMemories({
116
+ host,
117
+ parentSessionID: turn.sessionID,
126
118
  query,
127
119
  memories: headers,
128
120
  recentTools,
129
121
  agent: config.agents.recall,
130
- tools: agents.toolsFor(config.agents.recall),
131
122
  timeoutMs: config.recall.timeoutMs,
132
123
  maxMemories: config.recall.maxMemories,
133
124
  onSessionCreated: (id) => owned.add(id),
@@ -135,6 +126,6 @@ export class RecallCoordinator {
135
126
  })
136
127
  .then((selected) => recallSelectedMemories(headers, selected, alreadySurfaced, { maxMemories: config.recall.maxMemories }))
137
128
  .catch(() => []);
138
- return { turnID, promise, consumed: false };
129
+ return { turnID: turn.turnID, promise, consumed: false };
139
130
  }
140
131
  }
@@ -1,37 +1,32 @@
1
- import { type OpencodeClient } from "../sdk.js";
1
+ import type { MemoryHost } from "../host/types.js";
2
2
  import { type MemoryHeader } from "../store/scan.js";
3
3
  export declare const SELECT_MEMORIES_SYSTEM_PROMPT = "You are selecting memories that will be useful to OpenCode as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.\n\nReturn a list of filenames for the memories that will clearly be useful to OpenCode as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.\n- If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning.\n- If there are no memories in the list that would clearly be useful, feel free to return an empty list.\n- If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (OpenCode is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools \u2014 active use is exactly when those matter.\n";
4
- export declare const SELECT_MEMORIES_FORMAT: {
5
- readonly type: "json_schema";
6
- readonly schema: {
7
- readonly type: "object";
8
- readonly properties: {
9
- readonly selected_memories: {
10
- readonly type: "array";
11
- readonly items: {
12
- readonly type: "string";
13
- };
4
+ export declare const SELECT_MEMORIES_SCHEMA: {
5
+ readonly type: "object";
6
+ readonly properties: {
7
+ readonly selected_memories: {
8
+ readonly type: "array";
9
+ readonly items: {
10
+ readonly type: "string";
14
11
  };
15
12
  };
16
- readonly required: readonly ["selected_memories"];
17
- readonly additionalProperties: false;
18
13
  };
14
+ readonly required: readonly ["selected_memories"];
15
+ readonly additionalProperties: false;
19
16
  };
20
17
  export declare const RECALL_SELECTOR_TITLE = "opencode-memory recall selector";
21
- export type SelectRelevantMemoryFilenamesInput = {
22
- client: OpencodeClient;
23
- directory: string;
18
+ export type SelectRelevantMemoriesInput = {
19
+ host: MemoryHost;
24
20
  parentSessionID: string;
25
21
  query: string;
26
22
  memories: readonly MemoryHeader[];
27
23
  recentTools: readonly string[];
28
24
  agent: string;
29
- tools?: Record<string, boolean>;
30
25
  timeoutMs: number;
31
26
  maxMemories: number;
32
27
  onSessionCreated?: (sessionID: string) => void;
33
28
  onSessionFinished?: (sessionID: string) => void;
34
29
  };
35
- export declare function extractSelectedMemories(response: unknown): string[];
30
+ export declare function parseSelectedMemories(text: string): string[];
36
31
  export declare function buildSelectorQuery(query: string, memories: readonly MemoryHeader[], recentTools: readonly string[]): string;
37
- export declare function selectRelevantMemoryFilenames(input: SelectRelevantMemoryFilenamesInput): Promise<string[]>;
32
+ export declare function selectRelevantMemories(input: SelectRelevantMemoriesInput): Promise<string[]>;
@@ -1,7 +1,3 @@
1
- // LLM memory selection, ported from Claude Code's findRelevantMemories.ts side query. Runs in a
2
- // hidden child session so the main conversation never sees the selector exchange.
3
- import { runForkSession } from "../extraction/forkSession.js";
4
- import { unwrapData } from "../sdk.js";
5
1
  import { formatMemoryManifest } from "../store/scan.js";
6
2
  export const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to OpenCode as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
7
3
 
@@ -10,52 +6,49 @@ Return a list of filenames for the memories that will clearly be useful to OpenC
10
6
  - If there are no memories in the list that would clearly be useful, feel free to return an empty list.
11
7
  - If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (OpenCode is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter.
12
8
  `;
13
- export const SELECT_MEMORIES_FORMAT = {
14
- type: "json_schema",
15
- schema: {
16
- type: "object",
17
- properties: {
18
- selected_memories: { type: "array", items: { type: "string" } },
19
- },
20
- required: ["selected_memories"],
21
- additionalProperties: false,
9
+ export const SELECT_MEMORIES_SCHEMA = {
10
+ type: "object",
11
+ properties: {
12
+ selected_memories: { type: "array", items: { type: "string" } },
22
13
  },
14
+ required: ["selected_memories"],
15
+ additionalProperties: false,
23
16
  };
24
17
  export const RECALL_SELECTOR_TITLE = "opencode-memory recall selector";
25
- function tryParseSelectedMemories(raw) {
18
+ function selectedFrom(value) {
19
+ if (!value || typeof value !== "object")
20
+ return undefined;
21
+ const selected = value.selected_memories;
22
+ if (!Array.isArray(selected))
23
+ return undefined;
24
+ return selected.filter((item) => typeof item === "string");
25
+ }
26
+ function tryParse(raw) {
26
27
  try {
27
- const parsed = JSON.parse(raw);
28
- if (!Array.isArray(parsed.selected_memories))
29
- return undefined;
30
- return parsed.selected_memories.filter((item) => typeof item === "string");
28
+ return selectedFrom(JSON.parse(raw));
31
29
  }
32
30
  catch {
33
31
  return undefined;
34
32
  }
35
33
  }
36
- export function extractSelectedMemories(response) {
37
- const data = unwrapData(response);
38
- if (!data || typeof data !== "object")
39
- return [];
40
- const structured = data.info?.structured;
41
- if (structured && typeof structured === "object") {
42
- const selected = structured.selected_memories;
43
- if (Array.isArray(selected)) {
44
- return selected.filter((item) => typeof item === "string");
45
- }
46
- }
47
- if (!Array.isArray(data.parts))
34
+ // The selector answer as text: the whole answer, one of its lines, or the first JSON object inside
35
+ // prose / a code fence. Anything unparsable selects nothing.
36
+ export function parseSelectedMemories(text) {
37
+ const trimmed = text.trim();
38
+ if (!trimmed)
48
39
  return [];
49
- for (const part of data.parts) {
50
- if (!part || typeof part !== "object")
51
- continue;
52
- const text = part.text;
53
- if (typeof text !== "string")
54
- continue;
55
- const parsed = tryParseSelectedMemories(text);
40
+ const whole = tryParse(trimmed);
41
+ if (whole)
42
+ return whole;
43
+ for (const line of trimmed.split("\n")) {
44
+ const parsed = tryParse(line.trim());
56
45
  if (parsed)
57
46
  return parsed;
58
47
  }
48
+ const start = trimmed.indexOf("{");
49
+ const end = trimmed.lastIndexOf("}");
50
+ if (start >= 0 && end > start)
51
+ return tryParse(trimmed.slice(start, end + 1)) ?? [];
59
52
  return [];
60
53
  }
61
54
  export function buildSelectorQuery(query, memories, recentTools) {
@@ -63,26 +56,23 @@ export function buildSelectorQuery(query, memories, recentTools) {
63
56
  return `Query: ${query}\n\nAvailable memories:\n${formatMemoryManifest(memories)}${toolsSection}`;
64
57
  }
65
58
  // Never throws: a failed or timed-out selector simply recalls nothing for this turn.
66
- export async function selectRelevantMemoryFilenames(input) {
59
+ export async function selectRelevantMemories(input) {
67
60
  if (input.memories.length === 0)
68
61
  return [];
69
62
  try {
70
- const response = await runForkSession({
71
- client: input.client,
72
- directory: input.directory,
73
- parentSessionID: input.parentSessionID,
74
- title: RECALL_SELECTOR_TITLE,
63
+ const answer = await input.host.generate({
75
64
  agent: input.agent,
65
+ title: RECALL_SELECTOR_TITLE,
76
66
  system: SELECT_MEMORIES_SYSTEM_PROMPT,
77
- tools: input.tools ?? { "*": false },
78
- format: SELECT_MEMORIES_FORMAT,
79
- parts: [{ type: "text", text: buildSelectorQuery(input.query, input.memories, input.recentTools) }],
67
+ text: buildSelectorQuery(input.query, input.memories, input.recentTools),
68
+ schema: SELECT_MEMORIES_SCHEMA,
69
+ parentSessionID: input.parentSessionID,
80
70
  timeoutMs: input.timeoutMs,
81
71
  onCreated: input.onSessionCreated,
82
72
  onFinished: input.onSessionFinished,
83
73
  });
84
74
  const validFilenames = new Set(input.memories.map((memory) => memory.filename));
85
- return extractSelectedMemories(response)
75
+ return parseSelectedMemories(answer)
86
76
  .filter((filename) => validFilenames.has(filename))
87
77
  .slice(0, input.maxMemories);
88
78
  }
@@ -0,0 +1,4 @@
1
+ export declare function detectIgnoreMemory(query: string | undefined): boolean;
2
+ export declare function detectResumeMemory(query: string | undefined): boolean;
3
+ export declare function deriveIgnoredFromQueries(queries: Iterable<string | undefined>): boolean;
4
+ export declare function extractSurfacedMemoryKeys(systemText: string): Set<string>;