113 lines
4.9 KiB
TypeScript
113 lines
4.9 KiB
TypeScript
export * as SessionInstructions from "./instructions"
|
|
|
|
import { relative } from "path"
|
|
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
|
|
import { makeLocationNode } from "../effect/app-node"
|
|
import { EventV2 } from "../event"
|
|
import { FSUtil } from "../fs-util"
|
|
import { Location } from "../location"
|
|
import { SessionEvent } from "./event"
|
|
import { MessageDecodeError } from "./error"
|
|
import { SessionMessage } from "./message"
|
|
import { SessionSchema } from "./schema"
|
|
import { SessionStore } from "./store"
|
|
|
|
const InjectedMetadata = Schema.Struct({
|
|
instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }),
|
|
})
|
|
|
|
export interface Interface {
|
|
readonly load: (input: {
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly paths: ReadonlyArray<string>
|
|
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionInstructions") {}
|
|
|
|
const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const events = yield* EventV2.Service
|
|
const fs = yield* FSUtil.Service
|
|
const store = yield* SessionStore.Service
|
|
const location = yield* Location.Service
|
|
// Resolved once for the Location layer; the synthetic text and dedup ledger keep
|
|
// absolute paths, but the human-facing description shows paths relative to the project
|
|
// root so opening a subdirectory still describes paths from the project root.
|
|
const root = yield* fs.resolve(location.project.directory)
|
|
// Same-step parallel reads settle concurrently, so an in-memory claim guards each
|
|
// Session/path pair before any filesystem work. The durable history check below covers
|
|
// paths injected in earlier steps after this Location layer was reopened.
|
|
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
|
|
|
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly paths: ReadonlyArray<string>
|
|
}) {
|
|
const claimed = yield* Ref.modify(injected, (map) => {
|
|
const existing = map.get(input.sessionID) ?? new Set<string>()
|
|
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
|
if (newlyClaimed.length === 0) return [newlyClaimed, map]
|
|
const next = new Map(map)
|
|
next.set(input.sessionID, new Set([...existing, ...newlyClaimed]))
|
|
return [newlyClaimed, next]
|
|
})
|
|
if (claimed.length === 0) return
|
|
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
|
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
|
if (toInject.length === 0) return
|
|
const files = yield* Effect.forEach(
|
|
toInject,
|
|
(path) =>
|
|
fs
|
|
.readFileStringSafe(path)
|
|
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
|
if (readable.length === 0) return
|
|
// Publish directly rather than through SessionV2.synthetic: a Location-scoped layer
|
|
// cannot depend on SessionV2 (it routes through LocationServiceMap, forming a type
|
|
// cycle with this node). The durable publish is what makes the synthetic visible on
|
|
// the next projected history reload. The dedup ledger lives on the synthetic message
|
|
// metadata so it survives across Location layer restarts.
|
|
yield* events.publish(SessionEvent.Synthetic, {
|
|
sessionID: input.sessionID,
|
|
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
|
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
|
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
|
})
|
|
})
|
|
|
|
return Service.of({ load })
|
|
}),
|
|
)
|
|
|
|
function previouslyInjected(store: SessionStore.Interface, sessionID: SessionSchema.ID) {
|
|
return Effect.gen(function* () {
|
|
const history = yield* store.context(sessionID)
|
|
return new Set(
|
|
history
|
|
.filter((message): message is SessionMessage.Synthetic => message.type === "synthetic")
|
|
.flatMap(
|
|
(message) =>
|
|
Option.getOrUndefined(Schema.decodeUnknownOption(InjectedMetadata)(message.metadata))?.instruction.paths ??
|
|
[],
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
// Paths are normally discovered under the project root, so the description shows them
|
|
// relative to it. A directly-loaded path outside the root falls back to its absolute form
|
|
// rather than emitting `../..` chains.
|
|
function describePath(root: string, path: string) {
|
|
return FSUtil.contains(root, path) ? relative(root, path) : path
|
|
}
|
|
|
|
export const node = makeLocationNode({
|
|
name: "session-instructions",
|
|
layer,
|
|
deps: [EventV2.node, FSUtil.node, Location.node, SessionStore.node],
|
|
})
|