feat(core): make path-local instruction discovery durable

Replace synthetic-message instruction injection with a durable
discovery projection so discovered AGENTS.md files survive compaction,
forks, reverts, and restarts.

- Add the instruction_file table and the durable
  session.instructions.discovered event; projection stores each
  discovered path and content with its owning assistant-message
  boundary and durable discovery order.
- Fold discovered files into the core/instructions source through
  InstructionDiscovery, absorbing SessionInstructions. Completed
  compaction rebaselines restate discovered instructions instead of
  summarizing them away.
- Re-read discovered files live at each observation so mid-session
  edits reach the model; the frozen discovery content stands in only
  when a file becomes unreadable.
- Narrate instruction file changes as per-file deltas via diffByKey,
  restating the full set only for pure reorderings.
- Fork copies discoveries within the inherited transcript, committed
  revert removes discoveries past the revert boundary, and Session
  movement clears them so the destination initializes a complete
  baseline.
This commit is contained in:
Kit Langton 2026-07-06 14:22:53 -04:00
commit 0726f09b05
28 changed files with 1166 additions and 598 deletions

View file

@ -20,7 +20,8 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
@ -33,7 +34,7 @@ const readToolNode = makeLocationNode({
LocationMutation.node,
Image.node,
PermissionV2.node,
SessionInstructions.node,
InstructionDiscovery.node,
FSUtil.node,
Location.node,
],
@ -47,6 +48,8 @@ const readCalls: {
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
const discoveredInstructions: string[][] = []
let instructionPaths: string[] = []
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = {
@ -108,6 +111,7 @@ const testFileSystem = Layer.effect(
}),
)
: Effect.succeed(path),
up: () => Effect.succeed(instructionPaths),
}),
),
),
@ -146,6 +150,10 @@ const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const instructionDiscovery = Layer.mock(InstructionDiscovery.Service, {
load: (_sessionID) => Effect.succeed(Instructions.empty),
discover: (input) => Effect.sync(() => void discoveredInstructions.push([...input.paths])),
})
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, readToolNode]), [
[ReadToolFileSystem.node, reader],
@ -155,6 +163,7 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
[LocationMutation.node, mutation],
[FSUtil.node, testFileSystem],
[Location.node, locationLayer],
[InstructionDiscovery.node, instructionDiscovery],
[Global.node, Global.layerWith({ data: Global.Path.data })],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
@ -167,6 +176,8 @@ describe("ReadTool", () => {
assertions.length = 0
readCalls.length = 0
listCalls.length = 0
discoveredInstructions.length = 0
instructionPaths = []
allow = true
resolvedType = "file"
resolveFailure = undefined
@ -678,6 +689,7 @@ describe("ReadTool", () => {
mime: "application/octet-stream",
}
const registry = yield* ToolRegistry.Service
instructionPaths = [path.join(process.cwd(), "sub", "AGENTS.md")]
expect(
yield* executeTool(registry, {
@ -686,6 +698,7 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
}),
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
expect(discoveredInstructions).toEqual([])
}),
)
})