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,48 @@
|
|
|
1
|
+
// V1 registration of the hidden memory agents. Users override any field in opencode.json
|
|
2
|
+
// (`agent.opencode-memory-extract.model`, ...); the plugin only fills in what they did not set.
|
|
3
|
+
import { memoryAgentDefaults } from "../../agents.js";
|
|
4
|
+
export function buildAgentDefaults(agents) {
|
|
5
|
+
return Object.fromEntries(Object.entries(memoryAgentDefaults(agents)).map(([name, defaults]) => {
|
|
6
|
+
const config = {
|
|
7
|
+
mode: "all",
|
|
8
|
+
hidden: true,
|
|
9
|
+
...(defaults.temperature !== undefined ? { temperature: defaults.temperature } : {}),
|
|
10
|
+
prompt: defaults.prompt,
|
|
11
|
+
...(defaults.steps !== undefined ? { steps: defaults.steps } : {}),
|
|
12
|
+
tools: { "*": false, ...Object.fromEntries(defaults.allowedTools.map((tool) => [tool, true])) },
|
|
13
|
+
};
|
|
14
|
+
return [name, config];
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
export function mergeAgentConfig(defaults, user) {
|
|
18
|
+
if (!user)
|
|
19
|
+
return { ...defaults };
|
|
20
|
+
const merged = { ...defaults, ...user };
|
|
21
|
+
// `maxSteps` is the deprecated spelling of `steps`; a user who only set the old name must not be
|
|
22
|
+
// overridden by the default `steps`.
|
|
23
|
+
if (user.steps === undefined && user.maxSteps !== undefined)
|
|
24
|
+
delete merged.steps;
|
|
25
|
+
return merged;
|
|
26
|
+
}
|
|
27
|
+
export class AgentRegistry {
|
|
28
|
+
defaults;
|
|
29
|
+
merged;
|
|
30
|
+
constructor(agents) {
|
|
31
|
+
this.defaults = buildAgentDefaults(agents);
|
|
32
|
+
this.merged = { ...this.defaults };
|
|
33
|
+
}
|
|
34
|
+
// `config` hook: merge defaults under the user's own entries and remember the outcome so forks can
|
|
35
|
+
// pass the effective tool sandbox explicitly (defence in depth, see fork.ts).
|
|
36
|
+
register(config) {
|
|
37
|
+
config.agent ??= {};
|
|
38
|
+
const agent = config.agent;
|
|
39
|
+
for (const [name, defaults] of Object.entries(this.defaults)) {
|
|
40
|
+
const merged = mergeAgentConfig(defaults, agent[name]);
|
|
41
|
+
agent[name] = merged;
|
|
42
|
+
this.merged[name] = merged;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
toolsFor(name) {
|
|
46
|
+
return this.merged[name]?.tools;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ExtractionCoordinator } from "../../extraction/ExtractionCoordinator.js";
|
|
2
|
+
import { RecallCoordinator } from "../../recall/RecallCoordinator.js";
|
|
3
|
+
import type { ChatMessage, PluginEvent } from "./sdk.js";
|
|
4
|
+
export declare class V1RecallCoordinator extends RecallCoordinator {
|
|
5
|
+
onMessagesTransform(output: {
|
|
6
|
+
messages: ChatMessage[];
|
|
7
|
+
}): void;
|
|
8
|
+
onEvent(event: PluginEvent): void;
|
|
9
|
+
}
|
|
10
|
+
export declare class V1ExtractionCoordinator extends ExtractionCoordinator {
|
|
11
|
+
onEvent(event: PluginEvent): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// V1 hook payloads → coordinator calls. The coordinators themselves are host-independent; these
|
|
2
|
+
// subclasses only translate V1 events and the V1 message list.
|
|
3
|
+
import { ExtractionCoordinator } from "../../extraction/ExtractionCoordinator.js";
|
|
4
|
+
import { RecallCoordinator } from "../../recall/RecallCoordinator.js";
|
|
5
|
+
import { deriveIgnoredFromHistory, detectIgnoreMemory, stripAutoMemoryParts } from "./ignore.js";
|
|
6
|
+
import { buildTurnID, collectSurfacedMemoryKeys, extractRecentTools, getLastUserQuery } from "./messages.js";
|
|
7
|
+
export class V1RecallCoordinator extends RecallCoordinator {
|
|
8
|
+
// `experimental.chat.messages.transform`: derive the turn state and start the selector prefetch.
|
|
9
|
+
onMessagesTransform(output) {
|
|
10
|
+
const turn = getLastUserQuery(output.messages);
|
|
11
|
+
const { sessionID } = turn;
|
|
12
|
+
if (!sessionID) {
|
|
13
|
+
if (detectIgnoreMemory(turn.query))
|
|
14
|
+
output.messages = stripAutoMemoryParts(output.messages);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const messages = output.messages;
|
|
18
|
+
const { ignored } = this.onTurn({
|
|
19
|
+
sessionID,
|
|
20
|
+
turnID: buildTurnID(sessionID, turn),
|
|
21
|
+
query: turn.query,
|
|
22
|
+
ignoredInHistory: () => deriveIgnoredFromHistory(messages),
|
|
23
|
+
surfaced: () => collectSurfacedMemoryKeys(messages),
|
|
24
|
+
recentTools: () => extractRecentTools(messages),
|
|
25
|
+
});
|
|
26
|
+
if (ignored)
|
|
27
|
+
output.messages = stripAutoMemoryParts(output.messages);
|
|
28
|
+
}
|
|
29
|
+
onEvent(event) {
|
|
30
|
+
if (event.type === "session.deleted")
|
|
31
|
+
this.forget(event.properties.info.id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class V1ExtractionCoordinator extends ExtractionCoordinator {
|
|
35
|
+
onEvent(event) {
|
|
36
|
+
if (event.type === "session.idle")
|
|
37
|
+
this.onSessionIdle(event.properties.sessionID);
|
|
38
|
+
else if (event.type === "session.deleted")
|
|
39
|
+
this.onSessionDeleted(event.properties.info.id);
|
|
40
|
+
else if (event.type === "session.status")
|
|
41
|
+
this.onSessionStatus(event.properties.sessionID, event.properties.status.type);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ForkCleanupStage } from "../types.js";
|
|
2
|
+
import { type OpencodeClient } from "./sdk.js";
|
|
2
3
|
export declare const FORK_CREATE_TIMEOUT_MS = 30000;
|
|
3
4
|
export declare const FORK_CLEANUP_TIMEOUT_MS = 15000;
|
|
4
|
-
export type ForkCleanupStage
|
|
5
|
+
export type { ForkCleanupStage };
|
|
5
6
|
export type ForkSessionInput = {
|
|
6
7
|
client: OpencodeClient;
|
|
7
8
|
directory: string;
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// Every stage has its own deadline and AbortSignal: the SDK client disables fetch timeouts, so a
|
|
5
5
|
// hung `create`, `abort` or `delete` would otherwise pin the caller (and with it the extraction
|
|
6
6
|
// queue and the maintenance lock) forever. `onFinished` always fires, even when cleanup timed out.
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { TimeoutError, withDeadline } from "../../util/timeout.js";
|
|
8
|
+
import { unwrapData } from "./sdk.js";
|
|
9
9
|
export const FORK_CREATE_TIMEOUT_MS = 30_000;
|
|
10
10
|
export const FORK_CLEANUP_TIMEOUT_MS = 15_000;
|
|
11
11
|
export class ForkSessionTimeoutError extends Error {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type MemoryHost } from "../types.js";
|
|
2
|
+
import { type OpencodeClient } from "./sdk.js";
|
|
3
|
+
export type V1HostOptions = {
|
|
4
|
+
client: OpencodeClient;
|
|
5
|
+
directory: string;
|
|
6
|
+
toolsFor?: (agent: string) => Record<string, boolean> | undefined;
|
|
7
|
+
};
|
|
8
|
+
export declare function forkAnswerText(response: unknown): string;
|
|
9
|
+
export declare function createV1Host({ client, directory, toolsFor }: V1HostOptions): MemoryHost;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// MemoryHost over the V1 SDK client (`server` plugin entry). Every call keeps the exact SDK traffic
|
|
2
|
+
// the coordinators used to make directly: deadlines, `directory` query and the tool sandbox.
|
|
3
|
+
import { withDeadline } from "../../util/timeout.js";
|
|
4
|
+
import { SDK_READ_TIMEOUT_MS } from "../types.js";
|
|
5
|
+
import { runForkSession } from "./fork.js";
|
|
6
|
+
import { unwrapData } from "./sdk.js";
|
|
7
|
+
// Structured output when the server produced it, otherwise the text parts of the answer.
|
|
8
|
+
export function forkAnswerText(response) {
|
|
9
|
+
const data = unwrapData(response);
|
|
10
|
+
if (!data || typeof data !== "object")
|
|
11
|
+
return "";
|
|
12
|
+
const structured = data.info?.structured;
|
|
13
|
+
if (structured && typeof structured === "object")
|
|
14
|
+
return JSON.stringify(structured);
|
|
15
|
+
if (!Array.isArray(data.parts))
|
|
16
|
+
return "";
|
|
17
|
+
// Answer text only: reasoning parts carry text too, and their prose (or a JSON-looking line in it)
|
|
18
|
+
// must not be parsed as the selection.
|
|
19
|
+
return data.parts
|
|
20
|
+
.filter((part) => {
|
|
21
|
+
const type = part && typeof part === "object" ? part.type : undefined;
|
|
22
|
+
return type === undefined || type === "text";
|
|
23
|
+
})
|
|
24
|
+
.map((part) => (part && typeof part === "object" ? part.text : undefined))
|
|
25
|
+
.filter((text) => typeof text === "string")
|
|
26
|
+
.join("\n");
|
|
27
|
+
}
|
|
28
|
+
export function createV1Host({ client, directory, toolsFor }) {
|
|
29
|
+
return {
|
|
30
|
+
async readTranscript(sessionID) {
|
|
31
|
+
const response = await withDeadline("session.messages", SDK_READ_TIMEOUT_MS, (signal) => client.session.messages({ path: { id: sessionID }, query: { directory }, signal }));
|
|
32
|
+
const messages = unwrapData(response) ?? [];
|
|
33
|
+
// V1 returns the full session history, so the watermark message is never cut off.
|
|
34
|
+
return { messages, truncated: false };
|
|
35
|
+
},
|
|
36
|
+
async listSessions() {
|
|
37
|
+
const sessions = unwrapData(await withDeadline("session.list", SDK_READ_TIMEOUT_MS, (signal) => client.session.list({ query: { directory }, signal }))) ?? [];
|
|
38
|
+
return sessions.map((session) => ({
|
|
39
|
+
id: session.id,
|
|
40
|
+
parentID: session.parentID,
|
|
41
|
+
updatedAt: session.time?.updated,
|
|
42
|
+
}));
|
|
43
|
+
},
|
|
44
|
+
async runFork(input) {
|
|
45
|
+
await runForkSession({
|
|
46
|
+
client,
|
|
47
|
+
directory,
|
|
48
|
+
parentSessionID: input.parentSessionID,
|
|
49
|
+
title: input.title,
|
|
50
|
+
agent: input.agent,
|
|
51
|
+
system: input.system,
|
|
52
|
+
tools: toolsFor?.(input.agent),
|
|
53
|
+
parts: [{ type: "text", text: input.text }],
|
|
54
|
+
timeoutMs: input.timeoutMs,
|
|
55
|
+
onCreated: input.onCreated,
|
|
56
|
+
onFinished: input.onFinished,
|
|
57
|
+
onCleanupFailed: input.onCleanupFailed,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
async generate(input) {
|
|
61
|
+
const response = await runForkSession({
|
|
62
|
+
client,
|
|
63
|
+
directory,
|
|
64
|
+
parentSessionID: input.parentSessionID,
|
|
65
|
+
title: input.title,
|
|
66
|
+
agent: input.agent,
|
|
67
|
+
system: input.system,
|
|
68
|
+
tools: toolsFor?.(input.agent) ?? { "*": false },
|
|
69
|
+
...(input.schema ? { format: { type: "json_schema", schema: input.schema } } : {}),
|
|
70
|
+
parts: [{ type: "text", text: input.text }],
|
|
71
|
+
timeoutMs: input.timeoutMs,
|
|
72
|
+
onCreated: input.onCreated,
|
|
73
|
+
onFinished: input.onFinished,
|
|
74
|
+
onCleanupFailed: input.onCleanupFailed,
|
|
75
|
+
});
|
|
76
|
+
return forkAnswerText(response);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { ChatMessage, MessagePart } from "
|
|
2
|
-
export
|
|
3
|
-
export declare function detectResumeMemory(query: string | undefined): boolean;
|
|
1
|
+
import type { ChatMessage, MessagePart } from "./sdk.js";
|
|
2
|
+
export { detectIgnoreMemory, detectResumeMemory } from "../../recall/turn.js";
|
|
4
3
|
export declare function deriveIgnoredFromHistory(messages: readonly ChatMessage[]): boolean;
|
|
5
4
|
export declare function isAutoMemoryPart(part: MessagePart): boolean;
|
|
6
5
|
export declare function stripAutoMemoryParts(messages: readonly ChatMessage[]): ChatMessage[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// "Ignore memory" handling over the V1 message shape; the phrase rules live in recall/turn.ts.
|
|
2
|
+
import { AUTO_MEMORY_MARKER } from "../../prompt/systemPrompt.js";
|
|
3
|
+
import { deriveIgnoredFromQueries } from "../../recall/turn.js";
|
|
4
|
+
import { extractUserQuery, roleOf } from "./messages.js";
|
|
5
|
+
export { detectIgnoreMemory, detectResumeMemory } from "../../recall/turn.js";
|
|
6
|
+
// Replays every user message in order and returns whether memory is ignored at the end. Used to
|
|
7
|
+
// rebuild session state that the coordinator no longer holds (process restart, cache eviction), so
|
|
8
|
+
// an "ignore memory" said earlier in the session keeps applying without the user repeating it.
|
|
9
|
+
export function deriveIgnoredFromHistory(messages) {
|
|
10
|
+
return deriveIgnoredFromQueries(messages.filter((message) => roleOf(message) === "user").map((message) => extractUserQuery(message)));
|
|
11
|
+
}
|
|
12
|
+
export function isAutoMemoryPart(part) {
|
|
13
|
+
if (!part || typeof part !== "object")
|
|
14
|
+
return false;
|
|
15
|
+
const text = part.text;
|
|
16
|
+
return typeof text === "string" && text.trimStart().startsWith(AUTO_MEMORY_MARKER);
|
|
17
|
+
}
|
|
18
|
+
// Drops the plugin's own system segment from system-role messages; messages left without parts
|
|
19
|
+
// are removed entirely.
|
|
20
|
+
export function stripAutoMemoryParts(messages) {
|
|
21
|
+
return messages
|
|
22
|
+
.map((message) => {
|
|
23
|
+
if (roleOf(message) !== "system" || !Array.isArray(message.parts))
|
|
24
|
+
return message;
|
|
25
|
+
const parts = message.parts.filter((part) => !isAutoMemoryPart(part));
|
|
26
|
+
return parts.length === message.parts.length ? message : { ...message, parts };
|
|
27
|
+
})
|
|
28
|
+
.filter((message) => !Array.isArray(message.parts) || message.parts.length > 0);
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { LOG_SERVICE } from "../../util/log.js";
|
|
2
|
+
export { LOG_SERVICE };
|
|
3
|
+
// Logging goes through the OpenCode service log only. stderr is rendered into the chat UI, so a
|
|
4
|
+
// failing background task must never write there. Every call is best-effort and never throws.
|
|
5
|
+
export function createLogger(client, directory) {
|
|
6
|
+
return (level, message, extra) => {
|
|
7
|
+
const log = client?.app?.log;
|
|
8
|
+
if (typeof log !== "function")
|
|
9
|
+
return;
|
|
10
|
+
try {
|
|
11
|
+
void Promise.resolve(log.call(client?.app, {
|
|
12
|
+
body: { service: LOG_SERVICE, level, message, extra },
|
|
13
|
+
query: { directory },
|
|
14
|
+
})).catch(() => { });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// best-effort
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { extractSurfacedMemoryKeys } from "../../recall/turn.js";
|
|
2
|
+
import type { ChatMessage } from "./sdk.js";
|
|
3
|
+
export { extractSurfacedMemoryKeys };
|
|
2
4
|
export type TurnInfo = {
|
|
3
5
|
sessionID?: string;
|
|
4
6
|
query?: string;
|
|
@@ -9,6 +11,5 @@ export declare function roleOf(message: ChatMessage): string;
|
|
|
9
11
|
export declare function extractUserQuery(message: ChatMessage): string | undefined;
|
|
10
12
|
export declare function getLastUserQuery(messages: readonly ChatMessage[]): TurnInfo;
|
|
11
13
|
export declare function buildTurnID(sessionID: string, turn: TurnInfo): string;
|
|
12
|
-
export declare function extractSurfacedMemoryKeys(systemText: string): Set<string>;
|
|
13
14
|
export declare function collectSurfacedMemoryKeys(messages: readonly ChatMessage[]): Set<string>;
|
|
14
15
|
export declare function extractRecentTools(messages: readonly ChatMessage[]): string[];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Pure helpers over the SDK message shape passed to `experimental.chat.messages.transform`.
|
|
2
|
-
import {
|
|
1
|
+
// Pure helpers over the V1 SDK message shape passed to `experimental.chat.messages.transform`.
|
|
2
|
+
import { extractSurfacedMemoryKeys } from "../../recall/turn.js";
|
|
3
|
+
export { extractSurfacedMemoryKeys };
|
|
3
4
|
// Older runtimes and tests may hand over messages whose `info` lacks the full SDK shape; only the
|
|
4
5
|
// fields actually used are read, defensively.
|
|
5
6
|
export function roleOf(message) {
|
|
@@ -41,21 +42,6 @@ export function getLastUserQuery(messages) {
|
|
|
41
42
|
export function buildTurnID(sessionID, turn) {
|
|
42
43
|
return `${sessionID}:${turn.messageID ?? `${turn.messageIndex ?? -1}:${turn.query ?? ""}`}`;
|
|
43
44
|
}
|
|
44
|
-
// Parses "### <name> (<type>)" headers from the ## Recalled Memories section
|
|
45
|
-
// of system prompts. After compaction old system messages disappear, so
|
|
46
|
-
// the returned set naturally shrinks — no manual reset needed.
|
|
47
|
-
export function extractSurfacedMemoryKeys(systemText) {
|
|
48
|
-
const keys = new Set();
|
|
49
|
-
const recalledSection = systemText.indexOf(RECALLED_MEMORIES_HEADING);
|
|
50
|
-
if (recalledSection === -1)
|
|
51
|
-
return keys;
|
|
52
|
-
const headerPattern = /^### (.+?) \((\w+)\)/gm;
|
|
53
|
-
const section = systemText.slice(recalledSection);
|
|
54
|
-
for (let match = headerPattern.exec(section); match !== null; match = headerPattern.exec(section)) {
|
|
55
|
-
keys.add(`${match[1]}|${match[2]}`);
|
|
56
|
-
}
|
|
57
|
-
return keys;
|
|
58
|
-
}
|
|
59
45
|
export function collectSurfacedMemoryKeys(messages) {
|
|
60
46
|
const keys = new Set();
|
|
61
47
|
for (const message of messages) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parseConfig } from "../../config.js";
|
|
2
|
+
import { buildMemorySystemPrompt } from "../../prompt/systemPrompt.js";
|
|
3
|
+
import { formatRecalledMemories } from "../../recall/format.js";
|
|
4
|
+
import { MemoryStore } from "../../store/MemoryStore.js";
|
|
5
|
+
import { resolveMemoryRoot } from "../../store/paths.js";
|
|
6
|
+
import { OwnedSessions } from "../../util/ownedSessions.js";
|
|
7
|
+
import { AgentRegistry } from "./agents.js";
|
|
8
|
+
import { V1ExtractionCoordinator, V1RecallCoordinator } from "./coordinators.js";
|
|
9
|
+
import { createV1Host } from "./host.js";
|
|
10
|
+
import { createLogger } from "./log.js";
|
|
11
|
+
import { buildMemoryTools } from "./tools.js";
|
|
12
|
+
// Assembly only. Every piece of mutable state lives on the coordinators created here, so OpenCode's
|
|
13
|
+
// multi-directory `serve` gets fully isolated instances. `env` and `homeDir` are injectable so tests
|
|
14
|
+
// never touch the real environment (CLAUDE_CONFIG_DIR is the only variable read, see config.ts).
|
|
15
|
+
export const createMemoryPlugin = (env, homeDir) => async ({ worktree, directory, client }, options) => {
|
|
16
|
+
const config = parseConfig(options, env, homeDir);
|
|
17
|
+
const dir = directory ?? worktree;
|
|
18
|
+
const store = new MemoryStore(resolveMemoryRoot(worktree, dir), config);
|
|
19
|
+
const log = createLogger(client, dir);
|
|
20
|
+
const owned = new OwnedSessions();
|
|
21
|
+
const agents = new AgentRegistry(config.agents);
|
|
22
|
+
const host = client
|
|
23
|
+
? createV1Host({ client, directory: dir, toolsFor: (name) => agents.toolsFor(name) })
|
|
24
|
+
: undefined;
|
|
25
|
+
const deps = { store, config, host, owned, log };
|
|
26
|
+
const recall = new V1RecallCoordinator(deps);
|
|
27
|
+
const extraction = new V1ExtractionCoordinator(deps);
|
|
28
|
+
log("info", "opencode-claude-memory loaded", { directory: dir, memoryDir: store.memoryDir });
|
|
29
|
+
return {
|
|
30
|
+
config: async (cfg) => {
|
|
31
|
+
agents.register(cfg);
|
|
32
|
+
// Runs once the agent sandbox is known; failures are logged inside catchUp().
|
|
33
|
+
void extraction.catchUp();
|
|
34
|
+
},
|
|
35
|
+
event: async ({ event }) => {
|
|
36
|
+
if (event.type === "session.deleted")
|
|
37
|
+
owned.release(event.properties.info.id, 5_000);
|
|
38
|
+
recall.onEvent(event);
|
|
39
|
+
extraction.onEvent(event);
|
|
40
|
+
},
|
|
41
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
42
|
+
recall.onMessagesTransform(output);
|
|
43
|
+
},
|
|
44
|
+
"experimental.chat.system.transform": async ({ sessionID }, output) => {
|
|
45
|
+
if (owned.has(sessionID))
|
|
46
|
+
return;
|
|
47
|
+
const recalled = await recall.takeRecalled(sessionID);
|
|
48
|
+
output.system.push(buildMemorySystemPrompt(store, formatRecalledMemories(recalled), {
|
|
49
|
+
includeIndex: !recall.isIgnored(sessionID),
|
|
50
|
+
}));
|
|
51
|
+
},
|
|
52
|
+
tool: buildMemoryTools(store, extraction),
|
|
53
|
+
dispose: async () => {
|
|
54
|
+
extraction.dispose();
|
|
55
|
+
owned.dispose();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MemoryHeader } from "../../store/scan.js";
|
|
2
|
+
import type { OpencodeClient } from "./sdk.js";
|
|
3
|
+
export type SelectRelevantMemoryFilenamesInput = {
|
|
4
|
+
client: OpencodeClient;
|
|
5
|
+
directory: string;
|
|
6
|
+
parentSessionID: string;
|
|
7
|
+
query: string;
|
|
8
|
+
memories: readonly MemoryHeader[];
|
|
9
|
+
recentTools: readonly string[];
|
|
10
|
+
agent: string;
|
|
11
|
+
tools?: Record<string, boolean>;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
maxMemories: number;
|
|
14
|
+
onSessionCreated?: (sessionID: string) => void;
|
|
15
|
+
onSessionFinished?: (sessionID: string) => void;
|
|
16
|
+
};
|
|
17
|
+
export declare function extractSelectedMemories(response: unknown): string[];
|
|
18
|
+
export declare function selectRelevantMemoryFilenames(input: SelectRelevantMemoryFilenamesInput): Promise<string[]>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// V1 entry points of the recall selector: a hidden child session with structured output, driven
|
|
2
|
+
// through the V1 host. The selection logic itself lives in recall/selector.ts.
|
|
3
|
+
import { parseSelectedMemories, selectRelevantMemories } from "../../recall/selector.js";
|
|
4
|
+
import { createV1Host, forkAnswerText } from "./host.js";
|
|
5
|
+
export function extractSelectedMemories(response) {
|
|
6
|
+
return parseSelectedMemories(forkAnswerText(response));
|
|
7
|
+
}
|
|
8
|
+
// Never throws: a failed or timed-out selector simply recalls nothing for this turn.
|
|
9
|
+
export function selectRelevantMemoryFilenames(input) {
|
|
10
|
+
const { client, directory, tools, ...rest } = input;
|
|
11
|
+
return selectRelevantMemories({ ...rest, host: createV1Host({ client, directory, toolsFor: () => tools }) });
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin";
|
|
2
|
+
import type { ExtractionCoordinator } from "../../extraction/ExtractionCoordinator.js";
|
|
3
|
+
import type { MemoryStore } from "../../store/MemoryStore.js";
|
|
4
|
+
export type MemoryTools = NonNullable<Hooks["tool"]>;
|
|
5
|
+
export declare function buildMemoryTools(store: MemoryStore, extraction: Pick<ExtractionCoordinator, "recordSave">): MemoryTools;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { buildMemoryToolSpecs } from "../../tools.js";
|
|
2
|
+
export function buildMemoryTools(store, extraction) {
|
|
3
|
+
const tools = {};
|
|
4
|
+
for (const spec of buildMemoryToolSpecs(store, extraction)) {
|
|
5
|
+
tools[spec.name] = {
|
|
6
|
+
description: spec.description,
|
|
7
|
+
args: spec.args,
|
|
8
|
+
async execute(args, ctx) {
|
|
9
|
+
const result = await spec.execute(args, { sessionID: ctx?.sessionID });
|
|
10
|
+
return result.title === undefined ? { output: result.output } : { title: result.title, output: result.output };
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// The Zod v4 shapes come from the plugin's own `zod` dependency, not the SDK's copy; V1 accepts
|
|
15
|
+
// any object carrying `_zod`, but the two copies' types do not unify.
|
|
16
|
+
return tools;
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AgentEditor } from "@opencode/plugin/promise/agent";
|
|
2
|
+
import type { MemoryAgents } from "../../config.js";
|
|
3
|
+
import type { PermissionRule } from "./fork.js";
|
|
4
|
+
export type { PermissionRule };
|
|
5
|
+
export declare const BASELINE_PROBE_AGENT = "opencode-memory-baseline-probe";
|
|
6
|
+
export declare function sandboxRules(allowedTools: readonly string[]): PermissionRule[];
|
|
7
|
+
export declare function userRules(rules: readonly PermissionRule[], baseline: readonly PermissionRule[]): PermissionRule[];
|
|
8
|
+
export declare function applyMemoryAgents(editor: AgentEditor, agents: MemoryAgents): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { memoryAgentDefaults } from "../../agents.js";
|
|
2
|
+
// A throwaway id used to read the host's default ruleset inside the same draft.
|
|
3
|
+
export const BASELINE_PROBE_AGENT = "opencode-memory-baseline-probe";
|
|
4
|
+
export function sandboxRules(allowedTools) {
|
|
5
|
+
return [
|
|
6
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
7
|
+
...allowedTools.map((tool) => ({ action: tool, resource: "*", effect: "allow" })),
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
function ruleKey(rule) {
|
|
11
|
+
return `${rule.action}\u0000${rule.resource}\u0000${rule.effect}`;
|
|
12
|
+
}
|
|
13
|
+
// The user's own rules: the agent's ruleset minus the host defaults every agent starts with.
|
|
14
|
+
export function userRules(rules, baseline) {
|
|
15
|
+
const prefix = baseline.every((rule, index) => rules[index] && ruleKey(rules[index]) === ruleKey(rule));
|
|
16
|
+
if (prefix)
|
|
17
|
+
return rules.slice(baseline.length).map((rule) => ({ ...rule }));
|
|
18
|
+
const base = new Set(baseline.map(ruleKey));
|
|
19
|
+
return rules.filter((rule) => !base.has(ruleKey(rule))).map((rule) => ({ ...rule }));
|
|
20
|
+
}
|
|
21
|
+
export function applyMemoryAgents(editor, agents) {
|
|
22
|
+
editor.update(BASELINE_PROBE_AGENT, () => { });
|
|
23
|
+
const baseline = (editor.get(BASELINE_PROBE_AGENT)?.permissions ?? []).map((rule) => ({ ...rule }));
|
|
24
|
+
editor.remove(BASELINE_PROBE_AGENT);
|
|
25
|
+
for (const [name, defaults] of Object.entries(memoryAgentDefaults(agents))) {
|
|
26
|
+
applyAgent(editor, name, defaults, baseline);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function applyAgent(editor, name, defaults, baseline) {
|
|
30
|
+
const prior = editor.get(name);
|
|
31
|
+
const configured = prior ? userRules(prior.permissions, baseline) : [];
|
|
32
|
+
editor.update(name, (agent) => {
|
|
33
|
+
agent.mode = "subagent";
|
|
34
|
+
agent.hidden = true;
|
|
35
|
+
// Fill only what the user did not set (an agent configured before this transform keeps its own).
|
|
36
|
+
agent.system ??= defaults.prompt;
|
|
37
|
+
if (agent.steps === undefined && defaults.steps !== undefined)
|
|
38
|
+
agent.steps = defaults.steps;
|
|
39
|
+
agent.permissions = [...sandboxRules(defaults.allowedTools), ...configured];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtractionCoordinator } from "../../extraction/ExtractionCoordinator.js";
|
|
2
|
+
import type { RecalledMemory } from "../../recall/format.js";
|
|
3
|
+
import { type RecallCoordinator } from "../../recall/RecallCoordinator.js";
|
|
4
|
+
import type { OwnedSessions } from "../../util/ownedSessions.js";
|
|
5
|
+
export type V2Event = {
|
|
6
|
+
type?: string;
|
|
7
|
+
location?: {
|
|
8
|
+
directory?: string;
|
|
9
|
+
} | null;
|
|
10
|
+
data?: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
export type V2EventDeps = {
|
|
13
|
+
directory: string;
|
|
14
|
+
owned: OwnedSessions;
|
|
15
|
+
recall: Pick<RecallCoordinator, "forget">;
|
|
16
|
+
extraction: Pick<ExtractionCoordinator, "onSessionIdle" | "onSessionStatus" | "onSessionDeleted">;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
};
|
|
19
|
+
export declare class V2SessionEvents {
|
|
20
|
+
private readonly deps;
|
|
21
|
+
private readonly sessions;
|
|
22
|
+
private readonly recalled;
|
|
23
|
+
private readonly now;
|
|
24
|
+
constructor(deps: V2EventDeps);
|
|
25
|
+
track(sessionID: string): void;
|
|
26
|
+
isTracked(sessionID: string): boolean;
|
|
27
|
+
surfacedKeys(sessionID: string): ReadonlySet<string>;
|
|
28
|
+
remember(sessionID: string, memories: readonly RecalledMemory[]): RecalledMemory[];
|
|
29
|
+
private evictStale;
|
|
30
|
+
handle(event: V2Event): void;
|
|
31
|
+
private forget;
|
|
32
|
+
}
|
|
33
|
+
export declare const RESUBSCRIBE_MIN_MS = 1000;
|
|
34
|
+
export declare const RESUBSCRIBE_MAX_MS = 30000;
|
|
35
|
+
export declare function consumeEvents(subscribe: (options: {
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
}) => AsyncIterable<unknown>, signal: AbortSignal, onEvent: (event: V2Event) => void, onError: (error: unknown) => void, backoff?: {
|
|
38
|
+
minMs: number;
|
|
39
|
+
maxMs: number;
|
|
40
|
+
}): Promise<void>;
|