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
constEndpoint3_7=(raw: RawClient["server.session"])=>(input: Endpoint3_7Input)=>
129129
raw["session.compact"]({params: {sessionID: input["sessionID"]}}).pipe(Effect.mapError(mapClientError))
130130

131-
typeEndpoint3_8Request=Parameters<RawClient["server.session"]["session.wait"]>[0]
131+
typeEndpoint3_8Request=Parameters<RawClient["server.session"]["session.recap"]>[0]
132132
typeEndpoint3_8Input={readonlysessionID: Endpoint3_8Request["params"]["sessionID"]}
133133
constEndpoint3_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+
typeEndpoint3_9Request=Parameters<RawClient["server.session"]["session.wait"]>[0]
140+
typeEndpoint3_9Input={readonlysessionID: Endpoint3_9Request["params"]["sessionID"]}
141+
constEndpoint3_9=(raw: RawClient["server.session"])=>(input: Endpoint3_9Input)=>
134142
raw["session.wait"]({params: {sessionID: input["sessionID"]}}).pipe(Effect.mapError(mapClientError))
135143

136-
typeEndpoint3_9Request=Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
137-
typeEndpoint3_9Input={
138-
readonlysessionID: Endpoint3_9Request["params"]["sessionID"]
139-
readonlymessageID: Endpoint3_9Request["payload"]["messageID"]
140-
readonlyfiles?: Endpoint3_9Request["payload"]["files"]
144+
typeEndpoint3_10Request=Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
145+
typeEndpoint3_10Input={
146+
readonlysessionID: Endpoint3_10Request["params"]["sessionID"]
147+
readonlymessageID: Endpoint3_10Request["payload"]["messageID"]
148+
readonlyfiles?: Endpoint3_10Request["payload"]["files"]
141149
}
142-
constEndpoint3_9=(raw: RawClient["server.session"])=>(input: Endpoint3_9Input)=>
150+
constEndpoint3_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-
typeEndpoint3_10Request=Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
152-
typeEndpoint3_10Input={readonlysessionID: Endpoint3_10Request["params"]["sessionID"]}
153-
constEndpoint3_10=(raw: RawClient["server.session"])=>(input: Endpoint3_10Input)=>
154-
raw["session.revert.clear"]({params: {sessionID: input["sessionID"]}}).pipe(Effect.mapError(mapClientError))
155-
156-
typeEndpoint3_11Request=Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
159+
typeEndpoint3_11Request=Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
157160
typeEndpoint3_11Input={readonlysessionID: Endpoint3_11Request["params"]["sessionID"]}
158161
constEndpoint3_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-
typeEndpoint3_12Request=Parameters<RawClient["server.session"]["session.context"]>[0]
164+
typeEndpoint3_12Request=Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
162165
typeEndpoint3_12Input={readonlysessionID: Endpoint3_12Request["params"]["sessionID"]}
163166
constEndpoint3_12=(raw: RawClient["server.session"])=>(input: Endpoint3_12Input)=>
167+
raw["session.revert.commit"]({params: {sessionID: input["sessionID"]}}).pipe(Effect.mapError(mapClientError))
168+
169+
typeEndpoint3_13Request=Parameters<RawClient["server.session"]["session.context"]>[0]
170+
typeEndpoint3_13Input={readonlysessionID: Endpoint3_13Request["params"]["sessionID"]}
171+
constEndpoint3_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-
typeEndpoint3_13Request=Parameters<RawClient["server.session"]["session.history"]>[0]
170-
typeEndpoint3_13Input={
171-
readonlysessionID: Endpoint3_13Request["params"]["sessionID"]
172-
readonlylimit?: Endpoint3_13Request["query"]["limit"]
173-
readonlyafter?: Endpoint3_13Request["query"]["after"]
177+
typeEndpoint3_14Request=Parameters<RawClient["server.session"]["session.history"]>[0]
178+
typeEndpoint3_14Input={
179+
readonlysessionID: Endpoint3_14Request["params"]["sessionID"]
180+
readonlylimit?: Endpoint3_14Request["query"]["limit"]
181+
readonlyafter?: Endpoint3_14Request["query"]["after"]
174182
}
175-
constEndpoint3_13=(raw: RawClient["server.session"])=>(input: Endpoint3_13Input)=>
183+
constEndpoint3_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-
typeEndpoint3_14Request=Parameters<RawClient["server.session"]["session.events"]>[0]
182-
typeEndpoint3_14Input={
183-
readonlysessionID: Endpoint3_14Request["params"]["sessionID"]
184-
readonlyafter?: Endpoint3_14Request["query"]["after"]
189+
typeEndpoint3_15Request=Parameters<RawClient["server.session"]["session.events"]>[0]
190+
typeEndpoint3_15Input={
191+
readonlysessionID: Endpoint3_15Request["params"]["sessionID"]
192+
readonlyafter?: Endpoint3_15Request["query"]["after"]
185193
}
186-
constEndpoint3_14=(raw: RawClient["server.session"])=>(input: Endpoint3_14Input)=>
194+
constEndpoint3_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-
typeEndpoint3_15Request=Parameters<RawClient["server.session"]["session.interrupt"]>[0]
195-
typeEndpoint3_15Input={readonlysessionID: Endpoint3_15Request["params"]["sessionID"]}
196-
constEndpoint3_15=(raw: RawClient["server.session"])=>(input: Endpoint3_15Input)=>
202+
typeEndpoint3_16Request=Parameters<RawClient["server.session"]["session.interrupt"]>[0]
203+
typeEndpoint3_16Input={readonlysessionID: Endpoint3_16Request["params"]["sessionID"]}
204+
constEndpoint3_16=(raw: RawClient["server.session"])=>(input: Endpoint3_16Input)=>
197205
raw["session.interrupt"]({params: {sessionID: input["sessionID"]}}).pipe(Effect.mapError(mapClientError))
198206

199-
typeEndpoint3_16Request=Parameters<RawClient["server.session"]["session.message"]>[0]
200-
typeEndpoint3_16Input={
201-
readonlysessionID: Endpoint3_16Request["params"]["sessionID"]
202-
readonlymessageID: Endpoint3_16Request["params"]["messageID"]
207+
typeEndpoint3_17Request=Parameters<RawClient["server.session"]["session.message"]>[0]
208+
typeEndpoint3_17Input={
209+
readonlysessionID: Endpoint3_17Request["params"]["sessionID"]
210+
readonlymessageID: Endpoint3_17Request["params"]["messageID"]
203211
}
204-
constEndpoint3_16=(raw: RawClient["server.session"])=>(input: Endpoint3_16Input)=>
212+
constEndpoint3_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
typeEndpoint4_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<{readonlydata: 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
exporttypeSessionsCompactOutput=void
488488

489+
exporttypeSessionsRecapInput={readonlysessionID: {readonlysessionID: string}["sessionID"]}
490+
491+
exporttypeSessionsRecapOutput={readonlydata: string}["data"]
492+
489493
exporttypeSessionsWaitInput={readonlysessionID: {readonlysessionID: string}["sessionID"]}
490494

491495
exporttypeSessionsWaitOutput=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
exportclassOperationUnavailableErrorextendsSchema.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
readonlycompact: (input: CompactInput)=>Effect.Effect<void,NotFoundError|OperationUnavailableError>
167+
readonlyrecap: (sessionID: SessionSchema.ID)=>Effect.Effect<string,NotFoundError|OperationUnavailableError>
167168
readonlywait: (id: SessionSchema.ID)=>Effect.Effect<void,NotFoundError|OperationUnavailableError>
168169
readonlyactive: Effect.Effect<ReadonlySet<SessionSchema.ID>>
169170
readonlyresume: (sessionID: SessionSchema.ID)=>Effect.Effect<void,NotFoundError|SessionRunner.RunError>
@@ -418,6 +419,12 @@ const layer = Layer.effect(
418419
yield*result.get(input.sessionID)
419420
returnyield*newOperationUnavailableError({operation: "compact"})
420421
}),
422+
recap: Effect.fn("V2Session.recap")(function*(sessionID){
423+
yield*result.get(sessionID)
424+
returnyield*execution.recap(sessionID).pipe(
425+
Effect.catchCause((defect)=>Effect.logError("RECAP ERROR",defect).pipe(Effect.andThen(Effect.fail(newOperationUnavailableError({operation: "recap"})))))
426+
)
427+
}),
421428
wait: Effect.fn("V2Session.wait")(function*(sessionID){
422429
yield*result.get(sessionID)
423430
returnyield*newOperationUnavailableError({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
returnfalse
235235
returnyield*compactAfterOverflow(input)
236236
})
237+
constgenerateSummaryFromText=Effect.fn("SessionCompaction.generateSummaryFromText")(function*(input: {
238+
readonlycontextText: string
239+
readonlymodel: Model
240+
}){
241+
if(!input.contextText.trim())return"No sufficient context for a summary."
242+
constsummaryPrompt=`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+
constsummaryOutput=Math.min(input.model.route.defaults.limits?.output||SUMMARY_OUTPUT_TOKENS,SUMMARY_OUTPUT_TOKENS)
244+
245+
constchunks: string[]=[]
246+
letfailed=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+
returnEffect.void
261+
}),
262+
Effect.catchTag("LLM.Error",()=>Effect.void),
263+
)
264+
constsummary=chunks.join("")
265+
if(failed||!summary.trim())return"Failed to generate summary."
266+
returnsummary
267+
})
268+
constgenerateSummaryString=Effect.fn("SessionCompaction.generateSummaryString")(function*(input: {
269+
readonlysessionID: SessionSchema.ID
270+
readonlyentries: readonlyEntry[]
271+
readonlymodel: Model
272+
}){
273+
constselected=select(input.entries,config.tokens)
274+
constpreviousSummary=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+
constcontextText=[previousSummary?.type==="compaction" ? previousSummary.recent : "",selected.head,selected.recent].filter(Boolean).join("\n\n")
277+
returnyield*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
readonlywake: (sessionID: SessionSchema.ID)=>Effect.Effect<void>
1616
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
1717
readonlyinterrupt: (sessionID: SessionSchema.ID)=>Effect.Effect<void>
18+
/** Generates a read-only recap summary of the session using the LLM. */
19+
readonlyrecap: (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+
constsession=yield*store.get(sessionID)
38+
if(!session)returnyield*Effect.die(`Session not found: ${sessionID}`)
39+
returnyield*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
readonlysessionID: SessionSchema.ID
2424
readonlyforce: boolean
2525
})=>Effect.Effect<void,RunError>
26+
/** Generates a summary string of the session's current context without mutating anything. */
27+
readonlyrecap: (sessionID: SessionSchema.ID)=>Effect.Effect<string,RunError>
2628
}
2729

2830
exportclassServiceextendsContext.Service<Service,Interface>()("@opencode/v2/SessionRunner"){}

0 commit comments

Comments
 (0)