sessions: add optional session bifurcation#339
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces infrastructure for long-lived sessions by (1) expanding retrieval/tool scoping to a session “family” (multiple conversation segments), (2) adding opt-in automatic session bifurcation (archive + new active segment) based on thresholds, and (3) adding “doctor cleaners” diagnostics/apply flows for high-confidence junk cleanup.
Changes:
- Add conversation-family-aware scoping (
conversationIds) across grep/describe/expand-query and the underlying message/summary search layers. - Add
sessionBifurcationconfig (env + plugin config parsing) and implement automatic session rotation duringafterTurn. - Add
/lcm doctor cleanersand/lcm doctor cleaners applyplus tests.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/db/config.ts |
Adds sessionBifurcation config parsing and defaults. |
src/engine.ts |
Implements automatic rotation (archive + create) during afterTurn. |
src/store/conversation-store.ts |
Adds conversationIds search support, family lookup, and segment stats. |
src/store/summary-store.ts |
Adds conversationIds search support across summary query paths. |
src/retrieval.ts |
Plumbs conversationIds through RetrievalEngine.grep. |
src/tools/lcm-conversation-scope.ts |
Resolves session scope to a family (conversationIds) when available. |
src/tools/lcm-grep-tool.ts |
Passes conversationIds and updates scope text for families. |
src/tools/lcm-describe-tool.ts |
Enforces family scope for describe, updates error message. |
src/tools/lcm-expand-query-tool.ts |
Supports family grep + source conversation selection changes. |
src/plugin/lcm-doctor-cleaners.ts |
New cleaner scan/apply implementation with backup-first deletion. |
src/plugin/lcm-command.ts |
Adds command parsing + output for doctor cleaners scan/apply. |
test/config.test.ts |
Adds config parsing tests for sessionBifurcation. |
test/engine.test.ts |
Adds bifurcation behavior tests (threshold + cron exclusion). |
test/lcm-tools.test.ts |
Adds tests for family-scoped grep and updated describe behavior. |
test/lcm-expand-query-tool.test.ts |
Adds test for picking a single source conversation from family scope. |
test/lcm-command.test.ts |
Adds tests for doctor cleaners scan + apply workflows. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
cdd09da to
666a7ad
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
666a7ad to
72c098d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
72c098d to
e2a0fad
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
e2a0fad to
934bf15
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
934bf15 to
9845556
Compare
9845556 to
b24cec3
Compare
|
Post- |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function appendConversationScopeConstraint(params: { | ||
| where: string[]; | ||
| args: Array<string | number>; | ||
| columnExpr: string; | ||
| conversationId?: number; | ||
| conversationIds?: number[]; | ||
| }): void { | ||
| const normalizedConversationIds = [...new Set( | ||
| (params.conversationIds ?? []) | ||
| .filter((value) => Number.isFinite(value)) | ||
| .map((value) => Math.trunc(value)), | ||
| )]; | ||
| if (normalizedConversationIds.length > 0) { | ||
| if (normalizedConversationIds.length === 1) { | ||
| params.where.push(`${params.columnExpr} = ?`); | ||
| params.args.push(normalizedConversationIds[0]!); | ||
| return; | ||
| } | ||
| params.where.push( | ||
| `${params.columnExpr} IN (${normalizedConversationIds.map(() => "?").join(", ")})`, | ||
| ); | ||
| params.args.push(...normalizedConversationIds); | ||
| return; | ||
| } | ||
| if (params.conversationId != null) { | ||
| params.where.push(`${params.columnExpr} = ?`); | ||
| params.args.push(params.conversationId); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
appendConversationScopeConstraint is imported at the top of this module and then re-declared as a local function. In an ES module/TypeScript this is a duplicate identifier and will fail to compile. Remove the local declaration (and use the shared helper in src/store/conversation-scope.ts), or alias one of them so there’s only a single binding.
| function appendConversationScopeConstraint(params: { | |
| where: string[]; | |
| args: Array<string | number>; | |
| columnExpr: string; | |
| conversationId?: number; | |
| conversationIds?: number[]; | |
| }): void { | |
| const normalizedConversationIds = [...new Set( | |
| (params.conversationIds ?? []) | |
| .filter((value) => Number.isFinite(value)) | |
| .map((value) => Math.trunc(value)), | |
| )]; | |
| if (normalizedConversationIds.length > 0) { | |
| if (normalizedConversationIds.length === 1) { | |
| params.where.push(`${params.columnExpr} = ?`); | |
| params.args.push(normalizedConversationIds[0]!); | |
| return; | |
| } | |
| params.where.push( | |
| `${params.columnExpr} IN (${normalizedConversationIds.map(() => "?").join(", ")})`, | |
| ); | |
| params.args.push(...normalizedConversationIds); | |
| return; | |
| } | |
| if (params.conversationId != null) { | |
| params.where.push(`${params.columnExpr} = ?`); | |
| params.args.push(params.conversationId); | |
| } | |
| } |
| function appendConversationScopeConstraint(params: { | ||
| where: string[]; | ||
| args: Array<string | number>; | ||
| columnExpr: string; | ||
| conversationId?: ConversationId; | ||
| conversationIds?: ConversationId[]; | ||
| }): void { | ||
| const normalizedConversationIds = [...new Set( | ||
| (params.conversationIds ?? []) | ||
| .filter((value) => Number.isFinite(value)) | ||
| .map((value) => Math.trunc(value)), | ||
| )]; | ||
| if (normalizedConversationIds.length > 0) { | ||
| if (normalizedConversationIds.length === 1) { | ||
| params.where.push(`${params.columnExpr} = ?`); | ||
| params.args.push(normalizedConversationIds[0]!); | ||
| return; | ||
| } | ||
| params.where.push( | ||
| `${params.columnExpr} IN (${normalizedConversationIds.map(() => "?").join(", ")})`, | ||
| ); | ||
| params.args.push(...normalizedConversationIds); | ||
| return; | ||
| } | ||
|
|
||
| if (params.conversationId != null) { | ||
| params.where.push(`${params.columnExpr} = ?`); | ||
| params.args.push(params.conversationId); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
appendConversationScopeConstraint is imported from ./conversation-scope.js and also re-declared locally in this file. That duplicate binding will cause a compile-time error. Remove the local helper and use the shared appendConversationScopeConstraint implementation (or alias the import) to avoid the duplicate identifier.
| function appendConversationScopeConstraint(params: { | |
| where: string[]; | |
| args: Array<string | number>; | |
| columnExpr: string; | |
| conversationId?: ConversationId; | |
| conversationIds?: ConversationId[]; | |
| }): void { | |
| const normalizedConversationIds = [...new Set( | |
| (params.conversationIds ?? []) | |
| .filter((value) => Number.isFinite(value)) | |
| .map((value) => Math.trunc(value)), | |
| )]; | |
| if (normalizedConversationIds.length > 0) { | |
| if (normalizedConversationIds.length === 1) { | |
| params.where.push(`${params.columnExpr} = ?`); | |
| params.args.push(normalizedConversationIds[0]!); | |
| return; | |
| } | |
| params.where.push( | |
| `${params.columnExpr} IN (${normalizedConversationIds.map(() => "?").join(", ")})`, | |
| ); | |
| params.args.push(...normalizedConversationIds); | |
| return; | |
| } | |
| if (params.conversationId != null) { | |
| params.where.push(`${params.columnExpr} = ?`); | |
| params.args.push(params.conversationId); | |
| } | |
| } |
| // ── Circuit breaker for compaction auth failures ── | ||
| private circuitBreakerStates = new Map<string, CircuitBreakerState>(); | ||
| private bifurcationMessageCountCache = new Map<number, number>(); | ||
|
|
There was a problem hiding this comment.
bifurcationMessageCountCache is populated for active conversations but is only cleared when bifurcation rotates a segment. When sessions end (/reset, session_end, deleted, etc.) the conversation is archived via archiveConversation(...), but the cache entry is never removed, so the map can grow unbounded over long runtimes. Clear the cache when archiving a conversation in lifecycle paths (e.g., in applySessionReplacement after archiving) and/or cap the cache size.
| /** Dynamic step-band policy for incremental leaf chunk sizing. */ | ||
| dynamicLeafChunkTokens: DynamicLeafChunkTokensConfig; | ||
| /** Optional automatic rotation of long-running sessions into archived segments. */ | ||
| sessionBifurcation: SessionBifurcationConfig; |
There was a problem hiding this comment.
sessionBifurcation was added as a required field on LcmConfig. There are still several LcmConfig object literals in the test suite that don’t define sessionBifurcation (e.g., test/expansion.test.ts, test/circuit-breaker.test.ts, test/session-operation-queues.test.ts), which will cause TypeScript type-check failures. Either update those configs to include a default sessionBifurcation block, or make the field optional in the type and normalize it in resolveLcmConfig.
| sessionBifurcation: SessionBifurcationConfig; | |
| sessionBifurcation?: SessionBifurcationConfig; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Post-v0.8.0 context
v0.8.0already shipped:This PR is now one of the two remaining long-session follow-ups, and it depends on the retrieval family abstraction in #338.
Summary
Add optional session bifurcation so very long-lived keyed sessions can rotate into archived physical segments while still behaving like one logical session to retrieval.
Why this still matters
Lossless Claw is intentionally lossless. That means “summarize harder” is not enough when a session runs for weeks or months and keeps accumulating raw history in one physical conversation row set.
Without segmentation, a forever session can become increasingly expensive to inspect, recover, and maintain even if retrieval remains logically correct.
This PR introduces a controlled storage split without asking users to manually create a new session every few days.
What this PR changes
sessionBifurcationconfig with env and plugin-config supportopenclaw.plugin.jsonHow it works
flowchart TD A[afterTurn event] --> B[Per-session queue] B --> C[Replay dedupe] C --> D{Threshold exceeded?} D -- no --> E[Ingest into current active segment] D -- yes --> F[Archive current segment] F --> G[Create or reuse fresh active segment] G --> E E --> H[Normal compaction and retrieval continue]Trigger model
This does not run on a wall-clock schedule.
It runs opportunistically on the write path:
afterTurnRotation happens only when the active keyed session exceeds a configured threshold:
User-facing scenarios
1. A forever main session keeps running for weeks
The session keeps its logical identity while storage rolls forward into new physical segments.
2. A low-traffic but old session
Age alone is not enough;
minMessagesBeforeAgeSplitprevents unnecessary rotation of tiny sessions.3. Cron and subagent lanes
These are intentionally excluded because they have different lifecycle expectations and should not silently segment under the main-session policy.
Design decisions and trade-offs
Default
mainbehavior remains unchanged.The goal is to split when storage pressure justifies it, not to create arbitrary daily slices.
Rotation is serialized with replay dedupe and ingest so concurrent
afterTurnpaths cannot race.mainafterv0.8.0.The branch now reflects shipped doctor changes and current upstream naming.
Validation
pnpm exec vitest run test/config.test.ts test/engine.test.ts test/lcm-command.test.ts test/lcm-expand-query-tool.test.ts test/lcm-tools.test.ts test/index-complete-provider-config.test.ts