@serkanalgur/opencodev2-slim 2.0.13 → 2.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-slim",
3
- "version": "2.0.13",
3
+ "version": "2.0.15",
4
4
  "description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
5
5
  "keywords": [
6
6
  "opencode",
package/src/index.ts CHANGED
@@ -1,15 +1,26 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
- import { loadConfig, createDefaultConfig, resolveTokenLimit } from "./lib/config"
3
2
  import {
4
- loadSessionState,
5
- saveSessionState,
6
- addCompressionRecord,
7
- } from "./lib/state"
8
- import { countTokens, shouldCompress, getMessageText, getToolResultContent } from "./lib/compress"
9
- import { pruneMessages } from "./lib/strategies"
10
- import { getSystemPrompt, getCompressToolDescription, getNudgeMessage } from "./lib/prompts"
3
+ loadConfig,
4
+ createDefaultConfig,
5
+ resolveTokenLimit,
6
+ resolveCompressLimits,
7
+ } from "./lib/config"
8
+ import { loadSessionState, saveSessionState, addCompressionRecord } from "./lib/state"
9
+ import { countTokens, getMessageText, getToolResultContent } from "./lib/compress"
10
+ import {
11
+ syncCompressionBlocks,
12
+ applyCompressedRanges,
13
+ registerCompressionBlock,
14
+ buildCompressionSummary,
15
+ purgeStaleToolErrors,
16
+ pruneInPlace,
17
+ injectLimitNudges,
18
+ findLastUserMessage,
19
+ autoCompress,
20
+ } from "./lib/strategies"
21
+ import { getSystemPrompt, getCompressToolDescription } from "./lib/prompts"
11
22
  import { buildPanelData, renderPanel } from "./lib/tui"
12
- import type { SlimConfig, SessionState, MessageWithParts } from "./lib/types"
23
+ import type { SlimConfig, SessionState, MessageWithParts, CompressionBlock } from "./lib/types"
13
24
 
14
25
  // ─── State Management ───────────────────────────────────────────────────────
15
26
 
@@ -58,70 +69,59 @@ function stringifyTranscript(v: unknown): string {
58
69
 
59
70
  // ─── Helpers ────────────────────────────────────────────────────────────────
60
71
 
61
- function buildCompressionSummary(messages: MessageWithParts[], focus: string): string {
62
- const lines: string[] = []
63
- lines.push(`## Compression Summary`)
64
- lines.push(`Focus: ${focus}`)
65
- lines.push(`Messages compressed: ${messages.length}`)
66
- lines.push("")
67
-
68
- const toolCalls: string[] = []
69
- const errors: string[] = []
70
- const decisions: string[] = []
71
-
72
- for (const msg of messages) {
73
- for (const part of msg.parts) {
74
- if (part.type === "tool-call") {
75
- const toolPart = part as any
76
- toolCalls.push(
77
- `${toolPart.name}: ${JSON.stringify(toolPart.input || {}).slice(0, 100)}`,
78
- )
79
- }
80
- if (part.type === "tool-result") {
81
- const toolPart = part as any
82
- if (toolPart.result?.type === "error") {
83
- errors.push(String(toolPart.result.value).slice(0, 200) || "Unknown error")
84
- }
85
- }
86
- if (part.type === "text") {
87
- const textPart = part as any
88
- const text = textPart.text || ""
89
- if (
90
- text.includes("decided") ||
91
- text.includes("chose") ||
92
- text.includes("implemented")
93
- ) {
94
- decisions.push(text.slice(0, 200))
95
- }
96
- }
97
- }
98
- }
99
-
100
- if (toolCalls.length > 0) {
101
- lines.push("### Tool Calls")
102
- toolCalls.slice(0, 10).forEach((tc) => lines.push(`- ${tc}`))
103
- lines.push("")
104
- }
105
-
106
- if (errors.length > 0) {
107
- lines.push("### Errors Encountered")
108
- errors.slice(0, 5).forEach((e) => lines.push(`- ${e}`))
109
- lines.push("")
72
+ // Register a DCP-style compression block for the selected range. The range is
73
+ // covered (removed from future outgoing requests) and the summary is injected
74
+ // at the anchor: the first message after the range, or the latest message when
75
+ // the range reaches the end (the active user turn is never replaced).
76
+ function registerBlockForRange(
77
+ state: SessionState,
78
+ topic: string,
79
+ messageWithParts: MessageWithParts[],
80
+ targetIndices: number[],
81
+ summary: string,
82
+ summaryTokens: number,
83
+ ): CompressionBlock | null {
84
+ if (targetIndices.length === 0 || messageWithParts.length === 0) return null
85
+
86
+ const sorted = [...targetIndices].sort((a, b) => a - b)
87
+ const coveredIndices = new Set(sorted)
88
+
89
+ let anchorIndex = sorted[sorted.length - 1] + 1
90
+ if (anchorIndex >= messageWithParts.length) {
91
+ anchorIndex = messageWithParts.length - 1
92
+ coveredIndices.delete(anchorIndex)
110
93
  }
111
-
112
- if (decisions.length > 0) {
113
- lines.push("### Key Decisions")
114
- decisions.slice(0, 5).forEach((d) => lines.push(`- ${d}`))
115
- lines.push("")
116
- }
117
-
118
- return lines.join("\n")
94
+ if (anchorIndex < 0 || anchorIndex >= messageWithParts.length) return null
95
+
96
+ const anchorId = messageWithParts[anchorIndex].info?.id
97
+ if (!anchorId) return null
98
+
99
+ const coveredIds = [...coveredIndices]
100
+ .map((i) => messageWithParts[i].info?.id)
101
+ .filter((id): id is string => typeof id === "string" && id.length > 0)
102
+ if (coveredIds.length === 0) return null
103
+
104
+ return registerCompressionBlock(state, {
105
+ coveredIds,
106
+ anchorMessageId: anchorId,
107
+ summary,
108
+ topic,
109
+ summaryTokens,
110
+ })
119
111
  }
120
112
 
121
113
  function wrapAsMessageWithParts(msg: any): MessageWithParts {
114
+ const msgInfo = msg.info
115
+ const id = (msg && (msg.id || msgInfo?.id)) || ""
116
+ const role = (msg && (msg.role || msgInfo?.role)) || "user"
122
117
  return {
123
- info: msg.info || { id: msg.id || "", role: msg.role, sessionID: "", time: { created: Date.now() } },
124
- parts: msg.parts || msg.content || [],
118
+ info: {
119
+ id,
120
+ role,
121
+ sessionID: (msg && (msg.sessionID || msgInfo?.sessionID)) || "",
122
+ time: { created: Date.now() },
123
+ } as any,
124
+ parts: (msg && (msg.parts || msg.content)) || [],
125
125
  }
126
126
  }
127
127
 
@@ -241,10 +241,34 @@ export default Plugin.define({
241
241
  }
242
242
 
243
243
  const targetMessages = targetIndices.map((i) => messageWithParts[i])
244
- const summary = buildCompressionSummary(targetMessages, args.focus)
244
+ const summary = await buildCompressionSummary(
245
+ targetMessages,
246
+ args.focus,
247
+ config.compress.protectedTools,
248
+ config.compress.protectUserMessages,
249
+ )
245
250
  const outputTokens = await countTokens(summary)
246
251
  const ratio = inputTokens > 0 ? 1 - outputTokens / inputTokens : 0
247
252
 
253
+ // DCP: register a compression block so future outgoing
254
+ // requests replace this range with the summary.
255
+ let blockNote = ""
256
+ try {
257
+ const block = registerBlockForRange(
258
+ state,
259
+ args.focus,
260
+ messageWithParts,
261
+ targetIndices,
262
+ summary,
263
+ outputTokens,
264
+ )
265
+ if (block) {
266
+ blockNote = `\n\n_Block #${block.blockId}: ${block.coveredMessageIds.length} messages will collapse into this summary on future requests (${Math.round((1 - outputTokens / Math.max(1, inputTokens)) * 100)}% smaller)._\n_To restore them: ask to reset context._`
267
+ }
268
+ } catch {
269
+ // Best-effort: the summary is still returned to the model.
270
+ }
271
+
248
272
  addCompressionRecord(
249
273
  state,
250
274
  {
@@ -261,7 +285,7 @@ export default Plugin.define({
261
285
  saveSessionState(state, config.persistence.directory)
262
286
 
263
287
  return {
264
- content: `## Compressed ${targetMessages.length} messages\n\n${summary}\n\n---\n**Stats:** ${inputTokens} → ${outputTokens} tokens (${Math.round(ratio * 100)}% saved) | Mode: ${mode} | Focus: ${args.focus}`,
288
+ content: `## Compressed ${targetMessages.length} messages\n\n${summary}\n\n---\n**Stats:** ${inputTokens} → ${outputTokens} tokens (${Math.round(ratio * 100)}% saved) | Mode: ${mode} | Focus: ${args.focus}${blockNote}`,
265
289
  }
266
290
  } catch (error) {
267
291
  return {
@@ -362,8 +386,12 @@ export default Plugin.define({
362
386
  event.system.push({ type: "text", text: getSystemPrompt() })
363
387
  })
364
388
 
365
- // ─── Messages Transform Hook (sync) ──────────────────────────────
366
- await ctx.session.hook("context", (event) => {
389
+ // ─── Messages Transform Hook (sync → async) ─────────────────────────
390
+ // DCP pipeline for every outgoing request: sync compression blocks,
391
+ // replace covered ranges with summary placeholders, prune (dedup +
392
+ // purge errored tool inputs), then apply DCP limit rules as anchored
393
+ // nudges. Session history is never modified — only this request.
394
+ await ctx.session.hook("context", async (event) => {
367
395
  const sessionId = event.sessionID
368
396
  const config = getConfig(sessionId)
369
397
  if (!config.enabled) return
@@ -371,71 +399,59 @@ export default Plugin.define({
371
399
  const state = getState(sessionId, config)
372
400
  state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
373
401
 
374
- // Apply pruning - work with original OpenCode message format
375
- // event.messages contains { role, content: Part[], ... } objects
376
- const wrapped = event.messages.map((m: any) => wrapAsMessageWithParts(m))
377
- const pruned = pruneMessages(wrapped, config, event.messages.length)
378
-
379
- // Build a Set of pruned message IDs to keep
380
- const keepIds = new Set(pruned.map((m) => m.info.id))
381
-
382
- // Remove duplicates in-place, preserving OpenCode's message format
383
- for (let i = event.messages.length - 1; i >= 0; i--) {
384
- const msg = event.messages[i] as any
385
- const id = msg.id || msg.info?.id
386
- if (id && !keepIds.has(id)) {
387
- event.messages.splice(i, 1)
388
- }
402
+ // 1) Compression blocks: activate/deactivate and replace ranges.
403
+ const presentIds = new Set<string>()
404
+ for (const msg of event.messages) {
405
+ const id = (msg as any)?.id ?? (msg as any)?.info?.id
406
+ if (typeof id === "string") presentIds.add(id)
407
+ }
408
+ syncCompressionBlocks(state, presentIds)
409
+ const filtered = applyCompressedRanges(state, event.messages)
410
+ event.messages.splice(0, event.messages.length, ...filtered)
411
+
412
+ // 2) Pruning strategies (each request).
413
+ pruneInPlace(event.messages, config)
414
+ if (config.strategies.purgeErrors.enabled) {
415
+ purgeStaleToolErrors(event.messages, config.strategies.purgeErrors.turns)
389
416
  }
390
417
 
391
- // Quick token estimate (sync, ~4 chars per token). On the first pass
392
- // (before the panel has written real numbers into state) this is a
393
- // fallback; afterwards we prefer the server-measured value.
418
+ // 3) Token accounting: prefer the server-measured count; fall back
419
+ // to a quick estimate (~4 chars per token).
394
420
  let estimatedTokens = 0
395
421
  for (const msg of event.messages) {
396
- const content = (msg as any).content
422
+ const content = (msg as any)?.content ?? (msg as any)?.parts
397
423
  if (Array.isArray(content)) {
398
424
  for (const part of content) {
399
- if (part.type === "text" && part.text) {
425
+ if (part?.type === "text" && part.text) {
400
426
  estimatedTokens += Math.ceil(part.text.length / 4)
401
427
  }
402
428
  }
403
429
  }
404
430
  }
405
-
406
- // Prefer the real measured token count when available; else the estimate.
407
431
  const totalTokens =
408
432
  state.currentTokenCount > 0 ? state.currentTokenCount : estimatedTokens
409
433
  state.currentTokenCount = totalTokens
410
434
 
411
- const maxTokens = resolveTokenLimit(
412
- config.compress.maxContextLimit,
413
- state.modelContextLimit,
414
- )
415
- const minTokens = resolveTokenLimit(
416
- config.compress.minContextLimit,
417
- state.modelContextLimit,
418
- )
419
-
420
- const shouldComp = shouldCompress(
421
- totalTokens,
422
- maxTokens,
423
- minTokens,
424
- state.lastCompressionTime,
425
- config.compress.nudgeFrequency,
426
- event.messages.length,
427
- )
428
-
429
- if (shouldComp.compress && !state.manualMode) {
430
- const nudgeMessage = getNudgeMessage(
431
- shouldComp.reason,
432
- totalTokens,
433
- maxTokens,
434
- )
435
- event.messages.push({
436
- role: "assistant",
437
- content: [{ type: "text", text: nudgeMessage }],
438
- } as any)
435
+ // 4) DCP limit rules → anchored nudges (max 100k / min 50k by
436
+ // default, model overrides supported via modelMax/MinLimits).
437
+ const lastUser = findLastUserMessage(event.messages)
438
+ const providerId =
439
+ lastUser?.model?.providerID ?? lastUser?.model?.id?.split?.("/")[0]
440
+ const modelId =
441
+ lastUser?.model?.modelID ??
442
+ lastUser?.model?.id?.split?.("/").slice(1).join("/")
443
+ const limits = resolveCompressLimits(config, state, providerId, modelId)
444
+ injectLimitNudges(state, config, event.messages, totalTokens, limits)
445
+
446
+ // 5) Auto-compress: when over the max limit, directly compress old
447
+ // messages without waiting for the model to call the compress tool.
448
+ // Registers a compression block so future requests use the summary.
449
+ if (totalTokens > limits.max) {
450
+ try {
451
+ await autoCompress(state, config, event.messages, totalTokens, limits)
452
+ } catch {
453
+ // Best-effort: auto-compress failure should never break the request.
454
+ }
439
455
  }
440
456
 
441
457
  saveSessionState(state, config.persistence.directory)
package/src/lib/config.ts CHANGED
@@ -2,19 +2,24 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs"
2
2
  import { join } from "path"
3
3
  import { homedir } from "os"
4
4
  import { parse } from "jsonc-parser/lib/esm/main.js"
5
- import type { SlimConfig } from "./types"
5
+ import type { SlimConfig, SessionState } from "./types"
6
6
 
7
7
  const DEFAULT_CONFIG: SlimConfig = {
8
8
  enabled: true,
9
9
  debug: false,
10
10
  compress: {
11
11
  enabled: true,
12
+ mode: "range",
12
13
  permission: "allow",
13
- maxContextLimit: "80%",
14
- minContextLimit: "40%",
14
+ // DCP defaults: absolute token counts (not percentages).
15
+ maxContextLimit: 100000,
16
+ minContextLimit: 50000,
15
17
  nudgeFrequency: 5,
18
+ iterationNudgeThreshold: 15,
19
+ nudgeForce: "soft",
16
20
  protectUserMessages: false,
17
21
  protectedTools: ["task", "skill", "todowrite", "todoread"],
22
+ keepRecent: 5,
18
23
  },
19
24
  strategies: {
20
25
  deduplication: {
@@ -46,7 +51,12 @@ function deepMerge(base: SlimConfig, override: Partial<SlimConfig>): SlimConfig
46
51
  return {
47
52
  ...base,
48
53
  ...override,
49
- compress: { ...base.compress, ...override.compress },
54
+ compress: {
55
+ ...base.compress,
56
+ ...override.compress,
57
+ modelMaxLimits: override.compress?.modelMaxLimits ?? base.compress.modelMaxLimits,
58
+ modelMinLimits: override.compress?.modelMinLimits ?? base.compress.modelMinLimits,
59
+ },
50
60
  strategies: {
51
61
  deduplication: { ...base.strategies.deduplication, ...override.strategies?.deduplication },
52
62
  purgeErrors: { ...base.strategies.purgeErrors, ...override.strategies?.purgeErrors },
@@ -101,14 +111,17 @@ export function createDefaultConfig(): void {
101
111
  writeFileSync(
102
112
  configPath,
103
113
  `{
104
- // Slim Configuration
114
+ // Slim Configuration (DCP-compatible limit rules)
105
115
  "enabled": true,
106
116
  "compress": {
107
117
  "enabled": true,
118
+ "mode": "range",
108
119
  "permission": "allow",
109
- "maxContextLimit": "80%",
110
- "minContextLimit": "40%",
111
- "nudgeFrequency": 5
120
+ "maxContextLimit": 100000,
121
+ "minContextLimit": 50000,
122
+ "nudgeFrequency": 5,
123
+ "iterationNudgeThreshold": 15,
124
+ "nudgeForce": "soft"
112
125
  }
113
126
  }`,
114
127
  "utf-8",
@@ -124,3 +137,39 @@ export function resolveTokenLimit(value: number | string, contextLimit: number):
124
137
  const percent = parseFloat(value.replace("%", "")) / 100
125
138
  return Math.floor(contextLimit * percent)
126
139
  }
140
+
141
+ /**
142
+ * DCP limit resolution. Prefers per-model overrides (compress.modelMinLimits /
143
+ * compress.modelMaxLimits keyed by "providerId/modelId"), then falls back to the
144
+ * global max/min limit. Percent strings resolve against the model's context window.
145
+ */
146
+ export function resolveCompressLimits(
147
+ config: SlimConfig,
148
+ state: SessionState,
149
+ providerId?: string,
150
+ modelId?: string,
151
+ ): { max: number; min: number } {
152
+ const parseLimit = (value: number | string | undefined): number => {
153
+ if (value === undefined) {
154
+ return 0
155
+ }
156
+ if (typeof value === "number") {
157
+ return value
158
+ }
159
+ const pct = parseFloat(value.replace("%", ""))
160
+ if (Number.isNaN(pct)) {
161
+ return 0
162
+ }
163
+ return Math.round((Math.max(0, Math.min(100, pct)) / 100) * state.modelContextLimit)
164
+ }
165
+
166
+ const providerModel = providerId && modelId ? `${providerId}/${modelId}` : undefined
167
+
168
+ const modelMin = providerModel ? config.compress.modelMinLimits?.[providerModel] : undefined
169
+ const modelMax = providerModel ? config.compress.modelMaxLimits?.[providerModel] : undefined
170
+
171
+ return {
172
+ max: parseLimit(modelMax ?? config.compress.maxContextLimit),
173
+ min: parseLimit(modelMin ?? config.compress.minContextLimit),
174
+ }
175
+ }
@@ -6,12 +6,17 @@ You have access to context management tools. Use them wisely:
6
6
 
7
7
  ### compress tool
8
8
  Use \`compress\` to reduce context size when it gets large. It supports:
9
+ - Range mode: Compress a specific message range (start/end indices) into one summary
10
+ - Topic mode: Compress messages matching a topic keyword
9
11
  - Auto mode: Intelligently selects what to compress
10
- - Range mode: Compress specific message range
11
- - Topic mode: Compress messages matching a topic
12
12
 
13
13
  Example: \`compress({ focus: "old exploration" })\`
14
14
 
15
+ Compressed ranges are replaced by their summary on outgoing requests, so the
16
+ model keeps the essential information while token usage drops on every
17
+ subsequent request. Protected tool results (task, skill, todowrite, todoread)
18
+ are preserved inside the summary.
19
+
15
20
  ### panel tool
16
21
  Use \`panel\` to view current context usage and statistics.
17
22
 
@@ -30,19 +35,30 @@ export function getCompressToolDescription(): string {
30
35
  - Range mode: Compress specific message range (start/end indices)
31
36
  - Topic mode: Compress messages matching a topic keyword
32
37
 
33
- The compression creates a summary preserving key information while removing redundancy.`
38
+ The compression creates a summary preserving key information (including
39
+ protected tool outputs) and replaces the selected messages with that summary on
40
+ future requests, so the context stays small.`
34
41
  }
35
42
 
36
- export function getNudgeMessage(reason: string, currentTokens: number, maxTokens: number): string {
37
- const percent = Math.round((currentTokens / maxTokens) * 100)
38
- return `💡 **Context Optimization Available**
43
+ // ─── DCP-style limit nudges ────────────────────────────────────────────────
44
+ //
45
+ // Every nudge carries a stable marker so the pipeline can detect an existing
46
+ // nudge regardless of its dynamic content (percentages change every request).
39
47
 
40
- ${reason} (${percent}% used)
48
+ export const NUDGE_MARKERS = {
49
+ contextLimit: "[[slim:context-limit]]",
50
+ turn: "[[slim:turn]]",
51
+ iteration: "[[slim:iteration]]",
52
+ } as const
41
53
 
42
- Consider using the \`compress\` tool to free up context space:
43
- \`\`\`
44
- compress({ focus: "describe what to compress" })
45
- \`\`\`
54
+ export function contextLimitNudge(percent: number, maxTokens: number): string {
55
+ return `${NUDGE_MARKERS.contextLimit}\n\n> ⚠️ **Context at capacity: ${percent}% of ${maxTokens.toLocaleString()} tokens.** Older completed work should be compressed now to keep the session efficient. Call \`compress\` with a focus describing the oldest exchange, e.g. \`compress({ focus: "initial exploration" })\`. Past ranges are replaced by summaries to avoid re-sending tokens.`
56
+ }
46
57
 
47
- This will create a summary of older messages, preserving key information while freeing tokens.`
58
+ export function turnNudge(percent: number): string {
59
+ return `${NUDGE_MARKERS.turn}\n\n> 💡 **Context is getting large (${percent}% of limit).** When you finish the current task, consider calling \`compress\` on the completed portion to keep the session fast and cheap.`
48
60
  }
61
+
62
+ export function iterationNudge(percent: number): string {
63
+ return `${NUDGE_MARKERS.iteration}\n\n> 💡 **Many tool iterations since the last user message (${percent}% of limit used).** If the explored subtree is done, call \`compress({ focus: "exploration so far" })\` to replace it with a summary.`
64
+ }
package/src/lib/state.ts CHANGED
@@ -13,6 +13,29 @@ const DEFAULT_STATE: SessionState = {
13
13
  compressionHistory: [],
14
14
  averageCompressionRatio: 0,
15
15
  toolCalls: new Map(),
16
+ compressionBlocks: [],
17
+ nextBlockId: 1,
18
+ nudges: { contextLimitAnchors: [], turnNudgeAnchors: [], iterationNudgeAnchors: [] },
19
+ }
20
+
21
+ export function normalizeState(state: SessionState): SessionState {
22
+ state.compressionBlocks = Array.isArray(state.compressionBlocks) ? state.compressionBlocks : []
23
+ state.nextBlockId =
24
+ typeof state.nextBlockId === "number" && state.nextBlockId > 0
25
+ ? state.nextBlockId
26
+ : state.compressionBlocks.reduce((max, b) => Math.max(max, b.blockId), 0) + 1
27
+ state.nudges = {
28
+ contextLimitAnchors: Array.isArray(state.nudges?.contextLimitAnchors)
29
+ ? state.nudges.contextLimitAnchors
30
+ : [],
31
+ turnNudgeAnchors: Array.isArray(state.nudges?.turnNudgeAnchors)
32
+ ? state.nudges.turnNudgeAnchors
33
+ : [],
34
+ iterationNudgeAnchors: Array.isArray(state.nudges?.iterationNudgeAnchors)
35
+ ? state.nudges.iterationNudgeAnchors
36
+ : [],
37
+ }
38
+ return state
16
39
  }
17
40
 
18
41
  export function loadSessionState(sessionId: string, persistenceDir: string): SessionState {
@@ -26,13 +49,13 @@ export function loadSessionState(sessionId: string, persistenceDir: string): Ses
26
49
  if (parsed.toolCalls && Array.isArray(parsed.toolCalls)) {
27
50
  parsed.toolCalls = new Map(parsed.toolCalls)
28
51
  }
29
- return { ...DEFAULT_STATE, ...parsed, sessionId }
52
+ return normalizeState({ ...DEFAULT_STATE, ...parsed, sessionId })
30
53
  } catch {
31
54
  // Use default
32
55
  }
33
56
  }
34
57
 
35
- return { ...DEFAULT_STATE, sessionId }
58
+ return normalizeState({ ...DEFAULT_STATE, sessionId })
36
59
  }
37
60
 
38
61
  export function saveSessionState(state: SessionState, persistenceDir: string): void {