Skip to content

Commit 39a8902

Browse files
committed
feat(session): add session recap card
Generate a read-only LLM summary of a session and surface it in the TUI as a recap card on open, after idle, or on demand via /recap. Recap builds its transcript from the legacy V1 message/part tables where sessions actually persist their history, falling back to the V2 projected message store, so summaries reflect real conversations instead of always reporting no context. Model resolution falls back to the default model when the session's own model is unavailable, keeping recap best-effort so it never blocks the run. Adds the /api/session/:sessionID/recap endpoint across protocol, server, core execution/runner, and the generated SDK/client.
1 parent 2d6b472 commit 39a8902

17 files changed

Lines changed: 593 additions & 56 deletions

File tree

packages/client/src/generated-effect/client.ts

Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,26 @@ type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sess
128128
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
129129
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
130130

131-
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
131+
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.recap"]>[0]
132132
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
133133
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
134+
raw["session.recap"]({ params: { sessionID: input["sessionID"] } }).pipe(
135+
Effect.mapError(mapClientError),
136+
Effect.map((value) => value.data),
137+
)
138+
139+
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
140+
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
141+
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
134142
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
135143

136-
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
137-
type Endpoint3_9Input = {
138-
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
139-
readonly messageID: Endpoint3_9Request["payload"]["messageID"]
140-
readonly files?: Endpoint3_9Request["payload"]["files"]
144+
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
145+
type Endpoint3_10Input = {
146+
readonly sessionID: Endpoint3_10Request["params"]["sessionID"]
147+
readonly messageID: Endpoint3_10Request["payload"]["messageID"]
148+
readonly files?: Endpoint3_10Request["payload"]["files"]
141149
}
142-
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
150+
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
143151
raw["session.revert.stage"]({
144152
params: { sessionID: input["sessionID"] },
145153
payload: { messageID: input["messageID"], files: input["files"] },
@@ -148,60 +156,60 @@ const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Inp
148156
Effect.map((value) => value.data),
149157
)
150158

151-
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
152-
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
153-
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
154-
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
155-
156-
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
159+
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
157160
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
158161
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
159-
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
162+
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
160163

161-
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
164+
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
162165
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
163166
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
167+
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
168+
169+
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
170+
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
171+
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
164172
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
165173
Effect.mapError(mapClientError),
166174
Effect.map((value) => value.data),
167175
)
168176

169-
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
170-
type Endpoint3_13Input = {
171-
readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
172-
readonly limit?: Endpoint3_13Request["query"]["limit"]
173-
readonly after?: Endpoint3_13Request["query"]["after"]
177+
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.history"]>[0]
178+
type Endpoint3_14Input = {
179+
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
180+
readonly limit?: Endpoint3_14Request["query"]["limit"]
181+
readonly after?: Endpoint3_14Request["query"]["after"]
174182
}
175-
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
183+
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
176184
raw["session.history"]({
177185
params: { sessionID: input["sessionID"] },
178186
query: { limit: input["limit"], after: input["after"] },
179187
}).pipe(Effect.mapError(mapClientError))
180188

181-
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
182-
type Endpoint3_14Input = {
183-
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
184-
readonly after?: Endpoint3_14Request["query"]["after"]
189+
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[0]
190+
type Endpoint3_15Input = {
191+
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
192+
readonly after?: Endpoint3_15Request["query"]["after"]
185193
}
186-
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
194+
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
187195
Stream.unwrap(
188196
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
189197
Effect.mapError(mapClientError),
190198
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
191199
),
192200
)
193201

194-
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
195-
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
196-
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
202+
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
203+
type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] }
204+
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
197205
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
198206

199-
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
200-
type Endpoint3_16Input = {
201-
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
202-
readonly messageID: Endpoint3_16Request["params"]["messageID"]
207+
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[0]
208+
type Endpoint3_17Input = {
209+
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
210+
readonly messageID: Endpoint3_17Request["params"]["messageID"]
203211
}
204-
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
212+
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
205213
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
206214
Effect.mapError(mapClientError),
207215
Effect.map((value) => value.data),
@@ -216,15 +224,16 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
216224
switchModel: Endpoint3_5(raw),
217225
prompt: Endpoint3_6(raw),
218226
compact: Endpoint3_7(raw),
219-
wait: Endpoint3_8(raw),
220-
stage: Endpoint3_9(raw),
221-
clear: Endpoint3_10(raw),
222-
commit: Endpoint3_11(raw),
223-
context: Endpoint3_12(raw),
224-
history: Endpoint3_13(raw),
225-
events: Endpoint3_14(raw),
226-
interrupt: Endpoint3_15(raw),
227-
message: Endpoint3_16(raw),
227+
recap: Endpoint3_8(raw),
228+
wait: Endpoint3_9(raw),
229+
stage: Endpoint3_10(raw),
230+
clear: Endpoint3_11(raw),
231+
commit: Endpoint3_12(raw),
232+
context: Endpoint3_13(raw),
233+
history: Endpoint3_14(raw),
234+
events: Endpoint3_15(raw),
235+
interrupt: Endpoint3_16(raw),
236+
message: Endpoint3_17(raw),
228237
})
229238

230239
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]

packages/client/src/generated/client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type {
1919
SessionsPromptOutput,
2020
SessionsCompactInput,
2121
SessionsCompactOutput,
22+
SessionsRecapInput,
23+
SessionsRecapOutput,
2224
SessionsWaitInput,
2325
SessionsWaitOutput,
2426
SessionsStageInput,
@@ -390,6 +392,17 @@ export function make(options: ClientOptions) {
390392
},
391393
requestOptions,
392394
),
395+
recap: (input: SessionsRecapInput, requestOptions?: RequestOptions) =>
396+
request<{ readonly data: SessionsRecapOutput }>(
397+
{
398+
method: "POST",
399+
path: `/api/session/${encodeURIComponent(input.sessionID)}/recap`,
400+
successStatus: 200,
401+
declaredStatuses: [404, 503, 400, 401],
402+
empty: false,
403+
},
404+
requestOptions,
405+
).then((value) => value.data),
393406
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
394407
request<SessionsWaitOutput>(
395408
{

packages/client/src/generated/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,10 @@ export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: s
486486

487487
export type SessionsCompactOutput = void
488488

489+
export type SessionsRecapInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
490+
491+
export type SessionsRecapOutput = { readonly data: string }["data"]
492+
489493
export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
490494

491495
export type SessionsWaitOutput = void

packages/core/src/session.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
9595
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
9696
"Session.OperationUnavailableError",
9797
{
98-
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
98+
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "recap", "wait"]),
9999
},
100100
) {}
101101

@@ -164,6 +164,7 @@ export interface Interface {
164164
resume?: boolean
165165
}) => Effect.Effect<void, OperationUnavailableError>
166166
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
167+
readonly recap: (sessionID: SessionSchema.ID) => Effect.Effect<string, NotFoundError | OperationUnavailableError>
167168
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
168169
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
169170
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
@@ -418,6 +419,12 @@ const layer = Layer.effect(
418419
yield* result.get(input.sessionID)
419420
return yield* new OperationUnavailableError({ operation: "compact" })
420421
}),
422+
recap: Effect.fn("V2Session.recap")(function* (sessionID) {
423+
yield* result.get(sessionID)
424+
return yield* execution.recap(sessionID).pipe(
425+
Effect.catchCause((defect) => Effect.logError("RECAP ERROR", defect).pipe(Effect.andThen(Effect.fail(new OperationUnavailableError({ operation: "recap" })))))
426+
)
427+
}),
421428
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
422429
yield* result.get(sessionID)
423430
return yield* new OperationUnavailableError({ operation: "wait" })

packages/core/src/session/compaction.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,53 @@ export const make = (dependencies: Dependencies) => {
234234
return false
235235
return yield* compactAfterOverflow(input)
236236
})
237+
const generateSummaryFromText = Effect.fn("SessionCompaction.generateSummaryFromText")(function* (input: {
238+
readonly contextText: string
239+
readonly model: Model
240+
}) {
241+
if (!input.contextText.trim()) return "No sufficient context for a summary."
242+
const summaryPrompt = `Based on the following conversation history, please write a brief, 1-2 sentence conversational summary of what the user is asking or trying to accomplish, and why (if deducible). Phrase it directly to the user (e.g., "You've been asking about..."). Do not include any other markdown or headers, just the short summary.\n\n<history>\n${input.contextText}\n</history>`
243+
const summaryOutput = Math.min(input.model.route.defaults.limits?.output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
244+
245+
const chunks: string[] = []
246+
let failed = false
247+
yield* dependencies.llm
248+
.stream(
249+
LLM.request({
250+
model: input.model,
251+
messages: [Message.user(summaryPrompt)],
252+
tools: [],
253+
generation: { maxTokens: summaryOutput },
254+
}),
255+
)
256+
.pipe(
257+
Stream.runForEach((event) => {
258+
if (LLMEvent.is.providerError(event)) failed = true
259+
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
260+
return Effect.void
261+
}),
262+
Effect.catchTag("LLM.Error", () => Effect.void),
263+
)
264+
const summary = chunks.join("")
265+
if (failed || !summary.trim()) return "Failed to generate summary."
266+
return summary
267+
})
268+
const generateSummaryString = Effect.fn("SessionCompaction.generateSummaryString")(function* (input: {
269+
readonly sessionID: SessionSchema.ID
270+
readonly entries: readonly Entry[]
271+
readonly model: Model
272+
}) {
273+
const selected = select(input.entries, config.tokens)
274+
const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
275+
if (!selected || (selected.head.length === 0 && selected.recent.length === 0 && previousSummary?.type !== "compaction")) return "No sufficient context for a summary."
276+
const contextText = [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head, selected.recent].filter(Boolean).join("\n\n")
277+
return yield* generateSummaryFromText({ contextText, model: input.model })
278+
})
279+
237280
return {
238281
compactIfNeeded,
239282
compactAfterOverflow,
283+
generateSummaryString,
284+
generateSummaryFromText,
240285
}
241286
}

packages/core/src/session/execution.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface Interface {
1515
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
1616
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
1717
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
18+
/** Generates a read-only recap summary of the session using the LLM. */
19+
readonly recap: (sessionID: SessionSchema.ID) => Effect.Effect<string, SessionRunner.RunError>
1820
}
1921

2022
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
@@ -30,5 +32,6 @@ export const noopLayer = Layer.succeed(
3032
resume: () => Effect.void,
3133
wake: () => Effect.void,
3234
interrupt: () => Effect.void,
35+
recap: () => Effect.succeed(""),
3336
}),
3437
)

packages/core/src/session/execution/local.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ const layer = Layer.effect(
3333
interrupt: coordinator.interrupt,
3434
resume: coordinator.run,
3535
wake: coordinator.wake,
36+
recap: Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
37+
const session = yield* store.get(sessionID)
38+
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
39+
return yield* SessionRunner.Service.use((runner) => runner.recap(sessionID)).pipe(
40+
Effect.provide(locations.get(session.location)),
41+
Effect.tapCause((cause) => Effect.logError("Failed to recap Session", cause)),
42+
)
43+
}),
3644
})
3745
}),
3846
)

packages/core/src/session/runner/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface Interface {
2323
readonly sessionID: SessionSchema.ID
2424
readonly force: boolean
2525
}) => Effect.Effect<void, RunError>
26+
/** Generates a summary string of the session's current context without mutating anything. */
27+
readonly recap: (sessionID: SessionSchema.ID) => Effect.Effect<string, RunError>
2628
}
2729

2830
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunner") {}

0 commit comments

Comments
 (0)