chore: generate
This commit is contained in:
parent
76ee87ead8
commit
b0a929440b
87 changed files with 2301 additions and 1599 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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\`);`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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") {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue