Skip to content

Commit 327fd98

Browse files
edyedy
authored andcommitted
feat(core): V2 session API 补齐 + TUI 迁移
- V2 core 新增 remove/update/children/fork(atMessageID)/todo 方法 - Protocol 新增 6 个 V2 endpoint (remove/update/fork/children/messages/todo) - Server 实现新增 endpoint handler - SDK 重新生成 - TUI 迁移: delete/update/abort/fork/compact/shell/create/get/revert/todo/list - sync.tsx session.get 通过 toV1Session adapter 迁移到 V2 - MCP 工具 V2 ToolRegistry 接入 - 缓存策略统一到 cache-policy.ts - Dream 记忆 LLM 接入 - Session Fork V2 核心实现 - TUI cursor/replay SSE 订阅
1 parent 748c611 commit 327fd98

27 files changed

Lines changed: 2025 additions & 108 deletions

File tree

packages/core/src/location-services.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import { Snapshot } from "./snapshot"
3333
import { SubagentRunner } from "./subagent/runner"
3434
import { SystemContextBuiltIns } from "./system-context/builtins"
3535
import { SystemContextRegistry } from "./system-context/registry"
36+
import { McpRegistration } from "./mcp/registration"
37+
import { McpToolSource } from "./mcp/tool-source"
38+
import { MemoryContext } from "./memory/context"
3639
import { BuiltInTools } from "./tool/builtins"
3740
import { ReadToolFileSystem } from "./tool/read-filesystem"
3841
import { ToolRegistry } from "./tool/registry"
@@ -61,12 +64,15 @@ export const locationServices = LayerNode.group([
6164
SkillV2.node,
6265
SystemContextRegistry.node,
6366
SystemContextBuiltIns.node,
67+
MemoryContext.node,
6468
LocationMutation.node,
6569
FileMutation.node,
6670
PermissionV2.node,
6771
ToolOutputStore.node,
6872
ToolRegistry.node,
6973
ToolRegistry.toolsNode,
74+
McpToolSource.node,
75+
McpRegistration.node,
7076
Image.node,
7177
SkillGuidance.node,
7278
ReferenceGuidance.node,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
export * as McpRegistration from "./registration"
2+
3+
import { ToolFailure } from "@opencode-ai/llm"
4+
import { Effect, Layer, Schema } from "effect"
5+
import { makeLocationNode } from "../effect/app-node"
6+
import { ToolRegistry } from "../tool/registry"
7+
import { Tool } from "../tool/tool"
8+
import { Tools } from "../tool/tools"
9+
import { McpToolSource } from "./tool-source"
10+
11+
const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")
12+
const toolName = (serverName: string, name: string) => sanitize(serverName) + "_" + sanitize(name)
13+
14+
const Output = Schema.Struct({
15+
content: Schema.Array(Schema.Unknown),
16+
isError: Schema.optional(Schema.Boolean),
17+
structuredContent: Schema.optional(Schema.Unknown),
18+
})
19+
20+
function toModelOutput(output: Schema.Schema.Type<typeof Output>): ReadonlyArray<Tool.Content> {
21+
const parts: Tool.Content[] = []
22+
for (const item of output.content) {
23+
if (typeof item !== "object" || item === null) continue
24+
const part = item as Record<string, unknown>
25+
if (part.type === "text" && typeof part.text === "string") {
26+
parts.push({ type: "text", text: part.text })
27+
} else if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") {
28+
parts.push({ type: "file", data: part.data, mime: part.mimeType })
29+
} else if (part.type === "resource" && typeof part.resource === "object" && part.resource !== null) {
30+
const resource = part.resource as Record<string, unknown>
31+
if (typeof resource.text === "string") parts.push({ type: "text", text: resource.text })
32+
if (typeof resource.blob === "string" && typeof resource.mimeType === "string") {
33+
parts.push({ type: "file", data: resource.blob, mime: resource.mimeType })
34+
}
35+
}
36+
}
37+
if (parts.length === 0 && output.structuredContent !== undefined) {
38+
parts.push({ type: "text", text: JSON.stringify(output.structuredContent) })
39+
}
40+
return parts
41+
}
42+
43+
const layer = Layer.effectDiscard(
44+
Effect.gen(function* () {
45+
const source = yield* McpToolSource.Service
46+
const tools = yield* Tools.Service
47+
const entries = yield* source.tools
48+
49+
if (entries.length === 0) return
50+
51+
const record: Record<string, Tool.AnyTool> = {}
52+
for (const entry of entries) {
53+
const name = toolName(entry.serverName, entry.def.name)
54+
record[name] = Tool.make({
55+
description: entry.def.description ?? `MCP tool ${entry.def.name} from ${entry.serverName}`,
56+
input: Schema.Record(Schema.String, Schema.Unknown),
57+
inputJsonSchema: {
58+
...entry.def.inputSchema,
59+
type: "object",
60+
properties: (entry.def.inputSchema.properties ?? {}) as Record<string, unknown>,
61+
additionalProperties: false,
62+
},
63+
output: Output,
64+
execute: (input) =>
65+
source.execute(entry.serverName, entry.def.name, input, { timeout: entry.timeout }).pipe(
66+
Effect.mapError(
67+
(error) => new ToolFailure({ message: error.message }),
68+
),
69+
Effect.flatMap((result) => {
70+
if (result.isError) {
71+
const text = result.content
72+
.filter((item): item is { type: "text"; text: string } => item.type === "text")
73+
.map((item) => item.text)
74+
.filter((text) => text.trim())
75+
.join("\n\n")
76+
return Effect.fail(new ToolFailure({ message: text || "MCP tool returned an error" }))
77+
}
78+
return Effect.succeed({
79+
content: result.content as Array<unknown>,
80+
isError: result.isError,
81+
structuredContent: result.structuredContent,
82+
})
83+
}),
84+
),
85+
toModelOutput: ({ output }) => toModelOutput(output),
86+
})
87+
}
88+
89+
yield* tools.register(record).pipe(Effect.orDie)
90+
}),
91+
)
92+
93+
export const node = makeLocationNode({
94+
name: "mcp-registration",
95+
layer,
96+
deps: [ToolRegistry.node, McpToolSource.node],
97+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
export * as McpToolSource from "./tool-source"
2+
3+
import { Context, Effect, Layer } from "effect"
4+
import { makeGlobalNode } from "../effect/app-node"
5+
6+
/** JSON Schema definition for an MCP tool's input. */
7+
export interface ToolDef {
8+
readonly name: string
9+
readonly description?: string
10+
readonly inputSchema: Record<string, unknown>
11+
}
12+
13+
/** A discovered MCP tool with its owning server. */
14+
export interface ToolEntry {
15+
readonly serverName: string
16+
readonly def: ToolDef
17+
readonly timeout?: number
18+
}
19+
20+
export type ContentPart =
21+
| { readonly type: "text"; readonly text: string }
22+
| { readonly type: "image"; readonly data: string; readonly mimeType: string }
23+
| {
24+
readonly type: "resource"
25+
readonly resource: {
26+
readonly uri: string
27+
readonly text?: string
28+
readonly blob?: string
29+
readonly mimeType?: string
30+
}
31+
}
32+
33+
export interface ToolResult {
34+
readonly content: ReadonlyArray<ContentPart>
35+
readonly isError?: boolean
36+
readonly structuredContent?: unknown
37+
}
38+
39+
export interface Interface {
40+
/** All currently connected MCP tools across servers. */
41+
readonly tools: Effect.Effect<ReadonlyArray<ToolEntry>>
42+
/** Execute a tool on a specific server. */
43+
readonly execute: (
44+
serverName: string,
45+
toolName: string,
46+
args: Record<string, unknown>,
47+
options?: { readonly signal?: AbortSignal; readonly timeout?: number },
48+
) => Effect.Effect<ToolResult, Error>
49+
}
50+
51+
/**
52+
* Abstract MCP tool source consumed by the V2 registration layer.
53+
* The actual MCP client implementation lives in the server package;
54+
* core only depends on this interface.
55+
*/
56+
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpToolSource") {}
57+
58+
/** Default no-op source: no MCP tools available. */
59+
export const noop: Interface = {
60+
tools: Effect.succeed([]),
61+
execute: (_serverName, toolName) =>
62+
Effect.fail(new Error(`MCP tool source not available (called ${toolName})`)),
63+
}
64+
65+
/**
66+
* Process-scoped node with a no-op default. The server package replaces this
67+
* via `buildLocationServiceMap(replacements)` with a real implementation
68+
* backed by the MCP client.
69+
*/
70+
export const node = makeGlobalNode({
71+
service: Service,
72+
layer: Layer.succeed(Service, noop),
73+
deps: [],
74+
})

packages/core/src/memory/dream.ts

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ export const shouldConsolidate = async (): Promise<boolean> => {
2121
}
2222

2323
const buildPrompt = (existing: ReadonlyArray<Memory>, summaries: ReadonlyArray<string>): string => {
24-
const existingText = existing.length === 0
25-
? "(no existing memories)"
26-
: existing.map((m) => `- [${m.category}] ${m.title}: ${m.content}`).join("\n")
24+
const existingText =
25+
existing.length === 0
26+
? "(no existing memories)"
27+
: existing.map((m) => `- [${m.category}] ${m.title}: ${m.content}`).join("\n")
2728

2829
const summariesText = summaries.join("\n---\n")
2930

@@ -47,37 +48,80 @@ Respond with a JSON array of memory objects:
4748
If no new memories should be extracted, respond with: []`
4849
}
4950

50-
export const consolidate = async (sessionSummaries: ReadonlyArray<string>): Promise<void> => {
51+
/** Extract JSON array from LLM response that may contain markdown fences. */
52+
const extractJson = (text: string): string => {
53+
const fenced = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/)
54+
if (fenced) return fenced[1].trim()
55+
const bracket = text.indexOf("[")
56+
const lastBracket = text.lastIndexOf("]")
57+
if (bracket >= 0 && lastBracket > bracket) return text.slice(bracket, lastBracket + 1)
58+
return text.trim()
59+
}
60+
61+
/**
62+
* Consolidate session summaries into long-term memories.
63+
* @param sessionSummaries - Compaction summaries from recent sessions.
64+
* @param generate - LLM text generation function injected by the caller.
65+
* Receives the consolidation prompt, returns the raw LLM response text.
66+
*/
67+
export const consolidate = async (
68+
sessionSummaries: ReadonlyArray<string>,
69+
generate: (prompt: string) => Promise<string>,
70+
): Promise<void> => {
5171
if (sessionSummaries.length === 0) return
5272

5373
const existing = await loadMemories()
54-
const _prompt = buildPrompt(existing, sessionSummaries)
74+
const prompt = buildPrompt(existing, sessionSummaries)
5575

5676
try {
57-
const response = await Bun.stdin.text() // placeholder - in real usage, call LLM
58-
const parsed = JSON.parse(response) as ReadonlyArray<{
77+
const response = await generate(prompt)
78+
const parsed = JSON.parse(extractJson(response)) as ReadonlyArray<{
5979
category: Memory["category"]
6080
title: string
6181
content: string
6282
keywords: ReadonlyArray<string>
6383
}>
6484

85+
if (!Array.isArray(parsed) || parsed.length === 0) {
86+
await writeLastDream(Date.now())
87+
return
88+
}
89+
6590
const now = new Date().toISOString()
66-
const newMemories: ReadonlyArray<Memory> = parsed.map((item) => ({
67-
id: crypto.randomUUID(),
68-
category: item.category,
69-
title: item.title,
70-
content: item.content,
71-
keywords: item.keywords,
72-
created_at: now,
73-
updated_at: now,
74-
}))
75-
76-
const merged = [...existing, ...newMemories].slice(-200)
77-
await saveMemories(merged)
91+
const newMemories: ReadonlyArray<Memory> = parsed
92+
.filter(
93+
(item): item is { category: Memory["category"]; title: string; content: string; keywords: string[] } =>
94+
typeof item.title === "string" &&
95+
typeof item.content === "string" &&
96+
["user", "feedback", "project", "reference"].includes(item.category),
97+
)
98+
.map((item) => ({
99+
id: crypto.randomUUID(),
100+
category: item.category,
101+
title: item.title,
102+
content: item.content,
103+
keywords: Array.isArray(item.keywords) ? item.keywords : [],
104+
created_at: now,
105+
updated_at: now,
106+
}))
107+
108+
// Merge: update existing memories by title match, append new ones
109+
const byTitle = new Map(existing.map((m) => [m.title.toLowerCase(), m]))
110+
const merged = [...existing]
111+
for (const mem of newMemories) {
112+
const existingMatch = byTitle.get(mem.title.toLowerCase())
113+
if (existingMatch) {
114+
const index = merged.indexOf(existingMatch)
115+
if (index >= 0) merged[index] = { ...mem, id: existingMatch.id, created_at: existingMatch.created_at }
116+
} else {
117+
merged.push(mem)
118+
}
119+
}
120+
121+
await saveMemories(merged.slice(-200))
78122
await writeLastDream(Date.now())
79123
} catch {
80-
// Silently fail - don't crash on consolidation errors
124+
// Silently fail — consolidation is best-effort background work
81125
}
82126
}
83127

0 commit comments

Comments
 (0)