chore: generate

This commit is contained in:
opencode-agent[bot] 2026-06-04 03:03:39 +00:00
commit b0a929440b
87 changed files with 2301 additions and 1599 deletions

View file

@ -19,7 +19,9 @@ export const Plugin = PluginV2.define({
const transform = yield* skill.transform()
const entries = yield* config.entries()
const items = entries.flatMap((entry) =>
entry.type === "document" ? (entry.info.skills ?? []) : [path.join(entry.path, "skill"), path.join(entry.path, "skills")],
entry.type === "document"
? (entry.info.skills ?? [])
: [path.join(entry.path, "skill"), path.join(entry.path, "skills")],
)
yield* transform((editor) => {

View file

@ -8,8 +8,12 @@ export default {
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_idx\`;`)
yield* tx.run(`CREATE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`)
yield* tx.run(
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -6,13 +6,20 @@ export default {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL DEFAULT 0;`)
yield* tx.run(`UPDATE \`session_message\` SET \`seq\` = COALESCE((SELECT \`seq\` + 1 FROM \`event\` WHERE \`event\`.\`id\` = \`session_message\`.\`id\`), 0);`)
const unmatched = yield* tx.get<{ count: number }>(`SELECT COUNT(*) AS \`count\` FROM \`session_message\` WHERE \`seq\` = 0;`)
if ((unmatched?.count ?? 0) > 0) return yield* Effect.die("Cannot migrate session_message projections without matching durable events")
yield* tx.run(
`UPDATE \`session_message\` SET \`seq\` = COALESCE((SELECT \`seq\` + 1 FROM \`event\` WHERE \`event\`.\`id\` = \`session_message\`.\`id\`), 0);`,
)
const unmatched = yield* tx.get<{ count: number }>(
`SELECT COUNT(*) AS \`count\` FROM \`session_message\` WHERE \`seq\` = 0;`,
)
if ((unmatched?.count ?? 0) > 0)
return yield* Effect.die("Cannot migrate session_message projections without matching durable events")
yield* tx.run(`UPDATE \`session_message\` SET \`seq\` = \`seq\` - 1;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -6,7 +6,9 @@ export default {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_seq_idx\`;`)
yield* tx.run(`CREATE INDEX IF NOT EXISTS \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`seq\`);`,
)

View file

@ -20,21 +20,23 @@ export interface KeyedMutex<in Key> {
export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
const withLock = (key: Key) => <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const current = locks.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
if (!current) locks.set(key, entry)
entry.users++
return entry.semaphore.withPermit(effect).pipe(
Effect.ensuring(
Effect.sync(() => {
entry.users--
if (entry.users === 0) locks.delete(key)
}),
),
)
})
const withLock =
(key: Key) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const current = locks.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
if (!current) locks.set(key, entry)
entry.users++
return entry.semaphore.withPermit(effect).pipe(
Effect.ensuring(
Effect.sync(() => {
entry.users--
if (entry.users === 0) locks.delete(key)
}),
),
)
})
return { size: Effect.sync(() => locks.size), withLock }
}

View file

@ -146,7 +146,10 @@ export interface Interface {
) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly aggregateEvents: (input: { readonly aggregateID: string; readonly after?: Cursor }) => Stream.Stream<CursorEvent>
readonly aggregateEvents: (input: {
readonly aggregateID: string
readonly after?: Cursor
}) => Stream.Stream<CursorEvent>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
@ -169,394 +172,434 @@ export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
}
export const layerWith = (options?: LayerOptions) => Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const commitGuards = new Array<CommitGuard>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service
export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const commitGuards = new Array<CommitGuard>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(synchronized.values(), (pubsubs) =>
Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true })
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
function commitSyncEvent(
event: Payload,
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(event.type) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
if (input && input.seq <= latest) return
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
if (input.strictOwner) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
for (const guard of commitGuards) {
yield* guard(event)
}
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
const encoded = syncRegistry.get(versionedType(definition.type, sync.version))!.encode(event.data)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq, ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}) },
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: encoded as Record<string, unknown>,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
}
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (durable) {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const committed = yield* commitSyncEvent(event as Payload)
if (committed) event = { ...event, seq: committed.seq }
}
for (const listener of listeners) {
yield* listener(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
})
}
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) {
return Effect.gen(function* () {
const definition = syncRegistry.get(event.type)
if (!definition) {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
} as Payload
const committed = yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID, strictOwner: options?.strictOwner })
if (committed && options?.publish) {
const published = { ...payload, seq: committed.seq }
for (const listener of listeners) {
yield* listener(published)
}
const pubsub = typed.get(payload.type)
if (pubsub) yield* PubSub.publish(pubsub, published)
yield* PubSub.publish(all, published)
}
}
})
}
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) {
return Effect.gen(function* () {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
return source
})
}
function remove(aggregateID: string) {
return db
.transaction(() =>
Effect.gen(function* () {
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
}
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
const definition = syncRegistry.get(event.type)
if (!definition) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
}
return {
cursor: Cursor.make(event.seq),
event: {
id: event.id,
type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
}
}
const readAfter = (aggregateID: string, after: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
.orderBy(asc(EventTable.seq))
.all(),
),
Effect.orDie,
Effect.map((rows) =>
rows.map((event) =>
decodeSerializedEvent({
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
),
),
)
const subscribeSynchronized = (aggregateID: string) =>
Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub)
yield* Effect.acquireRelease(
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set()
pubsubs.add(pubsub)
synchronized.set(aggregateID, pubsubs)
}),
() =>
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID)
pubsubs?.delete(pubsub)
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
)
return subscription
})
const streamEvents = (input: { readonly aggregateID: string; readonly after?: Cursor }): Stream.Stream<CursorEvent> =>
Stream.unwrap(
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID)
let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor
}),
),
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(
synchronized.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true },
)
const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
return Stream.concat(Stream.fromIterable(historical), live)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
function commitSyncEvent(
event: Payload,
input?: {
readonly seq: number
readonly aggregateID: string
readonly ownerID?: string
readonly strictOwner?: boolean
},
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(event.type) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
if (input && input.seq <= latest) return
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
if (input.strictOwner) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
for (const guard of commitGuards) {
yield* guard(event)
}
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: encoded as Record<string, unknown>,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
})
}
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (durable) {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const committed = yield* commitSyncEvent(event as Payload)
if (committed) event = { ...event, seq: committed.seq }
}
for (const listener of listeners) {
yield* listener(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
})
}
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
})
}
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
})
function replay(
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = syncRegistry.get(event.type)
if (!definition) {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
} as Payload
const committed = yield* commitSyncEvent(payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
const published = { ...payload, seq: committed.seq }
for (const listener of listeners) {
yield* listener(published)
}
const pubsub = typed.get(payload.type)
if (pubsub) yield* PubSub.publish(pubsub, published)
yield* PubSub.publish(all, published)
}
}
})
}
return Service.of({ publish, subscribe, all: streamAll, aggregateEvents: streamEvents, sync, listen, beforeCommit, project, replay, replayAll, remove, claim })
}),
)
function replayAll(
events: SerializedEvent[],
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
return source
})
}
function remove(aggregateID: string) {
return db
.transaction(() =>
Effect.gen(function* () {
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
}
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
const definition = syncRegistry.get(event.type)
if (!definition) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
}
return {
cursor: Cursor.make(event.seq),
event: {
id: event.id,
type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
}
}
const readAfter = (aggregateID: string, after: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
.orderBy(asc(EventTable.seq))
.all(),
),
Effect.orDie,
Effect.map((rows) =>
rows.map((event) =>
decodeSerializedEvent({
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
),
),
)
const subscribeSynchronized = (aggregateID: string) =>
Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub)
yield* Effect.acquireRelease(
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set()
pubsubs.add(pubsub)
synchronized.set(aggregateID, pubsubs)
}),
() =>
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID)
pubsubs?.delete(pubsub)
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
)
return subscription
})
const streamEvents = (input: {
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap(
Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID)
let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor
}),
),
)
const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
return Stream.concat(Stream.fromIterable(historical), live)
}),
)
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
})
})
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
})
return Service.of({
publish,
subscribe,
all: streamAll,
aggregateEvents: streamEvents,
sync,
listen,
beforeCommit,
project,
replay,
replayAll,
remove,
claim,
})
}),
)
export const layer = layerWith()

View file

@ -52,17 +52,23 @@ export interface RemoveResult {
export interface Interface {
/** Create only while the planned target remains absent. */
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
readonly create: (
input: WriteInput,
) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
/** Write after immediately revalidating the planned target. */
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
readonly writeTextPreservingBom: (
input: TextWriteInput,
) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Commit only if an existing target still has the expected bytes. */
readonly writeIfUnchanged: (
input: ConditionalWriteInput,
) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
/** Remove after immediately revalidating the planned target. */
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
readonly remove: (
input: RemoveInput,
) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
@ -92,12 +98,15 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock = (target: string) => <A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target)(Effect.uninterruptible(effect))
const withTargetLock =
(target: string) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target)(Effect.uninterruptible(effect))
const withValidatedTarget = (plan: LocationMutation.Plan) => <A, E, R>(
commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>,
) => withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
const withValidatedTarget =
(plan: LocationMutation.Plan) =>
<A, E, R>(commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>) =>
withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({
operation: "write",
@ -138,7 +147,8 @@ export const layer = Layer.effect(
Effect.gen(function* () {
if (target.exists) return yield* new TargetExistsError({ path: target.canonical })
yield* fs.ensureDir(dirname(target.canonical))
if (typeof input.content === "string") yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
if (typeof input.content === "string")
yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" })
return writeResult(target, false)
}),

View file

@ -447,7 +447,11 @@ export const layer = Layer.effect(
const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie)
if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
const info = yield* fs.stat(canonical).pipe(Effect.orDie)
if (info.type !== (target.type === "file" ? "File" : "Directory") || info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
if (
info.type !== (target.type === "file" ? "File" : "Directory") ||
info.dev !== target.dev ||
Option.getOrUndefined(info.ino) !== target.ino
)
return yield* Effect.die(new Error("Search root identity changed after approval"))
return target
})

View file

@ -241,7 +241,13 @@ export const layer = Layer.effect(
const directory =
boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical
const resource = slash(path.join(directory, "*"))
return { action: "external_directory" as const, directory, resource, save: resource, authority: boundary.authority }
return {
action: "external_directory" as const,
directory,
resource,
save: resource,
authority: boundary.authority,
}
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {

View file

@ -83,7 +83,10 @@ export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepRes
export interface Interface {
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
readonly grep: (input: GrepInput, root?: FileSystem.RootTarget) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
readonly grep: (
input: GrepInput,
root?: FileSystem.RootTarget,
) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}

View file

@ -30,10 +30,10 @@ const DefaultSessions = SessionV2.layer.pipe(
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({ sessions: yield* SessionV2.Service })
}),
).pipe(Layer.provide(DefaultSessions))
Service,
Effect.gen(function* () {
return Service.of({ sessions: yield* SessionV2.Service })
}),
).pipe(Layer.provide(DefaultSessions))
// TODO: Add OpenCode.create(...) as the Promise facade over the same embedded API semantics.

View file

@ -3,7 +3,12 @@ export * as Patch from "./patch"
export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string }
| { readonly type: "update"; readonly path: string; readonly movePath?: string; readonly chunks: ReadonlyArray<UpdateFileChunk> }
| {
readonly type: "update"
readonly path: string
readonly movePath?: string
readonly chunks: ReadonlyArray<UpdateFileChunk>
}
export interface UpdateFileChunk {
readonly oldLines: ReadonlyArray<string>
@ -166,7 +171,12 @@ function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, star
return -1
}
function matches(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, offset: number, compare: (left: string, right: string) => boolean) {
function matches(
lines: ReadonlyArray<string>,
pattern: ReadonlyArray<string>,
offset: number,
compare: (left: string, right: string) => boolean,
) {
return pattern.every((line, index) => compare(lines[offset + index]!, line))
}
@ -174,6 +184,14 @@ const exact = (left: string, right: string) => left === right
const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
const trim = (left: string, right: string) => left.trim() === right.trim()
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
const normalize = (value: string) => value.replace(/[]/g, "'").replace(/[“”„‟]/g, '"').replace(/[‐‑‒–—―]/g, "-").replace(/…/g, "...").replace(/ /g, " ")
const splitBom = (text: string) => text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input
const normalize = (value: string) =>
value
.replace(/[]/g, "'")
.replace(/[“”„‟]/g, '"')
.replace(/[‐‑‒–—―]/g, "-")
.replace(/…/g, "...")
.replace(/ /g, " ")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) =>
input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input

View file

@ -200,9 +200,9 @@ export const layer = Layer.effect(
const item = { request, deferred }
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
pending.set(request.id, item)
yield* events.publish(Event.Asked, request).pipe(
EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))),
)
yield* events
.publish(Event.Asked, request)
.pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))))
return item
}),
)
@ -236,69 +236,73 @@ export const layer = Layer.effect(
),
)
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => EffectRuntime.uninterruptible(EffectRuntime.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* events.publish(Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) =>
EffectRuntime.uninterruptible(
EffectRuntime.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
yield* Deferred.fail(item.deferred, new RejectedError())
pending.delete(id)
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
pending.delete(id)
}
return
}
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
pending.delete(id)
}
})))
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
pending.delete(id)
}
}),
),
)
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request)

View file

@ -34,7 +34,7 @@ const RawMatch = Schema.Struct({
}),
})
export type Match = typeof RawMatch.Type["data"]
export type Match = (typeof RawMatch.Type)["data"]
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
message: Schema.String,
@ -77,7 +77,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const failure = (message: string, cause?: unknown) => new Error({ message, cause })
const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex")
const isInvalidPattern = (stderr: string) =>
stderr.includes("regex parse error") || stderr.includes("error parsing regex")
export const layer = Layer.effect(
Service,
@ -126,7 +127,13 @@ export const layer = Layer.effect(
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
return abortable.pipe(Effect.mapError((cause) => cause instanceof Error || cause instanceof InvalidPatternError ? cause : failure("ripgrep execution failed", cause)))
return abortable.pipe(
Effect.mapError((cause) =>
cause instanceof Error || cause instanceof InvalidPatternError
? cause
: failure("ripgrep execution failed", cause),
),
)
}
return Service.of({
@ -143,9 +150,7 @@ export const layer = Layer.effect(
".",
],
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
}).pipe(
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
grep: (input) =>
run<Match>({
...input,
@ -167,11 +172,16 @@ export const layer = Layer.effect(
: Effect.try({
try: () => JSON.parse(line) as unknown,
catch: (cause) => failure("Invalid ripgrep JSON output", cause),
})).pipe(
})
).pipe(
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.succeed(undefined)
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({ ...match.data, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES) })),
Effect.map((match) => ({
...match.data,
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),

View file

@ -6,7 +6,8 @@ export type ExternalID = {
readonly key: string
}
export const externalID = (prefix: string, input: ExternalID) => `${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}`
export const externalID = (prefix: string, input: ExternalID) =>
`${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}`
/**
* Integer greater than zero.

View file

@ -132,8 +132,14 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: EventV2.Cursor
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, OperationUnavailableError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, OperationUnavailableError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, OperationUnavailableError>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@ -318,7 +324,9 @@ export const layer = Layer.effect(
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)))
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined

View file

@ -126,7 +126,8 @@ export const equivalent = (
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
db: DatabaseService,

View file

@ -318,14 +318,16 @@ export const layer = Layer.effectDiscard(
}),
)
yield* events.project(SessionEvent.AgentSwitched, (event) =>
db.update(SessionTable)
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
db.update(SessionTable)
db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()

View file

@ -1,4 +1,11 @@
import { ToolOutput as LLMToolOutput, type LLMEvent, type ProviderMetadata, type ToolOutput as LLMToolOutputType, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import {
ToolOutput as LLMToolOutput,
type LLMEvent,
type ProviderMetadata,
type ToolOutput as LLMToolOutputType,
type ToolResultValue,
type Usage,
} from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
@ -52,7 +59,15 @@ const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultV
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map<
string,
{ readonly assistantMessageID: EventV2.ID; readonly name: string; inputEnded: boolean; called: boolean; settled: boolean; providerExecuted: boolean; providerMetadata?: ProviderMetadata }
{
readonly assistantMessageID: EventV2.ID
readonly name: string
inputEnded: boolean
called: boolean
settled: boolean
providerExecuted: boolean
providerMetadata?: ProviderMetadata
}
>()
const timestamp = DateTime.now
let assistantMessageID: EventV2.ID | undefined
@ -60,14 +75,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp })).id
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp }))
.id
return assistantMessageID
})
const currentAssistantMessageID = () => assistantMessageID === undefined
? Effect.die("Tool event before assistant step start")
: Effect.succeed(assistantMessageID)
const currentAssistantMessageID = () =>
assistantMessageID === undefined
? Effect.die("Tool event before assistant step start")
: Effect.succeed(assistantMessageID)
const fragments = (name: string, ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>) => {
const fragments = (
name: string,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
) => {
const chunks = new Map<string, string[]>()
const start = (id: string) =>
Effect.suspend(() => {
@ -139,7 +159,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
const assistantMessageID = yield* currentAssistantMessageID()
tools.set(event.id, { assistantMessageID, name: event.name, inputEnded: false, called: false, settled: false, providerExecuted: false })
tools.set(event.id, {
assistantMessageID,
name: event.name,
inputEnded: false,
called: false,
settled: false,
providerExecuted: false,
})
yield* toolInput.start(event.id)
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
@ -153,7 +180,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
yield* toolInput.end(event.id)
})
@ -190,7 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return
case "text-start":
yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, timestamp: yield* timestamp, textID: event.id })
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
textID: event.id,
})
return
case "text-delta":
yield* text.append(event.id, event.text)
@ -231,7 +263,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-input-delta": {
const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input delta before start: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.inputEnded) return yield* Effect.die(`Tool input delta after end: ${event.id}`)
yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, {
@ -250,7 +283,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (!tool.inputEnded) yield* endToolInput(event)
if (tool.name !== event.name) return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.called) return yield* Effect.die(`Duplicate tool call: ${event.id}`)
tool.called = true
tool.providerExecuted = event.providerExecuted === true
@ -272,7 +306,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-result": {
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool result before call: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.settled) {
if (event.result.type === "error") return
return yield* Effect.die(`Duplicate tool result: ${event.id}`)
@ -309,7 +344,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-error": {
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool error before call: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`)
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {

View file

@ -1,4 +1,12 @@
import { Message, ToolCallPart, ToolOutput, ToolResultPart, type ContentPart, type Model, type ProviderMetadata } from "@opencode-ai/llm"
import {
Message,
ToolCallPart,
ToolOutput,
ToolResultPart,
type ContentPart,
type Model,
type ProviderMetadata,
} from "@opencode-ai/llm"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
@ -61,7 +69,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
}
const assistant = (message: SessionMessage.Assistant, model: Model) => {
const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const sameModel =
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning")
@ -71,12 +80,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
const result = toolResult(item, sameModel ? item.provider?.resultMetadata ?? item.provider?.metadata : undefined)
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
return item.provider?.executed === true && result ? [call, result] : [call]
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) => toolResult(item, sameModel ? item.provider?.resultMetadata ?? item.provider?.metadata : undefined))
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
.filter((message) => message !== undefined)
.map(Message.tool)
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]

View file

@ -15,7 +15,7 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
const create = () => schema.make("ses_" + Identifier.descending())
return {
create,
descending: (id?: string) => id === undefined ? create() : schema.make(id),
descending: (id?: string) => (id === undefined ? create() : schema.make(id)),
fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)),
}
}),

View file

@ -151,11 +151,10 @@ export const layer = Layer.effect(
}),
({ skill, root, files }) =>
Effect.gen(function* () {
yield* Effect.forEach(
files,
(file) => download(file.url, file.destination),
{ concurrency: fileConcurrency, discard: true },
)
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
? [AbsolutePath.make(root)]

View file

@ -33,14 +33,20 @@ export class Page extends Schema.Class<Page>("ToolOutputStore.Page")({
next: NonNegativeInt.pipe(Schema.optional),
}) {}
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()("ToolOutputStore.AccessDeniedError", {
uri: Schema.String,
sessionID: SessionSchema.ID,
}) {}
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()(
"ToolOutputStore.AccessDeniedError",
{
uri: Schema.String,
sessionID: SessionSchema.ID,
},
) {}
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()("ToolOutputStore.InvalidResourceError", {
uri: Schema.String,
}) {}
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()(
"ToolOutputStore.InvalidResourceError",
{
uri: Schema.String,
},
) {}
export class ResourceNotFoundError extends Schema.TaggedErrorClass<ResourceNotFoundError>()(
"ToolOutputStore.ResourceNotFoundError",
@ -88,7 +94,9 @@ export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<Resource>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly read: (input: ReadInput) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly read: (
input: ReadInput,
) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly cleanup: () => Effect.Effect<void>
}
@ -153,11 +161,17 @@ const preview = (text: string, maxLines: number, maxBytes: number) => {
const sampled =
lines.length <= maxLines
? text
: [lines.slice(0, headLines).join("\n"), ...(tailLines > 0 ? [lines.slice(lines.length - tailLines).join("\n")] : [])].join("\n")
: [
lines.slice(0, headLines).join("\n"),
...(tailLines > 0 ? [lines.slice(lines.length - tailLines).join("\n")] : []),
].join("\n")
if (Buffer.byteLength(sampled, "utf-8") <= maxBytes) {
return lines.length <= maxLines
? { head: sampled, tail: "" }
: { head: lines.slice(0, headLines).join("\n"), tail: tailLines > 0 ? lines.slice(lines.length - tailLines).join("\n") : "" }
: {
head: lines.slice(0, headLines).join("\n"),
tail: tailLines > 0 ? lines.slice(lines.length - tailLines).join("\n") : "",
}
}
const headBytes = Math.ceil(maxBytes / 2)
const tailBytes = Math.floor(maxBytes / 2)
@ -192,7 +206,7 @@ export const layer = Layer.effect(
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
const configured = Object.assign(
{},
...entries.flatMap((entry) => entry.type === "document" ? [entry.info.tool_output ?? {}] : []),
...entries.flatMap((entry) => (entry.type === "document" ? [entry.info.tool_output ?? {}] : [])),
)
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
})
@ -218,7 +232,12 @@ export const layer = Layer.effect(
Effect.onError(() => fs.remove(contentPath(id)).pipe(Effect.catch(() => Effect.void))),
Effect.orDie,
)
return new Resource({ uri: resourceUri, mime: record.mime, ...(record.name === undefined ? {} : { name: record.name }), size })
return new Resource({
uri: resourceUri,
mime: record.mime,
...(record.name === undefined ? {} : { name: record.name }),
size,
})
})
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
@ -281,12 +300,14 @@ export const layer = Layer.effect(
const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
const cutoff = Date.now() - Duration.toMillis(RETENTION)
const ids = new Set(entries.flatMap((entry) => {
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
return match ? [match[1]] : []
}))
const ids = new Set(
entries.flatMap((entry) => {
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
return match ? [match[1]] : []
}),
)
const removeIfPresent = (target: string) =>
fs.existsSafe(target).pipe(Effect.flatMap((exists) => exists ? fs.remove(target) : Effect.void))
fs.existsSafe(target).pipe(Effect.flatMap((exists) => (exists ? fs.remove(target) : Effect.void)))
const removePair = (id: string) =>
Effect.gen(function* () {
yield* removeIfPresent(contentPath(id))
@ -298,13 +319,19 @@ export const layer = Layer.effect(
if (!text) {
if (!contentExists) continue
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
const modified = info ? info.mtime.pipe(Option.map((date) => date.getTime()), Option.getOrElse(() => 0)) : 0
const modified = info
? info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
)
: 0
if (modified < cutoff) yield* removePair(id)
continue
}
const record = yield* Effect.try({ try: () => JSON.parse(text), catch: () => new globalThis.Error("Invalid metadata") }).pipe(
Effect.catch(() => Effect.succeed(undefined)),
)
const record = yield* Effect.try({
try: () => JSON.parse(text),
catch: () => new globalThis.Error("Invalid metadata"),
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
const info = contentExists ? yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void)) : undefined
if (
!contentExists ||

View file

@ -1,6 +1,16 @@
export * as ToolRegistry from "./tool-registry"
import { Tool, ToolFailure, ToolOutput, ToolResultValue as ToolResult, type Tool as TypedTool, type ToolCall, type ToolResultValue, type ToolSchema, type ToolSettlement } from "@opencode-ai/llm"
import {
Tool,
ToolFailure,
ToolOutput,
ToolResultValue as ToolResult,
type Tool as TypedTool,
type ToolCall,
type ToolResultValue,
type ToolSchema,
type ToolSettlement,
} from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet } from "immer"
import { PermissionV2 } from "./permission"
@ -35,10 +45,15 @@ export type AuthorizeInput<Parameters = unknown> = Invocation & {
readonly parameters: Parameters
}
export type Entry<Parameters extends ToolSchema<any> = ToolSchema<any>, Success extends ToolSchema<any> = ToolSchema<any>> = {
export type Entry<
Parameters extends ToolSchema<any> = ToolSchema<any>,
Success extends ToolSchema<any> = ToolSchema<any>,
> = {
readonly tool: TypedTool<Parameters, Success>
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
readonly execute?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
}
type Data = {
@ -48,7 +63,10 @@ type Data = {
export type Editor = {
readonly list: () => ReadonlyArray<readonly [string, Entry]>
readonly get: (name: string) => Entry | undefined
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(name: string, entry: Entry<Parameters, Success>) => void
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(
name: string,
entry: Entry<Parameters, Success>,
) => void
readonly remove: (name: string) => void
}
@ -65,84 +83,91 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
get: (name) => draft.entries.get(name) as Entry | undefined,
set: (name, entry) => {
draft.entries.set(name, castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never)
},
remove: (name) => {
draft.entries.delete(name)
},
}),
})
Service,
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
get: (name) => draft.entries.get(name) as Entry | undefined,
set: (name, entry) => {
draft.entries.set(
name,
castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
},
remove: (name) => {
draft.entries.delete(name)
},
}),
})
const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
return Tool.toDefinitions(Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])))
})
const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
return Tool.toDefinitions(
Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])),
)
})
const invocation = (input: ExecuteInput): Invocation => ({
...input,
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }),
})
const invocation = (input: ExecuteInput): Invocation => ({
...input,
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }),
})
const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) {
const entry = state.get().entries.get(input.call.name)
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
if (!entry.execute && !entry.tool.execute)
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) {
const entry = state.get().entries.get(input.call.name)
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
if (!entry.execute && !entry.tool.execute)
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
return yield* entry.tool._decode(input.call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((parameters) => {
const context = { ...invocation(input), parameters }
const execute = entry.execute?.(context) ??
entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
return (entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))).pipe(
Effect.flatMap((value) =>
entry.tool._encode(value).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its success schema: ${error.message}`,
}),
),
return yield* entry.tool._decode(input.call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((parameters) => {
const context = { ...invocation(input), parameters }
const execute =
entry.execute?.(context) ?? entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
return (
entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))
).pipe(
Effect.flatMap((value) =>
entry.tool._encode(value).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its success schema: ${error.message}`,
}),
),
),
Effect.map((value): ToolSettlement => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
}),
)
}),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
),
)
})
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
})
return Service.of({
transform: state.transform,
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
),
Effect.map((value): ToolSettlement => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
}),
)
}),
definitions,
execute,
settle,
})
}),
)
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
),
)
})
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
})
return Service.of({
transform: state.transform,
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
}),
definitions,
execute,
settle,
})
}),
)

View file

@ -11,7 +11,9 @@ import { ToolRegistry } from "../tool-registry"
export const name = "apply_patch"
export const Parameters = Schema.Struct({
patchText: Schema.String.annotate({ description: "The full patch text describing add, update, and delete operations" }),
patchText: Schema.String.annotate({
description: "The full patch text describing add, update, and delete operations",
}),
})
export const Applied = Schema.Struct({
@ -24,7 +26,12 @@ export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
export type Success = typeof Success.Type
export const toModelOutput = (output: Success) =>
["Applied patch sequentially:", ...output.applied.map((item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`)].join("\n")
[
"Applied patch sequentially:",
...output.applied.map(
(item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`,
),
].join("\n")
const definition = Tool.make({
description:
@ -36,9 +43,23 @@ const definition = Tool.make({
type Planned = { readonly hunk: Patch.Hunk; readonly plan: LocationMutation.Plan }
type Prepared =
| { readonly type: "add"; readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>; readonly plan: LocationMutation.Plan }
| { readonly type: "delete"; readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>; readonly plan: LocationMutation.Plan }
| { readonly type: "update"; readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>; readonly plan: LocationMutation.Plan; readonly source: Uint8Array; readonly content: string }
| {
readonly type: "add"
readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "delete"
readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "update"
readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>
readonly plan: LocationMutation.Plan
readonly source: Uint8Array
readonly content: string
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -53,9 +74,10 @@ export const layer = Layer.effectDiscard(
execute: ({ parameters, assertPermission }) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, cause: unknown) => {
const prefix = applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
const prefix =
applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix, error: cause })
}
return Effect.gen(function* () {
@ -69,7 +91,8 @@ export const layer = Layer.effectDiscard(
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
const planned: Planned[] = []
for (const hunk of hunks) planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
for (const hunk of hunks)
planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { plan } of planned) {
const external = plan.target.externalDirectory
@ -78,7 +101,11 @@ export const layer = Layer.effectDiscard(
for (const external of externalDirectories.values()) {
yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
}
yield* assertPermission({ action: "edit", resources: [...new Set(planned.map(({ plan }) => plan.target.resource))], save: ["*"] })
yield* assertPermission({
action: "edit",
resources: [...new Set(planned.map(({ plan }) => plan.target.resource))],
save: ["*"],
})
const prepared: Prepared[] = []
for (const { hunk, plan } of planned) {
@ -89,33 +116,51 @@ export const layer = Layer.effectDiscard(
continue
}
const target = yield* mutation.revalidate(plan)
if (!target.exists || target.type !== "File") return yield* fail(hunk.path, new Error("Target file does not exist"))
if (!target.exists || target.type !== "File")
return yield* fail(hunk.path, new Error("Target file does not exist"))
if (hunk.type === "delete") {
prepared.push({ type: hunk.type, hunk, plan })
continue
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(hunk.path, hunk.chunks, new TextDecoder("utf-8", { ignoreBOM: true }).decode(source))
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({ type: hunk.type, hunk, plan, source, content: Patch.joinBom(update.content, update.bom) })
}
yield* Effect.uninterruptible(
Effect.forEach(prepared, (change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({ plan: change.plan, content: change.hunk.contents.endsWith("\n") || change.hunk.contents === "" ? change.hunk.contents : `${change.hunk.contents}\n` })
Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
plan: change.plan,
content:
change.hunk.contents.endsWith("\n") || change.hunk.contents === ""
? change.hunk.contents
: `${change.hunk.contents}\n`,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ plan: change.plan })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
plan: change.plan,
expected: change.source,
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ plan: change.plan })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({ plan: change.plan, expected: change.source, content: change.content })
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
{ discard: true }),
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
{ discard: true },
),
)
return { applied }
}).pipe(

View file

@ -112,7 +112,9 @@ export const layer = Layer.effectDiscard(
const error = Cause.squash(cause)
return Effect.fail(
error instanceof FileMutation.StaleContentError
? new ToolFailure({ message: "File changed after permission approval. Read it again before editing." })
? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.",
})
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }),
)
}),
@ -123,7 +125,9 @@ export const layer = Layer.effectDiscard(
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
}
if (parameters.oldString === "") {
return yield* new ToolFailure({ message: "oldString must not be empty. Use write to create or overwrite a file." })
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
const plan = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
@ -158,7 +162,11 @@ export const layer = Layer.effectDiscard(
: source.text.replace(oldString, newString)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({ plan, expected: source.content, content: joinBom(next.text, source.bom || next.bom) }),
files.writeIfUnchanged({
plan,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Success
})

View file

@ -10,9 +10,15 @@ export const name = "glob"
export const Parameters = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({ description: "Relative directory to search. Defaults to the active Location." }),
reference: LocationSearch.FilesInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
limit: LocationSearch.FilesInput.fields.limit.annotate({ description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.FilesInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
limit: LocationSearch.FilesInput.fields.limit.annotate({
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
}),
})
type ModelOutput = typeof LocationSearch.FilesResult.Encoded
@ -21,14 +27,18 @@ type ModelOutput = typeof LocationSearch.FilesResult.Encoded
export const toModelOutput = (output: ModelOutput) => {
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
if (output.truncated) {
lines.push("", `(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`)
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
return lines.join("\n")
}
const definition = Tool.make({
description: "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
parameters: Parameters,
success: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@ -66,7 +76,12 @@ export const layer = Layer.effectDiscard(
return yield* search.files(parameters, root)
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(new ToolFailure({ message: `Unable to find files matching ${parameters.pattern}`, error: Cause.squash(cause) })),
Effect.fail(
new ToolFailure({
message: `Unable to find files matching ${parameters.pattern}`,
error: Cause.squash(cause),
}),
),
),
),
}),

View file

@ -10,11 +10,21 @@ import { ToolRegistry } from "../tool-registry"
export const name = "grep"
export const Parameters = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({ description: "Regex pattern to search for in file contents" }),
path: LocationSearch.GrepInput.fields.path.annotate({ description: "Relative file or directory to search. Defaults to the active Location." }),
reference: LocationSearch.GrepInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
include: LocationSearch.GrepInput.fields.include.annotate({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")' }),
limit: LocationSearch.GrepInput.fields.limit.annotate({ description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
path: LocationSearch.GrepInput.fields.path.annotate({
description: "Relative file or directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.GrepInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
include: LocationSearch.GrepInput.fields.include.annotate({
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
}),
limit: LocationSearch.GrepInput.fields.limit.annotate({
description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
}),
})
type Success = typeof LocationSearch.GrepResult.Encoded
@ -32,14 +42,18 @@ export const toModelOutput = (output: Success) => {
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
}
if (output.truncated) {
lines.push("", `(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`)
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
return lines.join("\n")
}
const definition = Tool.make({
description: "Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
description:
"Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
parameters: Parameters,
success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@ -79,9 +93,10 @@ export const layer = Layer.effectDiscard(
}).pipe(
Effect.catchCause((cause) => {
const error = Cause.squash(cause)
const message = error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
: `Unable to grep for ${parameters.pattern}`
const message =
error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
: `Unable to grep for ${parameters.pattern}`
return Effect.fail(new ToolFailure({ message, error }))
}),
),

View file

@ -70,7 +70,11 @@ export const layer = Layer.effectDiscard(
const final = yield* filesystem.resolveReadPath(input)
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
return yield* Effect.die(new Error("File changed after permission approval"))
if (final.target.size > FileSystem.MAX_READ_BYTES || input.offset !== undefined || input.limit !== undefined)
if (
final.target.size > FileSystem.MAX_READ_BYTES ||
input.offset !== undefined ||
input.limit !== undefined
)
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
}).pipe(

View file

@ -150,7 +150,9 @@ export const layer = Layer.effectDiscard(
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, parameters.url, parameters.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => execute(http, parameters.url, parameters.format, "opencode")),
Effect.catchIf(isCloudflareChallenge, () =>
execute(http, parameters.url, parameters.format, "opencode"),
),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)

View file

@ -34,7 +34,9 @@ The current year is ${new Date().getFullYear()}. Use this year when searching fo
export const Parameters = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})` }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
}),
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
description:
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
@ -42,9 +44,11 @@ export const Parameters = Schema.Struct({
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate({
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
{
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
},
),
})
export const Provider = Schema.Literals(["exa", "parallel"])
@ -63,13 +67,11 @@ export class ConfigService extends Context.Service<ConfigService, Config>()("@op
/** Isolates the retained product environment contract from the generic tool implementation. */
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({
provider: process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa:
truthy("OPENCODE_EXPERIMENTAL") ||
truthy("OPENCODE_ENABLE_EXA") ||
truthy("OPENCODE_EXPERIMENTAL_EXA"),
provider:
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
exaApiKey: process.env.EXA_API_KEY,
parallelApiKey: process.env.PARALLEL_API_KEY,
@ -162,9 +164,15 @@ const callMcp = <F extends Schema.Struct.Fields>(
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* response.text
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
return yield* parseResponse(body)
}).pipe(Effect.timeoutOrElse({ duration: Duration.seconds(25), orElse: () => Effect.die(new Error(`${tool} request timed out`)) }))
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.die(new Error(`${tool} request timed out`)),
}),
)
})
const Success = Schema.Struct({
@ -201,30 +209,31 @@ export const layer = Layer.effectDiscard(
metadata: { ...parameters, provider },
})
const text = provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: parameters.query,
type: parameters.type || "auto",
numResults: parameters.numResults || 8,
livecrawl: parameters.livecrawl || "fallback",
contextMaxCharacters: parameters.contextMaxCharacters,
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: parameters.query,
search_queries: [parameters.query],
session_id: sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const text =
provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: parameters.query,
type: parameters.type || "auto",
numResults: parameters.numResults || 8,
livecrawl: parameters.livecrawl || "fallback",
contextMaxCharacters: parameters.contextMaxCharacters,
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: parameters.query,
search_queries: [parameters.query],
session_id: sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS })
return {
provider,
@ -234,7 +243,12 @@ export const layer = Layer.effectDiscard(
}
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(new ToolFailure({ message: `Unable to search the web for ${parameters.query}`, error: Cause.squash(cause) })),
Effect.fail(
new ToolFailure({
message: `Unable to search the web for ${parameters.query}`,
error: Cause.squash(cause),
}),
),
),
)
},