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,137 @@
|
|
|
1
|
+
import { SESSION_STATE_TTL_MS } from "../../recall/RecallCoordinator.js";
|
|
2
|
+
import { surfaceKey } from "../../store/scan.js";
|
|
3
|
+
const IDLE_EVENTS = [
|
|
4
|
+
"session.execution.succeeded",
|
|
5
|
+
"session.execution.failed",
|
|
6
|
+
"session.execution.interrupted",
|
|
7
|
+
"session.idle",
|
|
8
|
+
];
|
|
9
|
+
export class V2SessionEvents {
|
|
10
|
+
deps;
|
|
11
|
+
// Tracked sessions → last time a hook or event saw them. Entries idle for longer than
|
|
12
|
+
// SESSION_STATE_TTL_MS are evicted so a long-running server does not leak; the next prompt or
|
|
13
|
+
// context hook tracks the session again.
|
|
14
|
+
sessions = new Map();
|
|
15
|
+
// Memories recalled per session, keyed by `surfaceKey`. V2 rebuilds the system prompt for every
|
|
16
|
+
// model request and does not keep it in the history, so the recalled section is re-injected on
|
|
17
|
+
// every request (not only the first one of a turn) and its keys are what the selector skips.
|
|
18
|
+
recalled = new Map();
|
|
19
|
+
now;
|
|
20
|
+
constructor(deps) {
|
|
21
|
+
this.deps = deps;
|
|
22
|
+
this.now = deps.now ?? Date.now;
|
|
23
|
+
}
|
|
24
|
+
track(sessionID) {
|
|
25
|
+
if (this.deps.owned.has(sessionID))
|
|
26
|
+
return;
|
|
27
|
+
const now = this.now();
|
|
28
|
+
this.evictStale(now);
|
|
29
|
+
this.sessions.set(sessionID, now);
|
|
30
|
+
}
|
|
31
|
+
isTracked(sessionID) {
|
|
32
|
+
return this.sessions.has(sessionID);
|
|
33
|
+
}
|
|
34
|
+
surfacedKeys(sessionID) {
|
|
35
|
+
return new Set(this.recalled.get(sessionID)?.keys() ?? []);
|
|
36
|
+
}
|
|
37
|
+
// Adds the memories recalled for this request and returns everything recalled so far in the session.
|
|
38
|
+
remember(sessionID, memories) {
|
|
39
|
+
let session = this.recalled.get(sessionID);
|
|
40
|
+
if (!session && memories.length > 0) {
|
|
41
|
+
session = new Map();
|
|
42
|
+
this.recalled.set(sessionID, session);
|
|
43
|
+
}
|
|
44
|
+
for (const memory of memories) {
|
|
45
|
+
session?.set(surfaceKey({ name: memory.name, type: memory.type }), memory);
|
|
46
|
+
}
|
|
47
|
+
return [...(session?.values() ?? [])];
|
|
48
|
+
}
|
|
49
|
+
evictStale(now) {
|
|
50
|
+
const cutoff = now - SESSION_STATE_TTL_MS;
|
|
51
|
+
for (const [id, seenAt] of this.sessions) {
|
|
52
|
+
if (seenAt >= cutoff)
|
|
53
|
+
continue;
|
|
54
|
+
this.sessions.delete(id);
|
|
55
|
+
this.recalled.delete(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
handle(event) {
|
|
59
|
+
const type = event?.type;
|
|
60
|
+
const data = event?.data;
|
|
61
|
+
if (!type || !data)
|
|
62
|
+
return;
|
|
63
|
+
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
|
|
64
|
+
if (!sessionID)
|
|
65
|
+
return;
|
|
66
|
+
if (type === "session.deleted") {
|
|
67
|
+
this.deps.owned.release(sessionID, 5_000);
|
|
68
|
+
this.forget(sessionID);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (type === "session.created") {
|
|
72
|
+
const directory = data.location?.directory ?? event.location?.directory;
|
|
73
|
+
if (directory === this.deps.directory && !data.parentID)
|
|
74
|
+
this.track(sessionID);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (this.deps.owned.has(sessionID) || !this.sessions.has(sessionID))
|
|
78
|
+
return;
|
|
79
|
+
this.sessions.set(sessionID, this.now());
|
|
80
|
+
if (type === "session.execution.started") {
|
|
81
|
+
this.deps.extraction.onSessionStatus(sessionID, "busy");
|
|
82
|
+
}
|
|
83
|
+
else if (IDLE_EVENTS.includes(type)) {
|
|
84
|
+
this.deps.extraction.onSessionIdle(sessionID);
|
|
85
|
+
}
|
|
86
|
+
else if (type === "session.status") {
|
|
87
|
+
const status = data.status?.type;
|
|
88
|
+
if (status === "idle")
|
|
89
|
+
this.deps.extraction.onSessionIdle(sessionID);
|
|
90
|
+
else if (typeof status === "string")
|
|
91
|
+
this.deps.extraction.onSessionStatus(sessionID, status);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
forget(sessionID) {
|
|
95
|
+
this.sessions.delete(sessionID);
|
|
96
|
+
this.recalled.delete(sessionID);
|
|
97
|
+
this.deps.recall.forget(sessionID);
|
|
98
|
+
this.deps.extraction.onSessionDeleted(sessionID);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export const RESUBSCRIBE_MIN_MS = 1_000;
|
|
102
|
+
export const RESUBSCRIBE_MAX_MS = 30_000;
|
|
103
|
+
// Consumes the event stream until `signal` aborts. A stream that fails (a slow consumer overflows
|
|
104
|
+
// it) is re-opened with a capped backoff.
|
|
105
|
+
export async function consumeEvents(subscribe, signal, onEvent, onError, backoff = { minMs: RESUBSCRIBE_MIN_MS, maxMs: RESUBSCRIBE_MAX_MS }) {
|
|
106
|
+
let delay = backoff.minMs;
|
|
107
|
+
while (!signal.aborted) {
|
|
108
|
+
try {
|
|
109
|
+
for await (const event of subscribe({ signal })) {
|
|
110
|
+
if (signal.aborted)
|
|
111
|
+
return;
|
|
112
|
+
delay = backoff.minMs;
|
|
113
|
+
try {
|
|
114
|
+
onEvent(event);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
onError(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (signal.aborted)
|
|
123
|
+
return;
|
|
124
|
+
onError(error);
|
|
125
|
+
}
|
|
126
|
+
if (signal.aborted)
|
|
127
|
+
return;
|
|
128
|
+
await new Promise((resolve) => {
|
|
129
|
+
const timer = setTimeout(resolve, delay);
|
|
130
|
+
signal.addEventListener("abort", () => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
resolve();
|
|
133
|
+
}, { once: true });
|
|
134
|
+
});
|
|
135
|
+
delay = Math.min(delay * 2, backoff.maxMs);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ForkInput } from "../types.js";
|
|
2
|
+
export declare const FORK_CREATE_TIMEOUT_MS = 30000;
|
|
3
|
+
export declare const FORK_CLEANUP_TIMEOUT_MS = 15000;
|
|
4
|
+
export declare const FORK_READ_TIMEOUT_MS = 30000;
|
|
5
|
+
export declare const FORK_SETTLE_POLL_MS = 250;
|
|
6
|
+
export type PermissionRule = {
|
|
7
|
+
action: string;
|
|
8
|
+
resource: string;
|
|
9
|
+
effect: "allow" | "deny" | "ask";
|
|
10
|
+
};
|
|
11
|
+
export type ModelRef = {
|
|
12
|
+
id: string;
|
|
13
|
+
providerID: string;
|
|
14
|
+
variant?: string;
|
|
15
|
+
};
|
|
16
|
+
export type V2Message = {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string;
|
|
19
|
+
time?: {
|
|
20
|
+
created?: number;
|
|
21
|
+
completed?: number;
|
|
22
|
+
};
|
|
23
|
+
text?: string;
|
|
24
|
+
finish?: string;
|
|
25
|
+
error?: unknown;
|
|
26
|
+
content?: unknown;
|
|
27
|
+
};
|
|
28
|
+
export type V2SessionApi = {
|
|
29
|
+
create(input: {
|
|
30
|
+
parentID?: string;
|
|
31
|
+
title?: string;
|
|
32
|
+
agent?: string;
|
|
33
|
+
model?: ModelRef;
|
|
34
|
+
permissions?: PermissionRule[];
|
|
35
|
+
}): Promise<{
|
|
36
|
+
id: string;
|
|
37
|
+
}>;
|
|
38
|
+
prompt(input: {
|
|
39
|
+
sessionID: string;
|
|
40
|
+
text: string;
|
|
41
|
+
}): Promise<unknown>;
|
|
42
|
+
wait(input: {
|
|
43
|
+
sessionID: string;
|
|
44
|
+
}): Promise<unknown>;
|
|
45
|
+
context(input: {
|
|
46
|
+
sessionID: string;
|
|
47
|
+
}): Promise<readonly V2Message[]>;
|
|
48
|
+
interrupt(input: {
|
|
49
|
+
sessionID: string;
|
|
50
|
+
}): Promise<unknown>;
|
|
51
|
+
remove(input: {
|
|
52
|
+
sessionID: string;
|
|
53
|
+
}): Promise<unknown>;
|
|
54
|
+
};
|
|
55
|
+
export type V2ForkInput = ForkInput & {
|
|
56
|
+
permissions: PermissionRule[];
|
|
57
|
+
model?: ModelRef;
|
|
58
|
+
createTimeoutMs?: number;
|
|
59
|
+
cleanupTimeoutMs?: number;
|
|
60
|
+
pollMs?: number;
|
|
61
|
+
};
|
|
62
|
+
export declare class ForkSessionTimeoutError extends Error {
|
|
63
|
+
constructor(title: string, timeoutMs: number);
|
|
64
|
+
}
|
|
65
|
+
export declare class ForkSessionError extends Error {
|
|
66
|
+
constructor(title: string, detail: string);
|
|
67
|
+
}
|
|
68
|
+
type Outcome = {
|
|
69
|
+
done: true;
|
|
70
|
+
failure?: string;
|
|
71
|
+
} | {
|
|
72
|
+
done: false;
|
|
73
|
+
};
|
|
74
|
+
export declare function forkOutcome(messages: readonly V2Message[], promptID: string | undefined): Outcome;
|
|
75
|
+
export declare function runV2Fork(session: V2SessionApi, input: V2ForkInput): Promise<readonly V2Message[]>;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Lifecycle of a plugin-owned V2 child session: create(agent) → prompt → wait → context → remove.
|
|
2
|
+
// `prompt` only enqueues the message and `wait` resolves once the session loop is idle again, so the
|
|
3
|
+
// outcome is read back from the context: the fork succeeded when the last assistant message is
|
|
4
|
+
// complete and carries no error. On timeout the fork is interrupted before it is removed.
|
|
5
|
+
//
|
|
6
|
+
// The plugin-facing session API takes no AbortSignal, so every call is bounded with withTimeout; a
|
|
7
|
+
// hung host call must never pin the extraction queue or the maintenance lock.
|
|
8
|
+
import { TimeoutError, withDeadline } from "../../util/timeout.js";
|
|
9
|
+
export const FORK_CREATE_TIMEOUT_MS = 30_000;
|
|
10
|
+
export const FORK_CLEANUP_TIMEOUT_MS = 15_000;
|
|
11
|
+
export const FORK_READ_TIMEOUT_MS = 30_000;
|
|
12
|
+
// `wait` can return before an enqueued prompt was admitted; re-check this often until the deadline.
|
|
13
|
+
export const FORK_SETTLE_POLL_MS = 250;
|
|
14
|
+
export class ForkSessionTimeoutError extends Error {
|
|
15
|
+
constructor(title, timeoutMs) {
|
|
16
|
+
super(`${title} timed out after ${timeoutMs}ms`);
|
|
17
|
+
this.name = "ForkSessionTimeoutError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class ForkSessionError extends Error {
|
|
21
|
+
constructor(title, detail) {
|
|
22
|
+
super(`${title}: ${detail}`);
|
|
23
|
+
this.name = "ForkSessionError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function describeError(error) {
|
|
27
|
+
if (error && typeof error === "object") {
|
|
28
|
+
const named = error;
|
|
29
|
+
const kind = typeof named.type === "string" ? named.type : named.name;
|
|
30
|
+
if (typeof named.message === "string")
|
|
31
|
+
return typeof kind === "string" ? `${kind}: ${named.message}` : named.message;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.stringify(error);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return String(error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return String(error);
|
|
40
|
+
}
|
|
41
|
+
// The V2 session API takes no AbortSignal; withDeadline only bounds the wait.
|
|
42
|
+
function deadline(what, timeoutMs, call) {
|
|
43
|
+
return withDeadline(what, timeoutMs, () => call());
|
|
44
|
+
}
|
|
45
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
|
+
// The fork is settled once an assistant message answers the prompt and it is complete.
|
|
47
|
+
export function forkOutcome(messages, promptID) {
|
|
48
|
+
const promptIndex = promptID ? messages.findIndex((message) => message.id === promptID) : -1;
|
|
49
|
+
const answers = messages.slice(promptIndex + 1).filter((message) => message.type === "assistant");
|
|
50
|
+
const last = answers[answers.length - 1];
|
|
51
|
+
if (!last)
|
|
52
|
+
return { done: false };
|
|
53
|
+
if (last.error !== undefined && last.error !== null)
|
|
54
|
+
return { done: true, failure: describeError(last.error) };
|
|
55
|
+
if (last.time?.completed === undefined)
|
|
56
|
+
return { done: false };
|
|
57
|
+
if (last.finish === "error")
|
|
58
|
+
return { done: true, failure: "model finished with an error" };
|
|
59
|
+
return { done: true };
|
|
60
|
+
}
|
|
61
|
+
// Security: forks run on raw, potentially untrusted transcript content. The agent's ruleset and the
|
|
62
|
+
// explicit session ruleset restrict them to the memory tools; the deadlines guarantee the caller gets
|
|
63
|
+
// control back even when the fork hangs on a permission prompt or the server stops answering.
|
|
64
|
+
export async function runV2Fork(session, input) {
|
|
65
|
+
const createTimeoutMs = input.createTimeoutMs ?? FORK_CREATE_TIMEOUT_MS;
|
|
66
|
+
const cleanupTimeoutMs = input.cleanupTimeoutMs ?? FORK_CLEANUP_TIMEOUT_MS;
|
|
67
|
+
const pollMs = input.pollMs ?? FORK_SETTLE_POLL_MS;
|
|
68
|
+
const created = await deadline(`${input.title} session.create`, createTimeoutMs, () => session.create({
|
|
69
|
+
...(input.parentSessionID ? { parentID: input.parentSessionID } : {}),
|
|
70
|
+
title: input.title,
|
|
71
|
+
agent: input.agent,
|
|
72
|
+
...(input.model ? { model: input.model } : {}),
|
|
73
|
+
permissions: input.permissions,
|
|
74
|
+
}));
|
|
75
|
+
const forkID = created?.id;
|
|
76
|
+
if (typeof forkID !== "string")
|
|
77
|
+
throw new ForkSessionError(input.title, "session.create returned no session id");
|
|
78
|
+
input.onCreated?.(forkID);
|
|
79
|
+
const cleanup = async (stage) => {
|
|
80
|
+
try {
|
|
81
|
+
await deadline(`${input.title} session.${stage}`, cleanupTimeoutMs, () => stage === "abort" ? session.interrupt({ sessionID: forkID }) : session.remove({ sessionID: forkID }));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
input.onCleanupFailed?.(forkID, stage, error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
let timedOut = false;
|
|
88
|
+
try {
|
|
89
|
+
const started = Date.now();
|
|
90
|
+
const remaining = () => input.timeoutMs - (Date.now() - started);
|
|
91
|
+
const guarded = async (what, call, cap) => {
|
|
92
|
+
const left = remaining();
|
|
93
|
+
if (left <= 0) {
|
|
94
|
+
timedOut = true;
|
|
95
|
+
throw new ForkSessionTimeoutError(input.title, input.timeoutMs);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await deadline(`${input.title} ${what}`, Math.min(left, cap ?? left), call);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error instanceof TimeoutError && remaining() <= 0) {
|
|
102
|
+
timedOut = true;
|
|
103
|
+
throw new ForkSessionTimeoutError(input.title, input.timeoutMs);
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const queued = await guarded("session.prompt", () => session.prompt({ sessionID: forkID, text: input.text }));
|
|
109
|
+
const promptID = queued && typeof queued === "object" && typeof queued.id === "string"
|
|
110
|
+
? queued.id
|
|
111
|
+
: undefined;
|
|
112
|
+
for (;;) {
|
|
113
|
+
await guarded("session.wait", () => session.wait({ sessionID: forkID }));
|
|
114
|
+
const messages = await guarded("session.context", () => session.context({ sessionID: forkID }), FORK_READ_TIMEOUT_MS);
|
|
115
|
+
const outcome = forkOutcome(messages, promptID);
|
|
116
|
+
if (outcome.done) {
|
|
117
|
+
if (outcome.failure)
|
|
118
|
+
throw new ForkSessionError(input.title, outcome.failure);
|
|
119
|
+
return messages;
|
|
120
|
+
}
|
|
121
|
+
// `wait` returned before the prompt was admitted or the answer completed.
|
|
122
|
+
await guarded("settle", () => sleep(pollMs));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
try {
|
|
127
|
+
// On timeout the server is still running the fork: stop it before removing the session.
|
|
128
|
+
if (timedOut)
|
|
129
|
+
await cleanup("abort");
|
|
130
|
+
await cleanup("delete");
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
input.onFinished?.(forkID);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type GenerateInput, type MemoryHost } from "../types.js";
|
|
2
|
+
import { type ModelRef, type PermissionRule, type V2SessionApi } from "./fork.js";
|
|
3
|
+
export type V2AgentInfo = {
|
|
4
|
+
model?: ModelRef;
|
|
5
|
+
permissions?: readonly PermissionRule[];
|
|
6
|
+
};
|
|
7
|
+
export type V2HostOptions = {
|
|
8
|
+
session: V2SessionApi;
|
|
9
|
+
generateText: (input: {
|
|
10
|
+
prompt: string;
|
|
11
|
+
model?: ModelRef;
|
|
12
|
+
}) => Promise<{
|
|
13
|
+
text: string;
|
|
14
|
+
}>;
|
|
15
|
+
getAgent: (name: string) => Promise<V2AgentInfo | undefined>;
|
|
16
|
+
sandboxFor: (name: string) => PermissionRule[];
|
|
17
|
+
};
|
|
18
|
+
export declare function buildGeneratePrompt(input: Pick<GenerateInput, "system" | "text" | "schema">): string;
|
|
19
|
+
export declare class V2Host implements MemoryHost {
|
|
20
|
+
private readonly options;
|
|
21
|
+
private readonly forkSystems;
|
|
22
|
+
constructor(options: V2HostOptions);
|
|
23
|
+
systemFor(sessionID: string): string | undefined;
|
|
24
|
+
readTranscript(sessionID: string, afterMessageID?: string): Promise<{
|
|
25
|
+
messages: import("../types.js").TranscriptMessage[];
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
listSessions(): Promise<undefined>;
|
|
29
|
+
runFork(input: Parameters<MemoryHost["runFork"]>[0]): Promise<void>;
|
|
30
|
+
generate(input: GenerateInput): Promise<string>;
|
|
31
|
+
private agent;
|
|
32
|
+
private fork;
|
|
33
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// MemoryHost over the V2 plugin context (`setup` entry).
|
|
2
|
+
//
|
|
3
|
+
// Differences from V1 that the coordinators see through the interface:
|
|
4
|
+
// - readTranscript only has the messages after the last compaction (`session.context`), so a
|
|
5
|
+
// watermark message that was compacted away yields `truncated: true`;
|
|
6
|
+
// - listSessions is unavailable (the plugin API cannot list sessions);
|
|
7
|
+
// - generate is a one-shot `generate.text` call: no system prompt, no structured output, so both
|
|
8
|
+
// are folded into the prompt and the caller parses the text.
|
|
9
|
+
import { withDeadline } from "../../util/timeout.js";
|
|
10
|
+
import { SDK_READ_TIMEOUT_MS } from "../types.js";
|
|
11
|
+
import { runV2Fork } from "./fork.js";
|
|
12
|
+
import { toTranscript } from "./transcript.js";
|
|
13
|
+
// The V2 plugin calls take no AbortSignal; withDeadline only bounds the wait.
|
|
14
|
+
function bounded(what, timeoutMs, call) {
|
|
15
|
+
return withDeadline(what, timeoutMs, () => call());
|
|
16
|
+
}
|
|
17
|
+
export function buildGeneratePrompt(input) {
|
|
18
|
+
const sections = [input.system?.trim(), input.text];
|
|
19
|
+
if (input.schema) {
|
|
20
|
+
sections.push(`Respond with only a JSON object matching this JSON schema, without code fences or commentary:\n${JSON.stringify(input.schema)}`);
|
|
21
|
+
}
|
|
22
|
+
return sections.filter((section) => Boolean(section)).join("\n\n");
|
|
23
|
+
}
|
|
24
|
+
export class V2Host {
|
|
25
|
+
options;
|
|
26
|
+
// Per-fork system prompt, injected by the context hook (V2 prompts carry no system field).
|
|
27
|
+
forkSystems = new Map();
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
}
|
|
31
|
+
systemFor(sessionID) {
|
|
32
|
+
return this.forkSystems.get(sessionID);
|
|
33
|
+
}
|
|
34
|
+
async readTranscript(sessionID, afterMessageID) {
|
|
35
|
+
const messages = await bounded("session.context", SDK_READ_TIMEOUT_MS, () => this.options.session.context({ sessionID }));
|
|
36
|
+
const truncated = afterMessageID !== undefined && !messages.some((message) => message.id === afterMessageID);
|
|
37
|
+
return { messages: toTranscript(messages), truncated };
|
|
38
|
+
}
|
|
39
|
+
async listSessions() {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
async runFork(input) {
|
|
43
|
+
await this.fork(input);
|
|
44
|
+
}
|
|
45
|
+
// One deadline for the agent lookup and the model call together, so the selector never outlives
|
|
46
|
+
// the caller's timeout (recall.timeoutMs).
|
|
47
|
+
async generate(input) {
|
|
48
|
+
const result = await bounded("generate.text", input.timeoutMs, async () => {
|
|
49
|
+
const agent = await this.agent(input.agent);
|
|
50
|
+
return this.options.generateText({
|
|
51
|
+
prompt: buildGeneratePrompt(input),
|
|
52
|
+
...(agent?.model ? { model: agent.model } : {}),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return typeof result?.text === "string" ? result.text : "";
|
|
56
|
+
}
|
|
57
|
+
async agent(name) {
|
|
58
|
+
try {
|
|
59
|
+
return await bounded(`agent.get ${name}`, SDK_READ_TIMEOUT_MS, () => this.options.getAgent(name));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async fork(input) {
|
|
66
|
+
// The resolved agent's ruleset (sandbox + the user's overrides) is also passed as the session
|
|
67
|
+
// ruleset; without one the child would inherit its parent's session permissions.
|
|
68
|
+
const agent = await this.agent(input.agent);
|
|
69
|
+
const permissions = agent?.permissions?.length
|
|
70
|
+
? agent.permissions.map((rule) => ({ ...rule }))
|
|
71
|
+
: this.options.sandboxFor(input.agent);
|
|
72
|
+
return runV2Fork(this.options.session, {
|
|
73
|
+
...input,
|
|
74
|
+
permissions,
|
|
75
|
+
...(agent?.model ? { model: agent.model } : {}),
|
|
76
|
+
onCreated: (id) => {
|
|
77
|
+
if (input.system)
|
|
78
|
+
this.forkSystems.set(id, input.system);
|
|
79
|
+
input.onCreated?.(id);
|
|
80
|
+
},
|
|
81
|
+
onFinished: (id) => {
|
|
82
|
+
this.forkSystems.delete(id);
|
|
83
|
+
input.onFinished?.(id);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// V2 gives plugins no log API (ctx.app is name/version/channel only) and runs them inside the server
|
|
2
|
+
// process, whose stdout/stderr the background service discards or the standalone TUI shares. Log
|
|
3
|
+
// lines therefore go to a file in the plugin state directory, next to extraction-state.json.
|
|
4
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
export const LOG_FILE = "opencode-memory.log";
|
|
7
|
+
// Rotated once to `<file>.1` past this size, so a long-running server never grows it unbounded.
|
|
8
|
+
export const LOG_MAX_BYTES = 1_000_000;
|
|
9
|
+
function formatLine(level, message, extra) {
|
|
10
|
+
let suffix = "";
|
|
11
|
+
if (extra && Object.keys(extra).length > 0) {
|
|
12
|
+
try {
|
|
13
|
+
suffix = ` ${JSON.stringify(extra)}`;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
suffix = " [unserializable extra]";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return `${new Date().toISOString()} ${level.toUpperCase()} ${message}${suffix}\n`;
|
|
20
|
+
}
|
|
21
|
+
// Best-effort and synchronous: a failing write is dropped, never thrown into a hook.
|
|
22
|
+
export function createFileLogger(stateDir, maxBytes = LOG_MAX_BYTES) {
|
|
23
|
+
const file = join(stateDir, LOG_FILE);
|
|
24
|
+
return (level, message, extra) => {
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(stateDir, { recursive: true });
|
|
27
|
+
try {
|
|
28
|
+
if (statSync(file).size > maxBytes)
|
|
29
|
+
renameSync(file, `${file}.1`);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// no log file yet
|
|
33
|
+
}
|
|
34
|
+
appendFileSync(file, formatLine(level, message, extra), "utf-8");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// best-effort
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { type MemoryToolSpec } from "../../tools.js";
|
|
4
|
+
export type V2Context = Plugin.Context;
|
|
5
|
+
export type V2Cleanup = Plugin.Cleanup;
|
|
6
|
+
export declare const SETUP_CALL_TIMEOUT_MS = 30000;
|
|
7
|
+
export declare const CLEANUP_CALL_TIMEOUT_MS = 5000;
|
|
8
|
+
export declare function toV2Tool(spec: MemoryToolSpec): {
|
|
9
|
+
name: "memory_save" | "memory_delete" | "memory_list" | "memory_search" | "memory_read";
|
|
10
|
+
description: string;
|
|
11
|
+
input: z.ZodObject<{
|
|
12
|
+
[x: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
options: {
|
|
15
|
+
codemode: boolean;
|
|
16
|
+
};
|
|
17
|
+
execute(input: unknown, context: {
|
|
18
|
+
sessionID: string;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
metadata?: {
|
|
21
|
+
title: string;
|
|
22
|
+
} | undefined;
|
|
23
|
+
content: string;
|
|
24
|
+
}>;
|
|
25
|
+
};
|
|
26
|
+
export declare const createV2Setup: (env?: NodeJS.ProcessEnv, homeDir?: string) => (ctx: V2Context) => Promise<V2Cleanup>;
|