@namzu/sdk 62.7.0 → 62.9.0

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.
Files changed (147) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/compaction/plan.d.ts +12 -0
  3. package/dist/compaction/plan.d.ts.map +1 -1
  4. package/dist/compaction/plan.js +39 -11
  5. package/dist/compaction/plan.js.map +1 -1
  6. package/dist/compaction/salience/minhash.d.ts +9 -0
  7. package/dist/compaction/salience/minhash.d.ts.map +1 -1
  8. package/dist/compaction/salience/minhash.js +20 -0
  9. package/dist/compaction/salience/minhash.js.map +1 -1
  10. package/dist/compaction/salience/score.d.ts.map +1 -1
  11. package/dist/compaction/salience/score.js +87 -45
  12. package/dist/compaction/salience/score.js.map +1 -1
  13. package/dist/compaction/salience/working-set.d.ts +14 -0
  14. package/dist/compaction/salience/working-set.d.ts.map +1 -1
  15. package/dist/compaction/salience/working-set.js +84 -33
  16. package/dist/compaction/salience/working-set.js.map +1 -1
  17. package/dist/config/runtime.d.ts +2 -0
  18. package/dist/config/runtime.d.ts.map +1 -1
  19. package/dist/config/runtime.js +14 -0
  20. package/dist/config/runtime.js.map +1 -1
  21. package/dist/manager/session/turn-recorder.d.ts +25 -0
  22. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  23. package/dist/manager/session/turn-recorder.js +231 -31
  24. package/dist/manager/session/turn-recorder.js.map +1 -1
  25. package/dist/public-runtime.d.ts +1 -1
  26. package/dist/public-runtime.d.ts.map +1 -1
  27. package/dist/public-runtime.js +1 -1
  28. package/dist/public-runtime.js.map +1 -1
  29. package/dist/runtime/query/cancelled-before-start.js +1 -1
  30. package/dist/runtime/query/cancelled-before-start.js.map +1 -1
  31. package/dist/runtime/query/checkpoint.js +2 -1
  32. package/dist/runtime/query/checkpoint.js.map +1 -1
  33. package/dist/runtime/query/context.d.ts.map +1 -1
  34. package/dist/runtime/query/context.js +28 -2
  35. package/dist/runtime/query/context.js.map +1 -1
  36. package/dist/runtime/query/executor.d.ts.map +1 -1
  37. package/dist/runtime/query/executor.js +24 -2
  38. package/dist/runtime/query/executor.js.map +1 -1
  39. package/dist/runtime/query/index.d.ts.map +1 -1
  40. package/dist/runtime/query/index.js +4 -1
  41. package/dist/runtime/query/index.js.map +1 -1
  42. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  43. package/dist/runtime/query/iteration/phases/compaction.js +15 -1
  44. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  45. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  46. package/dist/runtime/query/prepare-turn.js +27 -12
  47. package/dist/runtime/query/prepare-turn.js.map +1 -1
  48. package/dist/runtime/query/session-storage.d.ts.map +1 -1
  49. package/dist/runtime/query/session-storage.js +44 -3
  50. package/dist/runtime/query/session-storage.js.map +1 -1
  51. package/dist/sandbox/file-walk.d.ts +7 -1
  52. package/dist/sandbox/file-walk.d.ts.map +1 -1
  53. package/dist/sandbox/file-walk.js +30 -4
  54. package/dist/sandbox/file-walk.js.map +1 -1
  55. package/dist/store/checkpoint/contract.d.ts +12 -0
  56. package/dist/store/checkpoint/contract.d.ts.map +1 -1
  57. package/dist/store/checkpoint/contract.js.map +1 -1
  58. package/dist/store/checkpoint/disk.d.ts.map +1 -1
  59. package/dist/store/checkpoint/disk.js +34 -2
  60. package/dist/store/checkpoint/disk.js.map +1 -1
  61. package/dist/store/checkpoint/prune.d.ts.map +1 -1
  62. package/dist/store/checkpoint/prune.js +6 -0
  63. package/dist/store/checkpoint/prune.js.map +1 -1
  64. package/dist/store/evidence/disk.js +40 -3
  65. package/dist/store/evidence/disk.js.map +1 -1
  66. package/dist/store/session-log/core.d.ts +31 -1
  67. package/dist/store/session-log/core.d.ts.map +1 -1
  68. package/dist/store/session-log/core.js +95 -9
  69. package/dist/store/session-log/core.js.map +1 -1
  70. package/dist/store/session-log/disk.d.ts +2 -0
  71. package/dist/store/session-log/disk.d.ts.map +1 -1
  72. package/dist/store/session-log/disk.js +37 -0
  73. package/dist/store/session-log/disk.js.map +1 -1
  74. package/dist/store/session-log/fold.d.ts +9 -1
  75. package/dist/store/session-log/fold.d.ts.map +1 -1
  76. package/dist/store/session-log/fold.js +18 -2
  77. package/dist/store/session-log/fold.js.map +1 -1
  78. package/dist/store/session-log/incremental.d.ts +31 -0
  79. package/dist/store/session-log/incremental.d.ts.map +1 -0
  80. package/dist/store/session-log/incremental.js +122 -0
  81. package/dist/store/session-log/incremental.js.map +1 -0
  82. package/dist/store/session-log/index.d.ts +1 -0
  83. package/dist/store/session-log/index.d.ts.map +1 -1
  84. package/dist/store/session-log/index.js +1 -0
  85. package/dist/store/session-log/index.js.map +1 -1
  86. package/dist/store/session-log/verified-prefix.d.ts +46 -0
  87. package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
  88. package/dist/store/session-log/verified-prefix.js +233 -0
  89. package/dist/store/session-log/verified-prefix.js.map +1 -0
  90. package/dist/tools/admit-facts.d.ts.map +1 -1
  91. package/dist/tools/admit-facts.js +1 -0
  92. package/dist/tools/admit-facts.js.map +1 -1
  93. package/dist/tools/builtins/bash.d.ts.map +1 -1
  94. package/dist/tools/builtins/bash.js +10 -1
  95. package/dist/tools/builtins/bash.js.map +1 -1
  96. package/dist/tools/builtins/bounded-file-read.d.ts +19 -1
  97. package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -1
  98. package/dist/tools/builtins/bounded-file-read.js +56 -3
  99. package/dist/tools/builtins/bounded-file-read.js.map +1 -1
  100. package/dist/tools/builtins/grep.d.ts +8 -0
  101. package/dist/tools/builtins/grep.d.ts.map +1 -1
  102. package/dist/tools/builtins/grep.js +147 -20
  103. package/dist/tools/builtins/grep.js.map +1 -1
  104. package/dist/tools/builtins/search-support.d.ts +8 -0
  105. package/dist/tools/builtins/search-support.d.ts.map +1 -1
  106. package/dist/tools/builtins/search-support.js +66 -0
  107. package/dist/tools/builtins/search-support.js.map +1 -1
  108. package/dist/types/session/records.d.ts +80 -1
  109. package/dist/types/session/records.d.ts.map +1 -1
  110. package/dist/types/session/records.js +22 -0
  111. package/dist/types/session/records.js.map +1 -1
  112. package/dist/types/tool/presentation.d.ts +6 -0
  113. package/dist/types/tool/presentation.d.ts.map +1 -1
  114. package/package.json +1 -1
  115. package/src/compaction/plan.ts +53 -11
  116. package/src/compaction/salience/minhash.ts +20 -0
  117. package/src/compaction/salience/score.ts +112 -43
  118. package/src/compaction/salience/working-set.ts +102 -31
  119. package/src/config/runtime.ts +14 -0
  120. package/src/manager/session/turn-recorder.ts +259 -30
  121. package/src/public-runtime.ts +1 -0
  122. package/src/runtime/query/cancelled-before-start.ts +1 -1
  123. package/src/runtime/query/checkpoint.ts +2 -1
  124. package/src/runtime/query/context.ts +33 -6
  125. package/src/runtime/query/executor.ts +24 -2
  126. package/src/runtime/query/index.ts +4 -1
  127. package/src/runtime/query/iteration/phases/compaction.ts +18 -0
  128. package/src/runtime/query/prepare-turn.ts +26 -11
  129. package/src/runtime/query/session-storage.ts +42 -3
  130. package/src/sandbox/file-walk.ts +41 -4
  131. package/src/store/checkpoint/contract.ts +15 -0
  132. package/src/store/checkpoint/disk.ts +34 -2
  133. package/src/store/checkpoint/prune.ts +13 -0
  134. package/src/store/evidence/disk.ts +57 -2
  135. package/src/store/session-log/core.ts +129 -10
  136. package/src/store/session-log/disk.ts +32 -0
  137. package/src/store/session-log/fold.ts +19 -2
  138. package/src/store/session-log/incremental.ts +160 -0
  139. package/src/store/session-log/index.ts +1 -0
  140. package/src/store/session-log/verified-prefix.ts +243 -0
  141. package/src/tools/admit-facts.ts +1 -0
  142. package/src/tools/builtins/bash.ts +11 -1
  143. package/src/tools/builtins/bounded-file-read.ts +59 -2
  144. package/src/tools/builtins/grep.ts +172 -26
  145. package/src/tools/builtins/search-support.ts +62 -0
  146. package/src/types/session/records.ts +25 -0
  147. package/src/types/tool/presentation.ts +6 -0
@@ -73,14 +73,18 @@ export function isStubbedNarration(content: unknown): boolean {
73
73
  return typeof content === 'string' && content.includes(STUB_MARK)
74
74
  }
75
75
 
76
- export function planWorkingSet(
76
+ /** What decides whether a message is something the pass may shed at all. */
77
+ interface EvictionRules {
78
+ readonly minTool: number
79
+ readonly minNarration: number
80
+ readonly preserve: ReadonlySet<string>
81
+ readonly toolNameByCallId: ReadonlyMap<string, string>
82
+ }
83
+
84
+ function evictionRules(
77
85
  messages: readonly Message[],
78
- scored: readonly ScoredMessage[],
79
- options: WorkingSetOptions,
80
- ): WorkingSetPlan {
81
- const minTool = options.minToolResultChars ?? 1_000
82
- const minNarration = options.minNarrationChars ?? 240
83
- const preserve = new Set(options.preserveTools ?? [])
86
+ options: Pick<WorkingSetOptions, 'minToolResultChars' | 'minNarrationChars' | 'preserveTools'>,
87
+ ): EvictionRules {
84
88
  const toolNameByCallId = new Map<string, string>()
85
89
  for (const m of messages) {
86
90
  if (m.role !== 'assistant') continue
@@ -88,6 +92,87 @@ export function planWorkingSet(
88
92
  toolNameByCallId.set(call.id, call.function.name)
89
93
  }
90
94
  }
95
+ return {
96
+ minTool: options.minToolResultChars ?? 1_000,
97
+ minNarration: options.minNarrationChars ?? 240,
98
+ preserve: new Set(options.preserveTools ?? []),
99
+ toolNameByCallId,
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Whether the pass could clear or stub this message, ignoring only what
105
+ * protection decides (the floor, the recent turns, retained turns, a pair
106
+ * with a protected half) and what the score decides. The one place the
107
+ * eligibility rules live: {@link planWorkingSet} asks it of each unprotected
108
+ * message, and {@link hasEvictableMessage} asks it of every message.
109
+ */
110
+ function evictionKind(m: Message, tokens: number, rules: EvictionRules): 'clear' | 'stub' | null {
111
+ if (tokens === 0) return null
112
+ if (m.role === 'tool') {
113
+ const tool = m as ToolMessage
114
+ if (tool.isError || isClearedToolResult(tool.content)) return null
115
+ if (rules.preserve.has(rules.toolNameByCallId.get(tool.toolCallId) ?? '')) return null
116
+ if (tokens * CHARS_PER_TOKEN < rules.minTool) return null
117
+ return 'clear'
118
+ }
119
+ if (m.role === 'assistant' && !Array.isArray((m as AssistantMessage).toolCalls)) {
120
+ const text = typeof m.content === 'string' ? m.content : ''
121
+ if (text.length < rules.minNarration || isStubbedNarration(text)) return null
122
+ return 'stub'
123
+ }
124
+ return null
125
+ }
126
+
127
+ /**
128
+ * A cheap count of whether anything at all is left for the pass to shed:
129
+ * false means {@link planWorkingSet} would return no action whatever the
130
+ * scores are, so scoring can be skipped. It leaves out the two protections
131
+ * that are only a position (the leading system run and the last
132
+ * `keepRecentMessages`); retained turns, tool-call pairs and the goal floor
133
+ * are not looked at. True is therefore only "go and look", and a false is
134
+ * exact.
135
+ */
136
+ export function hasEvictableMessage(
137
+ messages: readonly Message[],
138
+ options: Pick<WorkingSetOptions, 'minToolResultChars' | 'minNarrationChars' | 'preserveTools'> & {
139
+ readonly keepRecentMessages: number
140
+ },
141
+ ): boolean {
142
+ const rules = evictionRules(messages, options)
143
+ let floorEnd = 0
144
+ while (floorEnd < messages.length && messages[floorEnd]?.role === 'system') floorEnd += 1
145
+ const end = Math.max(floorEnd, messages.length - options.keepRecentMessages)
146
+ for (let i = floorEnd; i < end; i += 1) {
147
+ const m = messages[i] as Message
148
+ if (evictionKind(m, estimateMessageTokens(m), rules) !== null) return true
149
+ }
150
+ return false
151
+ }
152
+
153
+ /** The plan of a pass that sheds nothing: every message as it was. */
154
+ export function planNothingToShed(
155
+ messages: readonly Message[],
156
+ options: Pick<WorkingSetOptions, 'estimatedTokens' | 'targetTokens'>,
157
+ ): WorkingSetPlan {
158
+ return {
159
+ messages: [...messages],
160
+ actions: [],
161
+ clearedCount: 0,
162
+ stubbedCount: 0,
163
+ charsReclaimed: 0,
164
+ reclaimedTokens: 0,
165
+ reachedTarget: options.estimatedTokens - options.targetTokens <= 0,
166
+ }
167
+ }
168
+
169
+ export function planWorkingSet(
170
+ messages: readonly Message[],
171
+ scored: readonly ScoredMessage[],
172
+ options: WorkingSetOptions,
173
+ ): WorkingSetPlan {
174
+ const rules = evictionRules(messages, options)
175
+ const { toolNameByCallId } = rules
91
176
 
92
177
  // Ordered by salience, not salience per token. Per token was the plan's
93
178
  // first answer and it evicts the wrong thing: a large result the goal
@@ -104,31 +189,17 @@ export function planWorkingSet(
104
189
  }
105
190
  const candidates: Candidate[] = []
106
191
  for (const s of scored) {
107
- if (s.protected !== null || s.tokens === 0) continue
192
+ if (s.protected !== null) continue
108
193
  const m = messages[s.index] as Message
109
- if (m.role === 'tool') {
110
- const tool = m as ToolMessage
111
- if (tool.isError || isClearedToolResult(tool.content)) continue
112
- if (preserve.has(toolNameByCallId.get(tool.toolCallId) ?? '')) continue
113
- if (s.tokens * CHARS_PER_TOKEN < minTool) continue
114
- candidates.push({
115
- index: s.index,
116
- kind: 'clear',
117
- salience: s.salience,
118
- tokens: s.tokens,
119
- goalBound: s.relevance >= 0.5 || s.utility >= 1,
120
- })
121
- } else if (m.role === 'assistant' && !Array.isArray((m as AssistantMessage).toolCalls)) {
122
- const text = typeof m.content === 'string' ? m.content : ''
123
- if (text.length < minNarration || isStubbedNarration(text)) continue
124
- candidates.push({
125
- index: s.index,
126
- kind: 'stub',
127
- salience: s.salience,
128
- tokens: s.tokens,
129
- goalBound: s.relevance >= 0.5 || s.utility >= 1,
130
- })
131
- }
194
+ const kind = evictionKind(m, s.tokens, rules)
195
+ if (kind === null) continue
196
+ candidates.push({
197
+ index: s.index,
198
+ kind,
199
+ salience: s.salience,
200
+ tokens: s.tokens,
201
+ goalBound: s.relevance >= 0.5 || s.utility >= 1,
202
+ })
132
203
  }
133
204
  candidates.sort((a, b) => a.salience - b.salience || b.tokens - a.tokens || a.index - b.index)
134
205
  const maxSalience = candidates.reduce((m, c) => Math.max(m, c.salience), Number.NEGATIVE_INFINITY)
@@ -32,6 +32,20 @@ export const CompactionConfigSchema = z.object({
32
32
  * `triggerThreshold` summarises. The context is held around here.
33
33
  */
34
34
  softTarget: z.number().min(0).max(1).optional(),
35
+ /**
36
+ * `salience` only: how far below `softTarget`, as a fraction of the
37
+ * window, a pass sheds. A pass still starts when the context reaches the
38
+ * soft target, but it then keeps shedding until the context is
39
+ * `softTargetHeadroom` of the window under it, so the next pass is needed
40
+ * only after that headroom has been used instead of on the next turn.
41
+ * Fewer passes, and the request is rewritten (a prompt cache's prefix
42
+ * broken) once per headroom instead of once per turn; the cost is that
43
+ * the context is held that much lower, so that much less history stays
44
+ * whole. The messages shed are the same ones in the same order, only
45
+ * earlier. Absent or `0`, the default, a pass sheds to the soft target
46
+ * and no further, exactly as before.
47
+ */
48
+ softTargetHeadroom: z.number().min(0).max(1).optional(),
35
49
  /**
36
50
  * Optional model context-window size (tokens) the compaction trigger
37
51
  * measures the CURRENT window against. NO default: when omitted the
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util'
1
2
  import {
2
3
  AUTO_CONTINUATION_USER_MESSAGE,
3
4
  REASONING_CUT_USER_MESSAGE,
@@ -7,6 +8,7 @@ import { NAMZU } from '../../constants/telemetry/index.js'
7
8
  import { resolveModelPricing } from '../../pricing/index.js'
8
9
  import type { SessionTokenBudget } from '../../store/budget/index.js'
9
10
  import type { SessionCheckpointStore } from '../../store/checkpoint/index.js'
11
+ import { foldSessionLog } from '../../store/session-log/incremental.js'
10
12
  import {
11
13
  type SessionLease,
12
14
  type SessionLog,
@@ -93,6 +95,16 @@ function stampMessageId(message: Message, id: MessageId | undefined): void {
93
95
  else (message as { id?: MessageId }).id = id
94
96
  }
95
97
 
98
+ /** Two messages that differ only in their id and time are the same text. */
99
+ function sameStoredContent(a: Message, b: Message): boolean {
100
+ const { id: _a, timestamp: _ta, ...restA } = a
101
+ const { id: _b, timestamp: _tb, ...restB } = b
102
+ return isDeepStrictEqual(
103
+ JSON.parse(JSON.stringify(restA)) as unknown,
104
+ JSON.parse(JSON.stringify(restB)) as unknown,
105
+ )
106
+ }
107
+
96
108
  /** How {@link TurnRecorder.open} takes the session's writer lease and starts an empty log. */
97
109
  export interface TurnRecorderOpenOptions {
98
110
  /** A lease the caller already holds. Absent: the recorder claims one and releases it at the end. */
@@ -254,6 +266,8 @@ export class TurnRecorder {
254
266
  * these needs no re-basing, so no `compaction` record is written for it.
255
267
  */
256
268
  #foldIdsAtResume: readonly MessageId[] | undefined
269
+ /** What the log folds to for a message a replacement or an edit changed, as of the resume. */
270
+ #foldEditedAtResume = new Map<MessageId, Message>()
257
271
  /** When a resumed turn began: its `turn_started` record, not this process's start. */
258
272
  #resumedTurnStartedAt: number | undefined
259
273
 
@@ -867,21 +881,38 @@ export class TurnRecorder {
867
881
  async #readResumedLog(): Promise<readonly MessageId[] | undefined> {
868
882
  await this.flush()
869
883
  const fold = new SessionMessageFold()
884
+ // Whether the latest change to a message was a compaction edit: only
885
+ // that one is the recorder's to put back (a guardrail's replacement is
886
+ // the settled answer and the restored context is not allowed to undo it).
887
+ const latestChange = new Map<MessageId, 'edited' | 'replaced'>()
870
888
  for await (const entry of this.log.read()) {
871
889
  const record = entry.record as SessionRecord
872
890
  if (record.type === 'turn_started' && record.turnId === this.turnId) {
873
891
  const at = Date.parse(record.ts)
874
892
  if (Number.isFinite(at)) this.#resumedTurnStartedAt = at
875
893
  }
894
+ if (record.type === 'message_edited') latestChange.set(record.targetMessageId, 'edited')
895
+ else if (record.type === 'message_replaced')
896
+ latestChange.set(record.targetMessageId, 'replaced')
876
897
  fold.apply(record)
877
898
  }
878
- if (fold.spilledSummary) return undefined
879
899
  const ids: MessageId[] = []
900
+ let allRecorded = !fold.spilledSummary
901
+ this.#foldEditedAtResume.clear()
902
+ // Filled for every fold entry with an id, whether or not the fold also
903
+ // holds id-less entries (a summary): a restored context older than an edit
904
+ // is put right either way. An entry whose text is spilled is not
905
+ // compared, so a message edited to a spilled text is not put back.
880
906
  for (const entry of fold.entries()) {
881
- if (!entry.messageId) return undefined
907
+ if (!entry.messageId) {
908
+ allRecorded = false
909
+ continue
910
+ }
882
911
  ids.push(entry.messageId)
912
+ if (latestChange.get(entry.messageId) === 'edited' && entry.spill === undefined)
913
+ this.#foldEditedAtResume.set(entry.messageId, entry.message)
883
914
  }
884
- return ids
915
+ return allRecorded ? ids : undefined
885
916
  }
886
917
 
887
918
  /** The recorded context is exactly what the log folds to: a resume changed nothing. */
@@ -1092,6 +1123,13 @@ export class TurnRecorder {
1092
1123
  options: {
1093
1124
  readonly messageId?: MessageId
1094
1125
  readonly transient?: boolean
1126
+ /**
1127
+ * The message is part of the history a resume restores, not a new
1128
+ * one. While the resume's re-base is pending, a restored message with no
1129
+ * id is held and nothing is reconciled; the first message that is not
1130
+ * restored (or any other reconcile) re-bases once on the whole context.
1131
+ */
1132
+ readonly restored?: boolean
1095
1133
  } = {},
1096
1134
  ): void {
1097
1135
  if (options.messageId) {
@@ -1109,6 +1147,16 @@ export class TurnRecorder {
1109
1147
  })
1110
1148
  return
1111
1149
  }
1150
+ if (options.restored && this.#rebasePending && this.#phase === 'active') {
1151
+ // A restored message with no id (a compaction summary, the working-memory
1152
+ // slot, a message the host added) can arrive before the recorded ones.
1153
+ // Reconciling now would re-base the fold on a context holding only it and
1154
+ // drop the recorded messages that follow; hold it as an id-less view entry
1155
+ // and let the first reconciliation after the whole context is in place
1156
+ // write one `compaction` that keeps the recorded ids after it.
1157
+ this.#view.push({ message })
1158
+ return
1159
+ }
1112
1160
  this.#syncMessages()
1113
1161
  }
1114
1162
 
@@ -1151,6 +1199,100 @@ export class TurnRecorder {
1151
1199
  return id
1152
1200
  }
1153
1201
 
1202
+ /**
1203
+ * A compaction pass edits one message by replacing its object with a copy
1204
+ * that has other content (`{ ...message, content }`). The copy still carries
1205
+ * the id the original was stamped with, but the recorder's own indexes are
1206
+ * keyed by object identity, so without this it would see an unknown message
1207
+ * and record every message after it again. A live object that is not in the
1208
+ * view, whose `id` is that of a recorded view entry whose own object has left
1209
+ * the context, is that same message edited: one `message_edited` record
1210
+ * carries its new content and the view entry follows the new object.
1211
+ *
1212
+ * Only an id claimed by exactly one live object is taken, so two copies of
1213
+ * one message are never both treated as its edit. Anything else is left to
1214
+ * the callers, which record it as a new message.
1215
+ */
1216
+ #absorbEdits(): void {
1217
+ if (this.#phase !== 'active') return
1218
+ const live = this.#turn.messages
1219
+ const inContext = new Set<Message>(live)
1220
+ let orphans: Map<MessageId, ViewEntry> | undefined
1221
+ for (const entry of this.#view) {
1222
+ if (entry.id === undefined || entry.transient || inContext.has(entry.message)) continue
1223
+ orphans ??= new Map()
1224
+ orphans.set(entry.id, entry)
1225
+ }
1226
+ if (orphans === undefined) return
1227
+ const known = new Set<Message>(this.#view.map((entry) => entry.message))
1228
+ const claimed = new Map<MessageId, Message[]>()
1229
+ for (const message of live) {
1230
+ if (known.has(message) || this.#transient.has(message)) continue
1231
+ const id = message.id
1232
+ if (id === undefined || !orphans.has(id)) continue
1233
+ const list = claimed.get(id)
1234
+ if (list) list.push(message)
1235
+ else claimed.set(id, [message])
1236
+ }
1237
+ const turnId = this.turnId
1238
+ for (const [id, messages] of claimed) {
1239
+ const message = messages[0] as Message
1240
+ const entry = orphans.get(id) as ViewEntry
1241
+ if (messages.length !== 1 || message.role !== entry.message.role) continue
1242
+ // `previous` is the live object the view holds: a message mutated in place
1243
+ // and then replaced by a copy of itself is adopted without a record, and
1244
+ // the fold keeps the text it recorded.
1245
+ const previous = entry.message
1246
+ entry.message = message
1247
+ this.#ids.set(message, id)
1248
+ this.#recorded.set(message, id)
1249
+ // A copy with the same text (a structured clone, a re-parsed message)
1250
+ // is adopted as is: the fold already shows that content.
1251
+ if (sameStoredContent(previous, message)) continue
1252
+ void this.#enqueue(() =>
1253
+ this.#append({
1254
+ type: 'message_edited',
1255
+ turnId,
1256
+ targetMessageId: id,
1257
+ content: message,
1258
+ ignorable: true,
1259
+ reason: 'compaction',
1260
+ }),
1261
+ ).catch(() => undefined)
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * A resume restores the context a checkpoint holds, which can predate a
1267
+ * compaction edit written after it. The ids still match, so the log is not
1268
+ * re-based; but the fold then shows the edited text of a message the context
1269
+ * holds as it was. One `message_edited` per such message makes the fold show
1270
+ * the context the resume actually runs with.
1271
+ */
1272
+ #recordDriftFromFold(): void {
1273
+ const edited = this.#foldEditedAtResume
1274
+ if (edited.size === 0) return
1275
+ const turnId = this.turnId
1276
+ for (const entry of this.#view) {
1277
+ if (entry.id === undefined || entry.transient) continue
1278
+ const folded = edited.get(entry.id)
1279
+ if (folded === undefined || sameStoredContent(folded, entry.message)) continue
1280
+ const message = entry.message
1281
+ const id = entry.id
1282
+ void this.#enqueue(() =>
1283
+ this.#append({
1284
+ type: 'message_edited',
1285
+ turnId,
1286
+ targetMessageId: id,
1287
+ content: message,
1288
+ ignorable: true,
1289
+ reason: 'resume',
1290
+ }),
1291
+ ).catch(() => undefined)
1292
+ }
1293
+ edited.clear()
1294
+ }
1295
+
1154
1296
  /**
1155
1297
  * Reconcile the context with the log. Nothing is written before the turn
1156
1298
  * begins or after it pauses or settles.
@@ -1163,9 +1305,15 @@ export class TurnRecorder {
1163
1305
  this.#foldIdsAtResume = undefined
1164
1306
  if (!unchanged) {
1165
1307
  this.#recordCompaction()
1308
+ // Kept ids whose restored text differs from the edited text the fold
1309
+ // holds get their record; entries re-recorded under fresh ids are not
1310
+ // in the map and are skipped.
1311
+ this.#recordDriftFromFold()
1166
1312
  return
1167
1313
  }
1314
+ this.#recordDriftFromFold()
1168
1315
  }
1316
+ this.#absorbEdits()
1169
1317
  const live = this.#turn.messages
1170
1318
  const view = this.#view
1171
1319
  let prefix = 0
@@ -1204,6 +1352,7 @@ export class TurnRecorder {
1204
1352
  * log order, the whole context becomes the summary.
1205
1353
  */
1206
1354
  #recordCompaction(): void {
1355
+ this.#absorbEdits()
1207
1356
  const live = this.#turn.messages
1208
1357
  const byMessage = new Map(this.#view.map((entry) => [entry.message, entry]))
1209
1358
  const next: ViewEntry[] = live.map((message) => {
@@ -1441,12 +1590,29 @@ export async function readFoldedHistory(
1441
1590
  log: SessionLog,
1442
1591
  options: { readonly throughSeq?: number } = {},
1443
1592
  ): Promise<RecordedMessage[]> {
1593
+ // Folding the whole log is advanced from the last record the previous call
1594
+ // folded, not redone from byte 0 on every turn. A fold to a seq is a
1595
+ // different question and is read in full.
1596
+ if (options.throughSeq === undefined) {
1597
+ return foldSessionLog(log, {
1598
+ name: 'folded-history',
1599
+ mode: 'strict',
1600
+ create: () => new SessionMessageFold(),
1601
+ apply: (fold, entry) => fold.apply(entry.record as SessionRecord),
1602
+ result: (fold) => recordedMessages(log, fold),
1603
+ })
1604
+ }
1444
1605
  const fold = new SessionMessageFold()
1445
- for await (const entry of log.read(
1446
- options.throughSeq !== undefined ? { throughSeq: options.throughSeq } : {},
1447
- )) {
1606
+ for await (const entry of log.read({ throughSeq: options.throughSeq })) {
1448
1607
  fold.apply(entry.record as SessionRecord)
1449
1608
  }
1609
+ return recordedMessages(log, fold)
1610
+ }
1611
+
1612
+ async function recordedMessages(
1613
+ log: SessionLog,
1614
+ fold: SessionMessageFold,
1615
+ ): Promise<RecordedMessage[]> {
1450
1616
  const out: RecordedMessage[] = []
1451
1617
  const spilled = fold.spilledSummary
1452
1618
  if (spilled) {
@@ -1463,9 +1629,10 @@ export async function readFoldedHistory(
1463
1629
  }
1464
1630
  }
1465
1631
  for (const entry of fold.entries()) {
1632
+ // A message from a shared fold is copied: the caller owns what it gets.
1466
1633
  const message = entry.spill
1467
1634
  ? (JSON.parse(await log.readSpill(entry.spill as SpillRef)) as Message)
1468
- : entry.message
1635
+ : structuredClone(entry.message)
1469
1636
  out.push({
1470
1637
  message: withMessageId(message, entry.messageId),
1471
1638
  ...(entry.messageId ? { messageId: entry.messageId } : {}),
@@ -1474,6 +1641,34 @@ export async function readFoldedHistory(
1474
1641
  return out
1475
1642
  }
1476
1643
 
1644
+ type RecordedContent = { readonly content: Message; readonly spill?: SpillRef }
1645
+
1646
+ function recordedContent(record: {
1647
+ readonly content: Message
1648
+ readonly spill?: SpillRef | undefined
1649
+ }): RecordedContent {
1650
+ return { content: record.content, ...(record.spill ? { spill: record.spill } : {}) }
1651
+ }
1652
+
1653
+ /** Every message ever recorded, and the texts an edited one held before each edit. */
1654
+ export interface EverRecordedMessages {
1655
+ /** The latest text of each message, by its durable id. */
1656
+ readonly latest: ReadonlyMap<MessageId, Message>
1657
+ /**
1658
+ * Whether `id` held `message`'s text before a compaction edit. A
1659
+ * `message_replaced` for the id forgets the texts before it: a guardrail's
1660
+ * replacement is the settled answer. A spilled earlier text is read only
1661
+ * when a comparison needs it.
1662
+ */
1663
+ heldBeforeAnEdit(id: MessageId, message: Message): Promise<boolean>
1664
+ }
1665
+
1666
+ interface EverRecordedState {
1667
+ readonly latest: Map<MessageId, RecordedContent>
1668
+ /** Only ids that have an edit appear here. */
1669
+ readonly earlier: Map<MessageId, RecordedContent[]>
1670
+ }
1671
+
1477
1672
  /**
1478
1673
  * Every message this session's log has ever recorded, keyed by the durable
1479
1674
  * id its `message` record gave it — including one a compaction has since
@@ -1488,29 +1683,63 @@ export async function readFoldedHistory(
1488
1683
  export async function readEverRecordedMessages(
1489
1684
  log: SessionLog,
1490
1685
  ): Promise<ReadonlyMap<MessageId, Message>> {
1491
- const latest = new Map<MessageId, { readonly content: Message; readonly spill?: SpillRef }>()
1492
- for await (const entry of log.read()) {
1493
- const record = entry.record as SessionRecord
1494
- if (record.type === 'message') {
1495
- latest.set(record.messageId, {
1496
- content: record.content,
1497
- ...(record.spill ? { spill: record.spill } : {}),
1498
- })
1499
- } else if (record.type === 'message_replaced') {
1500
- latest.set(record.targetMessageId, {
1501
- content: record.content,
1502
- ...(record.spill ? { spill: record.spill } : {}),
1503
- })
1504
- }
1505
- }
1506
- const resolved = new Map<MessageId, Message>()
1507
- for (const [id, entry] of latest) {
1508
- const content = entry.spill
1509
- ? (JSON.parse(await log.readSpill(entry.spill)) as Message)
1510
- : entry.content
1511
- resolved.set(id, withMessageId(content, id))
1512
- }
1513
- return resolved
1686
+ return (await readEverRecordedMessageIndex(log)).latest
1687
+ }
1688
+
1689
+ /**
1690
+ * {@link readEverRecordedMessages} plus what a host that cached the history
1691
+ * before a compaction edit still holds, answered from the same reduction with
1692
+ * no read of the log of its own.
1693
+ */
1694
+ export async function readEverRecordedMessageIndex(log: SessionLog): Promise<EverRecordedMessages> {
1695
+ return foldSessionLog(log, {
1696
+ name: 'ever-recorded-messages',
1697
+ mode: 'strict',
1698
+ create: (): EverRecordedState => ({ latest: new Map(), earlier: new Map() }),
1699
+ apply: ({ latest, earlier }, entry) => {
1700
+ const record = entry.record as SessionRecord
1701
+ if (record.type === 'message') {
1702
+ latest.set(record.messageId, recordedContent(record))
1703
+ } else if (record.type === 'message_replaced') {
1704
+ earlier.delete(record.targetMessageId)
1705
+ latest.set(record.targetMessageId, recordedContent(record))
1706
+ } else if (record.type === 'message_edited') {
1707
+ const before = latest.get(record.targetMessageId)
1708
+ if (before !== undefined) {
1709
+ const held = earlier.get(record.targetMessageId)
1710
+ if (held) held.push(before)
1711
+ else earlier.set(record.targetMessageId, [before])
1712
+ }
1713
+ latest.set(record.targetMessageId, recordedContent(record))
1714
+ }
1715
+ },
1716
+ result: async ({ latest, earlier }) => {
1717
+ const resolved = new Map<MessageId, Message>()
1718
+ for (const [id, entry] of latest) {
1719
+ // Copied: the map this is read from is shared with the next call.
1720
+ const content = entry.spill
1721
+ ? (JSON.parse(await log.readSpill(entry.spill)) as Message)
1722
+ : structuredClone(entry.content)
1723
+ resolved.set(id, withMessageId(content, id))
1724
+ }
1725
+ // A snapshot: the arrays are advanced in place by the next call. The
1726
+ // entries in them are never changed once made.
1727
+ const held = new Map<MessageId, readonly RecordedContent[]>()
1728
+ for (const [id, list] of earlier) held.set(id, [...list])
1729
+ return {
1730
+ latest: resolved,
1731
+ async heldBeforeAnEdit(id, message) {
1732
+ for (const entry of held.get(id) ?? []) {
1733
+ const content = entry.spill
1734
+ ? (JSON.parse(await log.readSpill(entry.spill)) as Message)
1735
+ : entry.content
1736
+ if (sameStoredContent(content, message)) return true
1737
+ }
1738
+ return false
1739
+ },
1740
+ }
1741
+ },
1742
+ })
1514
1743
  }
1515
1744
 
1516
1745
  /**
@@ -1775,6 +1775,7 @@ export {
1775
1775
  ExternalRefSchema,
1776
1776
  IgnorableRecordSchema,
1777
1777
  MessageRecordSchema,
1778
+ MessageEditedRecordSchema,
1778
1779
  MessageReplacedRecordSchema,
1779
1780
  OriginSchema,
1780
1781
  OutcomeProgressWrittenRecordSchema,
@@ -86,7 +86,7 @@ export async function* settlePreStartCancellation(
86
86
 
87
87
  const push = (message: Message, ids: ReadonlyMap<Message, MessageId>): void => {
88
88
  const messageId = ids.get(message)
89
- ctx.recorder.pushMessage(message, messageId ? { messageId } : {})
89
+ ctx.recorder.pushMessage(message, { ...(messageId ? { messageId } : {}), restored: true })
90
90
  }
91
91
  try {
92
92
  if (selectedResumeState) {
@@ -822,7 +822,8 @@ async function readRecordedMessage(
822
822
  }
823
823
  if (
824
824
  (entry.type === 'message' && entry.messageId === messageId) ||
825
- (entry.type === 'message_replaced' && entry.targetMessageId === messageId)
825
+ ((entry.type === 'message_replaced' || entry.type === 'message_edited') &&
826
+ entry.targetMessageId === messageId)
826
827
  ) {
827
828
  found = { content: entry.content, ...(entry.spill ? { spill: entry.spill } : {}) }
828
829
  }
@@ -92,6 +92,34 @@ export interface TurnContext {
92
92
  trackingConfig: ActivityTrackingConfig
93
93
  }
94
94
 
95
+ /**
96
+ * Forward `signal`'s abort to `controller` without keeping the controller, or
97
+ * anything the turn built around it, alive for the life of `signal`.
98
+ *
99
+ * The session's signal outlives every turn, and a listener on it is strongly
100
+ * held for as long as the signal is. A listener that closed over the turn's
101
+ * controller (and the configuration it was built from) kept each finished
102
+ * turn's whole object graph in memory, one more per turn. This one holds the
103
+ * controller through a `WeakRef`, lives in its own scope so the closure
104
+ * captures nothing else, and is removed once the controller is collected.
105
+ */
106
+ function relayAbort(signal: AbortSignal, controller: AbortController): void {
107
+ const controllerRef = new WeakRef(controller)
108
+ const signalRef = new WeakRef(signal)
109
+ const relay = (): void => {
110
+ controllerRef.deref()?.abort(signalRef.deref()?.reason)
111
+ }
112
+ signal.addEventListener('abort', relay, { once: true })
113
+ relayCleanup.register(controller, { signalRef, relay })
114
+ }
115
+
116
+ const relayCleanup = new FinalizationRegistry<{
117
+ signalRef: WeakRef<AbortSignal>
118
+ relay: () => void
119
+ }>(({ signalRef, relay }) => {
120
+ signalRef.deref()?.removeEventListener('abort', relay)
121
+ })
122
+
95
123
  export class TurnContextFactory {
96
124
  /**
97
125
  * The turn's one correlated logger, built once and handed to every
@@ -136,11 +164,7 @@ export class TurnContextFactory {
136
164
  if (config.signal.aborted) {
137
165
  abortController.abort(config.signal.reason)
138
166
  } else {
139
- config.signal.addEventListener(
140
- 'abort',
141
- () => abortController.abort(config.signal?.reason),
142
- { once: true },
143
- )
167
+ relayAbort(config.signal, abortController)
144
168
  }
145
169
  }
146
170
 
@@ -178,7 +202,10 @@ export class TurnContextFactory {
178
202
  config.enableActivityTracking,
179
203
  )
180
204
  const activityStore = new ActivityStore(config.turnId, trackingConfig)
181
- const planManager = new PlanManager({ sessionId: config.sessionId, turnId: config.turnId })
205
+ const planManager = new PlanManager({
206
+ sessionId: config.sessionId,
207
+ turnId: config.turnId,
208
+ })
182
209
  const toolResultsDir =
183
210
  config.storage.sessionDir === undefined
184
211
  ? undefined