Skip to content

Commit e27f636

Browse files
committed
Merge remote-tracking branch 'origin/main'
# Conflicts: # MERGE.md # PLAN.md # bun.lock # packages/codemode/package.json # packages/core/package.json # packages/core/src/bus.ts # packages/core/src/location-services.ts # packages/core/src/memory/dream.ts # packages/core/src/plugin.ts # packages/core/src/plugin/host.ts # packages/core/src/session.ts # packages/core/src/session/compaction.ts # packages/core/src/session/hooks.ts # packages/core/src/session/runner/llm.ts # packages/core/src/session/runner/model.ts # packages/core/src/subagent/runner.ts # packages/core/src/tool/agent.ts # packages/core/src/tool/registry.ts # packages/core/src/tool/tool.ts # packages/core/test/plugin.test.ts # packages/core/test/plugin/fixture.ts # packages/core/test/plugin/host.ts # packages/core/test/session-runner-model.test.ts # packages/core/test/session-runner.test.ts # packages/effect-drizzle-sqlite/package.json # packages/http-recorder/package.json # packages/llm/package.json # packages/opencode/package.json # packages/opencode/src/mcp/catalog.ts # packages/opencode/src/plugin/index.ts # packages/opencode/src/provider/transform.ts # packages/opencode/src/server/routes/instance/httpapi/server.ts # packages/opencode/test/provider/transform.test.ts # packages/opencode/test/session/llm.test.ts # packages/plugin/package.json # packages/plugin/src/v2/effect/context.ts # packages/plugin/src/v2/effect/index.ts # packages/protocol/src/groups/session.ts # packages/schema/src/session-event.ts # packages/sdk/js/package.json # packages/sdk/js/src/v2/gen/sdk.gen.ts # packages/sdk/js/src/v2/gen/types.gen.ts # packages/server/package.json # packages/server/src/handlers/session.ts # packages/server/src/routes.ts # packages/tui/package.json # packages/tui/src/app.tsx # packages/tui/src/component/dialog-session-list.tsx # packages/tui/src/component/dialog-session-rename.tsx # packages/tui/src/component/prompt/index.tsx # packages/tui/src/context/data.tsx # packages/tui/src/routes/session/subagent-footer.tsx # packages/tui/src/util/usage.ts # patches/@modelcontextprotocol%2Fsdk@1.29.0.patch # script/merge-clean.ts
2 parents fe2e6f7 + 327fd98 commit e27f636

55 files changed

Lines changed: 2698 additions & 671 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 189 additions & 270 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"solid-list": "0.3.0",
6464
"vite": "7.1.4",
6565
"solid-js": "1.9.10",
66+
"solid-sonner": "0.3.1",
6667
"vite-plugin-solid": "2.11.10",
6768
"@lydell/node-pty": "1.2.0-beta.12"
6869
}

packages/codemode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
33
"name": "@opencode-ai/codemode",
4-
"version": "1.18.9",
4+
"version": "1.18.10",
55
"description": "Effect-native confined code execution over schema-described tools",
66
"private": true,
77
"type": "module",

packages/core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
3-
"version": "1.18.9",
3+
"version": "1.18.10",
44
"name": "@opencode-ai/core",
55
"type": "module",
66
"license": "MIT",
@@ -106,7 +106,7 @@
106106
"drizzle-orm": "catalog:",
107107
"effect": "catalog:",
108108
"fuzzysort": "3.1.0",
109-
"gitlab-ai-provider": "6.12.0",
109+
"gitlab-ai-provider": "6.12.1",
110110
"glob": "13.0.5",
111111
"google-auth-library": "10.5.0",
112112
"gray-matter": "4.0.3",

packages/core/src/location-services.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,17 @@ import { ReferenceGuidance } from "./reference/guidance"
2727
import * as SessionRunnerLLM from "./session/runner/llm"
2828
import { SessionRunnerModel } from "./session/runner/model"
2929
import { SessionTodo } from "./session/todo"
30-
import { SessionToolPermissions } from "./session/tool-permissions"
3130
import { SkillV2 } from "./skill"
3231
import { SkillGuidance } from "./skill/guidance"
3332
import { Snapshot } from "./snapshot"
3433
import { SubagentRunner } from "./subagent/runner"
3534
import { SystemContextBuiltIns } from "./system-context/builtins"
3635
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"
39+
import { SessionToolPermissions } from "./session/tool-permissions"
3740
import { BuiltInTools } from "./tool/builtins"
38-
import { MCP } from "./tool/mcp"
3941
import { ReadToolFileSystem } from "./tool/read-filesystem"
4042
import { ToolRegistry } from "./tool/registry"
4143
import { ToolOutputStore } from "./tool-output-store"
@@ -63,21 +65,23 @@ export const locationServices = LayerNode.group([
6365
SkillV2.node,
6466
SystemContextRegistry.node,
6567
SystemContextBuiltIns.node,
68+
MemoryContext.node,
6669
LocationMutation.node,
6770
FileMutation.node,
6871
PermissionV2.node,
6972
ToolOutputStore.node,
7073
ToolRegistry.node,
7174
ToolRegistry.toolsNode,
75+
McpToolSource.node,
76+
McpRegistration.node,
77+
SessionToolPermissions.node,
7278
Image.node,
7379
SkillGuidance.node,
7480
ReferenceGuidance.node,
7581
SessionTodo.node,
76-
SessionToolPermissions.node,
7782
QuestionV2.node,
7883
ReadToolFileSystem.node,
7984
BuiltInTools.node,
80-
MCP.toolNode,
8185
SessionRunnerModel.node,
8286
Snapshot.node,
8387
SubagentRunner.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)