opencode-claude-memory 2.0.2 → 2.1.0
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 +67 -10
- package/dist/agents.d.ts +8 -10
- package/dist/agents.js +4 -43
- package/dist/extraction/ExtractionCoordinator.d.ts +10 -12
- package/dist/extraction/ExtractionCoordinator.js +66 -50
- package/dist/extraction/autodream.d.ts +2 -5
- package/dist/extraction/autodream.js +3 -7
- package/dist/host/types.d.ts +52 -0
- package/dist/host/types.js +6 -0
- package/dist/host/v1/agents.d.ts +11 -0
- package/dist/host/v1/agents.js +48 -0
- package/dist/host/v1/coordinators.d.ts +12 -0
- package/dist/host/v1/coordinators.js +43 -0
- package/dist/{extraction/forkSession.d.ts → host/v1/fork.d.ts} +3 -2
- package/dist/{extraction/forkSession.js → host/v1/fork.js} +2 -2
- package/dist/host/v1/host.d.ts +9 -0
- package/dist/host/v1/host.js +79 -0
- package/dist/{hooks → host/v1}/ignore.d.ts +2 -3
- package/dist/host/v1/ignore.js +29 -0
- package/dist/host/v1/log.d.ts +4 -0
- package/dist/host/v1/log.js +20 -0
- package/dist/{hooks → host/v1}/messages.d.ts +3 -2
- package/dist/{hooks → host/v1}/messages.js +3 -17
- package/dist/host/v1/plugin.d.ts +2 -0
- package/dist/host/v1/plugin.js +58 -0
- package/dist/host/v1/selector.d.ts +18 -0
- package/dist/host/v1/selector.js +12 -0
- package/dist/host/v1/tools.d.ts +5 -0
- package/dist/host/v1/tools.js +17 -0
- package/dist/host/v2/agents.d.ts +8 -0
- package/dist/host/v2/agents.js +41 -0
- package/dist/host/v2/events.d.ts +40 -0
- package/dist/host/v2/events.js +137 -0
- package/dist/host/v2/fork.d.ts +76 -0
- package/dist/host/v2/fork.js +136 -0
- package/dist/host/v2/host.d.ts +33 -0
- package/dist/host/v2/host.js +87 -0
- package/dist/host/v2/log.d.ts +4 -0
- package/dist/host/v2/log.js +40 -0
- package/dist/host/v2/plugin.d.ts +26 -0
- package/dist/host/v2/plugin.js +148 -0
- package/dist/host/v2/transcript.d.ts +3 -0
- package/dist/host/v2/transcript.js +50 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -55
- package/dist/recall/RecallCoordinator.d.ts +15 -10
- package/dist/recall/RecallCoordinator.js +26 -35
- package/dist/recall/selector.d.ts +14 -19
- package/dist/recall/selector.js +37 -47
- package/dist/recall/turn.d.ts +4 -0
- package/dist/recall/turn.js +49 -0
- package/dist/tools.d.ts +16 -3
- package/dist/tools.js +59 -41
- package/dist/util/log.d.ts +0 -2
- package/dist/util/log.js +2 -18
- package/package.json +16 -4
- package/dist/hooks/ignore.js +0 -53
- /package/dist/{sdk.d.ts → host/v1/sdk.d.ts} +0 -0
- /package/dist/{sdk.js → host/v1/sdk.js} +0 -0
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
});
|
|
56
|
+
const deps = { store, config, host, owned, log };
|
|
57
|
+
const recall = new RecallCoordinator(deps);
|
|
58
|
+
const extraction = new ExtractionCoordinator(deps);
|
|
59
|
+
const events = new V2SessionEvents({ directory, owned, recall, extraction });
|
|
60
|
+
const registrations = [];
|
|
61
|
+
const register = async (what, call) => {
|
|
62
|
+
registrations.push(await bounded(what, SETUP_CALL_TIMEOUT_MS, call));
|
|
63
|
+
};
|
|
64
|
+
const disposeRegistrations = async () => {
|
|
65
|
+
for (const registration of registrations.splice(0).reverse()) {
|
|
66
|
+
try {
|
|
67
|
+
await bounded("registration.dispose", CLEANUP_CALL_TIMEOUT_MS, () => registration.dispose());
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// the host tears the plugin scope down anyway
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const registerHooks = async () => {
|
|
75
|
+
await register("agent.transform", () => ctx.agent.transform((editor) => applyMemoryAgents(editor, config.agents)));
|
|
76
|
+
await register("tool.transform", () => ctx.tool.transform((editor) => {
|
|
77
|
+
for (const spec of buildMemoryToolSpecs(store, extraction)) {
|
|
78
|
+
editor.add(toV2Tool(spec));
|
|
79
|
+
}
|
|
80
|
+
}));
|
|
81
|
+
// Before the prompt is admitted: start the recall selector for this turn.
|
|
82
|
+
await register("session.hook(prompt)", () => ctx.session.hook("prompt", (event) => {
|
|
83
|
+
const { sessionID } = event;
|
|
84
|
+
if (owned.has(sessionID) || host.systemFor(sessionID) !== undefined)
|
|
85
|
+
return;
|
|
86
|
+
events.track(sessionID);
|
|
87
|
+
const text = event.prompt?.text;
|
|
88
|
+
recall.onTurn({
|
|
89
|
+
sessionID,
|
|
90
|
+
turnID: event.messageID,
|
|
91
|
+
query: typeof text === "string" ? text : undefined,
|
|
92
|
+
// Prompts reach the plugin one by one; there is no history to replay here.
|
|
93
|
+
ignoredInHistory: () => false,
|
|
94
|
+
surfaced: () => events.surfacedKeys(sessionID),
|
|
95
|
+
recentTools: () => [],
|
|
96
|
+
});
|
|
97
|
+
}));
|
|
98
|
+
// Before every model request: the fork's own system prompt, or the memory prompt.
|
|
99
|
+
await register("session.hook(context)", () => ctx.session.hook("context", async (event) => {
|
|
100
|
+
const { sessionID } = event;
|
|
101
|
+
const forkSystem = host.systemFor(sessionID);
|
|
102
|
+
if (forkSystem !== undefined) {
|
|
103
|
+
event.system.push({ type: "text", text: forkSystem });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (owned.has(sessionID))
|
|
107
|
+
return;
|
|
108
|
+
events.track(sessionID);
|
|
109
|
+
// Everything recalled in this session so far, not only this turn's selection: the system
|
|
110
|
+
// prompt is rebuilt for every request, so a memory shown once would otherwise vanish after
|
|
111
|
+
// the first tool call while the selector keeps skipping it as already surfaced.
|
|
112
|
+
const recalled = events.remember(sessionID, await recall.takeRecalled(sessionID));
|
|
113
|
+
const ignored = recall.isIgnored(sessionID);
|
|
114
|
+
const text = buildMemorySystemPrompt(store, ignored ? "" : formatRecalledMemories(recalled), {
|
|
115
|
+
includeIndex: !ignored,
|
|
116
|
+
});
|
|
117
|
+
event.system.push({ type: "text", text });
|
|
118
|
+
}));
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
await registerHooks();
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// A registration that failed or timed out fails the whole setup: undo the ones that succeeded
|
|
125
|
+
// so the host is not left with hidden agents or hooks of a plugin it reports as failed.
|
|
126
|
+
extraction.dispose();
|
|
127
|
+
owned.dispose();
|
|
128
|
+
await disposeRegistrations();
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
const abort = new AbortController();
|
|
132
|
+
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) }));
|
|
133
|
+
// Runs once the agent sandbox is registered; failures are logged inside catchUp().
|
|
134
|
+
void extraction.catchUp();
|
|
135
|
+
log("info", "opencode-claude-memory loaded", { directory, memoryDir: store.memoryDir });
|
|
136
|
+
return async () => {
|
|
137
|
+
abort.abort();
|
|
138
|
+
extraction.dispose();
|
|
139
|
+
owned.dispose();
|
|
140
|
+
await disposeRegistrations();
|
|
141
|
+
try {
|
|
142
|
+
await bounded("event stream", CLEANUP_CALL_TIMEOUT_MS, () => consuming);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// an iterator that ignores the abort is left to the host
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
};
|
|
@@ -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
|
|
5
|
+
export { createMemoryPlugin, createV2Setup };
|
|
4
6
|
export declare const MemoryPlugin: Plugin;
|
|
5
|
-
declare const
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
31
|
+
onTurn(turn: RecallTurn): {
|
|
32
|
+
ignored: boolean;
|
|
33
|
+
};
|
|
29
34
|
takeRecalled(sessionID: string | undefined): Promise<RecalledMemory[]>;
|
|
30
35
|
isIgnored(sessionID: string | undefined): boolean;
|
|
31
|
-
|
|
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 {
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
35
|
+
ignored: turn.ignoredInHistory(),
|
|
42
36
|
};
|
|
43
37
|
state.updatedAt = now;
|
|
44
|
-
|
|
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(
|
|
43
|
+
state.turnID = turn.turnID;
|
|
44
|
+
state.prefetch = state.ignored ? undefined : this.startPrefetch(turn);
|
|
52
45
|
}
|
|
53
46
|
this.sessions.set(sessionID, state);
|
|
54
|
-
|
|
55
|
-
output.messages = stripAutoMemoryParts(output.messages);
|
|
47
|
+
return { ignored: state.ignored };
|
|
56
48
|
}
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
114
|
-
const {
|
|
115
|
-
|
|
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 =
|
|
118
|
-
const recentTools =
|
|
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 =
|
|
123
|
-
|
|
124
|
-
|
|
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 {
|
|
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
|
|
5
|
-
readonly type: "
|
|
6
|
-
readonly
|
|
7
|
-
readonly
|
|
8
|
-
|
|
9
|
-
readonly
|
|
10
|
-
readonly type: "
|
|
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
|
|
22
|
-
|
|
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
|
|
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
|
|
32
|
+
export declare function selectRelevantMemories(input: SelectRelevantMemoriesInput): Promise<string[]>;
|
package/dist/recall/selector.js
CHANGED
|
@@ -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
|
|
14
|
-
type: "
|
|
15
|
-
|
|
16
|
-
type: "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
59
|
+
export async function selectRelevantMemories(input) {
|
|
67
60
|
if (input.memories.length === 0)
|
|
68
61
|
return [];
|
|
69
62
|
try {
|
|
70
|
-
const
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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>;
|