refactor: keep runtime context in memory

This commit is contained in:
2026-06-07 17:07:14 +08:00
parent 1ed7e56f35
commit 9d4e5486e9
11 changed files with 273 additions and 269 deletions
+53 -45
View File
@@ -1,13 +1,12 @@
import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js";
import { SessionRuntimeContextStore } from "../../src/sessions/runtimeContextStore.js";
import {
getRuntimeSessionContext,
setRuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
const memoryStore = new MemoryStore();
const toolContextStore = new SessionRuntimeContextStore();
const initializePromise = Promise.all([
memoryStore.initialize(),
toolContextStore.initialize(),
]);
const initializePromise = memoryStore.initialize();
export default tool({
description:
@@ -37,7 +36,7 @@ export default tool({
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
const sessionContext = getRuntimeSessionContext(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
@@ -57,10 +56,10 @@ export default tool({
}
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "memory writes are disabled for this session",
ok: true,
kind: "memory",
decision: "rejected",
detail: "memory writes are disabled for this session",
});
}
@@ -71,17 +70,17 @@ export default tool({
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
};
await toolContextStore.write({
setRuntimeSessionContext({
...sessionContext,
memoryListReadScopes: readScopes,
});
return JSON.stringify({
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items: await memoryStore.list(scope, scopeKey),
target: scope,
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items: await memoryStore.list(scope, scopeKey),
target: scope,
});
}
@@ -96,46 +95,55 @@ export default tool({
});
}
const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
});
if (!result.entry) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "content rejected by persistence policy",
});
}
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "content rejected by persistence policy",
});
}
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "deduped",
detail: result.detail,
entry: result.entry,
target: scope,
decision: result.changed ? "accepted" : "deduped",
detail: result.detail,
entry: result.entry,
target: scope,
});
}
if (args.action === "replace") {
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
});
const result = await memoryStore.replace(
scope,
scopeKey,
args.target_id ?? "",
{
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
},
);
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
}
const result = await memoryStore.remove(scope, scopeKey, args.target_id ?? "");
const result = await memoryStore.remove(
scope,
scopeKey,
args.target_id ?? "",
);
return JSON.stringify({
ok: true,
kind: "memory",
+136 -105
View File
@@ -1,123 +1,154 @@
import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js";
import { SessionRuntimeContextStore } from "../../src/sessions/runtimeContextStore.js";
import {
getRuntimeSessionContext,
type RuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
type ToolContextReader = {
read(sessionId: string): RuntimeSessionContext | null;
};
const runtimeContextReader: ToolContextReader = {
read: getRuntimeSessionContext,
};
export const createSkillManagerTool = (
skillStore = new SkillStore(),
toolContextStore = new SessionRuntimeContextStore(),
initializePromise: Promise<unknown> = toolContextStore.initialize(),
) => tool({
description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe(
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
),
pattern: tool.schema
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
content: tool.schema
.string()
.optional()
.describe("Content used by write_skill, write_reference, or write_script."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "skill writes are disabled for this session",
});
}
if (args.action === "list") {
const result = await skillStore.list(args.skill_path);
if (!result) {
toolContextStore: ToolContextReader = runtimeContextReader,
initializePromise: Promise<unknown> = Promise.resolve(),
) =>
tool({
description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe(
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
),
pattern: tool.schema
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe(
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
),
content: tool.schema
.string()
.optional()
.describe(
"Content used by write_skill, write_reference, or write_script.",
),
},
async execute(args, context) {
await initializePromise;
const sessionContext = toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
if (
sessionContext.allowLearningWrite === false &&
args.action !== "list"
) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
detail: "skill writes are disabled for this session",
});
}
if (args.action === "list") {
const result = await skillStore.list(args.skill_path);
if (!result) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
});
}
return JSON.stringify({
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
...result,
});
}
const result =
args.action === "write_skill"
? await skillStore.writeSkill(args.skill_path, args.content ?? "")
: args.action === "remove_skill"
? await skillStore.removeSkill(args.skill_path)
: args.action === "append_pattern"
? await skillStore.appendPattern(
args.skill_path,
args.pattern ?? "",
)
: args.action === "remove_pattern"
? await skillStore.removePattern(
args.skill_path,
args.target_id ?? "",
)
: args.action === "write_reference"
? await skillStore.writeReference(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: args.action === "remove_reference"
? await skillStore.removeReference(
args.skill_path,
args.file_path ?? "",
)
: args.action === "write_script"
? await skillStore.writeScript(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: await skillStore.removeScript(
args.skill_path,
args.file_path ?? "",
);
return JSON.stringify({
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
...result,
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
});
}
const result =
args.action === "write_skill"
? await skillStore.writeSkill(args.skill_path, args.content ?? "")
: args.action === "remove_skill"
? await skillStore.removeSkill(args.skill_path)
: args.action === "append_pattern"
? await skillStore.appendPattern(args.skill_path, args.pattern ?? "")
: args.action === "remove_pattern"
? await skillStore.removePattern(args.skill_path, args.target_id ?? "")
: args.action === "write_reference"
? await skillStore.writeReference(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: args.action === "remove_reference"
? await skillStore.removeReference(args.skill_path, args.file_path ?? "")
: args.action === "write_script"
? await skillStore.writeScript(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: await skillStore.removeScript(args.skill_path, args.file_path ?? "");
return JSON.stringify({
ok: true,
kind: "skill",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
});
},
});
},
});
export default createSkillManagerTool();
+6 -6
View File
@@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SessionRuntimeContextStore } from "../sessions/runtimeContextStore.js";
import {
removeRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = {
@@ -26,7 +29,6 @@ export type ChatRequestContext = SessionContext & {
export class ChatSessionBridge {
private readonly abortControllers = new Map<string, AbortController>();
private readonly sessionRuntimeContextStore = new SessionRuntimeContextStore();
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
@@ -61,7 +63,7 @@ export class ChatSessionBridge {
sessionId,
startedAt: Date.now(),
};
await this.sessionRuntimeContextStore.write({
setRuntimeSessionContext({
accessToken: requestContext.accessToken,
actorKey: requestContext.actorKey,
allowLearningWrite: true,
@@ -133,9 +135,7 @@ export class ChatSessionBridge {
"failed while waiting for runtime session to become idle",
);
});
await this.sessionRuntimeContextStore.remove(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "failed to cleanup runtime tool context");
});
removeRuntimeSessionContext(sessionId);
}
private buildRequestContext(context: {
-2
View File
@@ -47,8 +47,6 @@ const envSchema = z
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
// client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 提供给本地 opencode tools 读取的会话上下文目录。
SESSION_RUNTIME_CONTEXT_STORAGE_DIR: z.string().default("./data/session-runtime-contexts"),
// tjwater-cli 可执行文件路径。
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
// TJWater 后端 API 的基础地址。
+9 -10
View File
@@ -9,7 +9,10 @@ import { SessionLearningStateStore } from "./sessionStateStore.js";
import { MemoryStore, type MemoryScope } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SkillStore } from "../skills/store.js";
import { SessionRuntimeContextStore } from "../sessions/runtimeContextStore.js";
import {
removeRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import {
sanitizePersistentDocument,
sanitizePersistentLine,
@@ -76,7 +79,6 @@ export class LearningOrchestrator {
private readonly activeReviews = new Set<string>();
private readonly sessionLearningStateStore = new SessionLearningStateStore();
private readonly skillStore = new SkillStore();
private readonly sessionRuntimeContextStore = new SessionRuntimeContextStore();
constructor(
private readonly runtime: OpencodeRuntimeAdapter,
@@ -85,10 +87,7 @@ export class LearningOrchestrator {
) {}
async initialize() {
await Promise.all([
this.sessionLearningStateStore.initialize(),
this.sessionRuntimeContextStore.initialize(),
]);
await this.sessionLearningStateStore.initialize();
}
async onTurnCompleted(input: TurnReviewInput) {
@@ -147,7 +146,7 @@ export class LearningOrchestrator {
`learning-gate-${input.requestContext.clientSessionId}`,
);
gateSessionId = gateSession.id;
await this.sessionRuntimeContextStore.write({
setRuntimeSessionContext({
actorKey: input.requestContext.actorKey,
allowLearningWrite: false,
clientSessionId: `gate-${input.requestContext.clientSessionId}`,
@@ -215,7 +214,7 @@ export class LearningOrchestrator {
});
} finally {
if (gateSessionId) {
await this.sessionRuntimeContextStore.remove(gateSessionId).catch(() => undefined);
removeRuntimeSessionContext(gateSessionId);
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
}
}
@@ -235,7 +234,7 @@ export class LearningOrchestrator {
const reviewSession = await this.runtime.createSession(
`learning-review-${input.requestContext.clientSessionId}`,
);
await this.sessionRuntimeContextStore.write({
setRuntimeSessionContext({
actorKey: input.requestContext.actorKey,
allowLearningWrite: false,
clientSessionId: `review-${input.requestContext.clientSessionId}`,
@@ -283,7 +282,7 @@ export class LearningOrchestrator {
traceId: input.requestContext.traceId,
});
} finally {
await this.sessionRuntimeContextStore.remove(reviewSession.id).catch(() => undefined);
removeRuntimeSessionContext(reviewSession.id);
await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
}
}
+27
View File
@@ -0,0 +1,27 @@
export type RuntimeSessionContext = {
accessToken?: string;
actorKey: string;
allowLearningWrite?: boolean;
clientSessionId: string;
learningMode?: "interactive" | "review";
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
projectId?: string;
projectKey: string;
sessionId: string;
traceId: string;
};
const contexts = new Map<string, RuntimeSessionContext>();
export const setRuntimeSessionContext = (context: RuntimeSessionContext) => {
contexts.set(context.sessionId, { ...context });
};
export const getRuntimeSessionContext = (sessionId: string) => {
const context = contexts.get(sessionId);
return context ? { ...context } : null;
};
export const removeRuntimeSessionContext = (sessionId: string) => {
contexts.delete(sessionId);
};
+4 -6
View File
@@ -18,7 +18,7 @@ import {
} from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js";
import { opencodeRuntime } from "./runtime/opencode.js";
import { SessionRuntimeContextStore } from "./sessions/runtimeContextStore.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
const app = express();
@@ -28,7 +28,6 @@ const sessionMetadataStore = new SessionMetadataStore();
const sessionUiStateStore = new SessionUiStateStore();
const memoryStore = new MemoryStore();
const sessionTranscriptStore = new SessionTranscriptStore();
const sessionRuntimeContextStore = new SessionRuntimeContextStore();
const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime,
memoryStore,
@@ -71,7 +70,7 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? await sessionRuntimeContextStore.read(sessionId) : null;
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
@@ -175,7 +174,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
const context = sessionId ? await sessionRuntimeContextStore.read(sessionId) : null;
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
@@ -225,7 +224,7 @@ app.post("/internal/tools/session-search", async (req, res) => {
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const query = typeof req.body?.query === "string" ? req.body.query : "";
const context = sessionId ? await sessionRuntimeContextStore.read(sessionId) : null;
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
@@ -273,7 +272,6 @@ const bootstrap = async () => {
memoryStore.initialize(),
resultReferenceStore.initialize(),
sessionTranscriptStore.initialize(),
sessionRuntimeContextStore.initialize(),
]);
resultReferenceStore.startCleanupLoop();
};
-46
View File
@@ -1,46 +0,0 @@
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
removeFileIfExists,
} from "../utils/fileStore.js";
export type SessionRuntimeContext = {
accessToken?: string;
actorKey: string;
allowLearningWrite?: boolean;
clientSessionId: string;
learningMode?: "interactive" | "review";
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
projectId?: string;
projectKey: string;
sessionId: string;
traceId: string;
};
export class SessionRuntimeContextStore {
constructor(private readonly baseDir = config.SESSION_RUNTIME_CONTEXT_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async write(context: SessionRuntimeContext) {
await atomicWriteJson(this.filePath(context.sessionId), context);
}
async read(sessionId: string) {
return await readJsonFile<SessionRuntimeContext>(this.filePath(sessionId));
}
async remove(sessionId: string) {
await removeFileIfExists(this.filePath(sessionId));
}
private filePath(sessionId: string) {
return join(this.baseDir, `${sessionId}.json`);
}
}
+6 -8
View File
@@ -4,13 +4,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
import { SessionRuntimeContextStore } from "../../src/sessions/runtimeContextStore.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { SkillStore } from "../../src/skills/store.js";
describe("skill_manager tool", () => {
let tempDir: string;
let skillStore: SkillStore;
let contextStore: SessionRuntimeContextStore;
let context: RuntimeSessionContext;
const toolContext = {
abort: new AbortController().signal,
@@ -39,16 +39,14 @@ describe("skill_manager tool", () => {
join(tempDir, "skills"),
join(tempDir, "backup", "skills"),
);
contextStore = new SessionRuntimeContextStore(join(tempDir, "contexts"));
await contextStore.initialize();
await contextStore.write({
context = {
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "client-session-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
});
};
});
afterEach(async () => {
@@ -58,7 +56,7 @@ describe("skill_manager tool", () => {
it("dispatches skill-level write, overwrite, and remove actions", async () => {
const tool = createSkillManagerTool(
skillStore,
contextStore,
{ read: () => context },
Promise.resolve(),
);
@@ -111,7 +109,7 @@ describe("skill_manager tool", () => {
it("writes the root skills index through the reserved alias", async () => {
const tool = createSkillManagerTool(
skillStore,
contextStore,
{ read: () => context },
Promise.resolve(),
);
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test";
import {
getRuntimeSessionContext,
removeRuntimeSessionContext,
setRuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
describe("runtime session context", () => {
it("stores authentication context in process memory", () => {
setRuntimeSessionContext({
accessToken: "token-1",
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "chat-session-1",
learningMode: "interactive",
projectId: "project-id-1",
projectKey: "project-1",
sessionId: "runtime-session-1",
traceId: "trace-1",
});
const runtimeContext = getRuntimeSessionContext("runtime-session-1");
expect(runtimeContext?.accessToken).toBe("token-1");
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
removeRuntimeSessionContext("runtime-session-1");
expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();
});
});
@@ -1,41 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionRuntimeContextStore } from "../../src/sessions/runtimeContextStore.js";
describe("SessionRuntimeContextStore", () => {
let tempDir: string;
let store: SessionRuntimeContextStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-tool-context-"));
store = new SessionRuntimeContextStore(tempDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("writes and reads runtime session context by opencode session id", async () => {
await store.write({
accessToken: "token-1",
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "chat-session-1",
learningMode: "interactive",
projectId: "project-id-1",
projectKey: "project-1",
sessionId: "runtime-session-1",
traceId: "trace-1",
});
const runtimeContext = await store.read("runtime-session-1");
expect(runtimeContext?.accessToken).toBe("token-1");
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
});
});