@namzu/sdk 62.8.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 (88) hide show
  1. package/CHANGELOG.md +20 -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 +193 -17
  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/index.d.ts.map +1 -1
  34. package/dist/runtime/query/index.js +4 -1
  35. package/dist/runtime/query/index.js.map +1 -1
  36. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  37. package/dist/runtime/query/iteration/phases/compaction.js +15 -1
  38. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  39. package/dist/runtime/query/prepare-turn.js +10 -4
  40. package/dist/runtime/query/prepare-turn.js.map +1 -1
  41. package/dist/store/session-log/core.d.ts +31 -1
  42. package/dist/store/session-log/core.d.ts.map +1 -1
  43. package/dist/store/session-log/core.js +95 -9
  44. package/dist/store/session-log/core.js.map +1 -1
  45. package/dist/store/session-log/disk.d.ts +2 -0
  46. package/dist/store/session-log/disk.d.ts.map +1 -1
  47. package/dist/store/session-log/disk.js +37 -0
  48. package/dist/store/session-log/disk.js.map +1 -1
  49. package/dist/store/session-log/fold.d.ts +9 -1
  50. package/dist/store/session-log/fold.d.ts.map +1 -1
  51. package/dist/store/session-log/fold.js +18 -2
  52. package/dist/store/session-log/fold.js.map +1 -1
  53. package/dist/store/session-log/incremental.d.ts +8 -0
  54. package/dist/store/session-log/incremental.d.ts.map +1 -1
  55. package/dist/store/session-log/incremental.js +7 -2
  56. package/dist/store/session-log/incremental.js.map +1 -1
  57. package/dist/store/session-log/index.d.ts +1 -0
  58. package/dist/store/session-log/index.d.ts.map +1 -1
  59. package/dist/store/session-log/index.js +1 -0
  60. package/dist/store/session-log/index.js.map +1 -1
  61. package/dist/store/session-log/verified-prefix.d.ts +46 -0
  62. package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
  63. package/dist/store/session-log/verified-prefix.js +233 -0
  64. package/dist/store/session-log/verified-prefix.js.map +1 -0
  65. package/dist/types/session/records.d.ts +80 -1
  66. package/dist/types/session/records.d.ts.map +1 -1
  67. package/dist/types/session/records.js +22 -0
  68. package/dist/types/session/records.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/compaction/plan.ts +53 -11
  71. package/src/compaction/salience/minhash.ts +20 -0
  72. package/src/compaction/salience/score.ts +112 -43
  73. package/src/compaction/salience/working-set.ts +102 -31
  74. package/src/config/runtime.ts +14 -0
  75. package/src/manager/session/turn-recorder.ts +218 -15
  76. package/src/public-runtime.ts +1 -0
  77. package/src/runtime/query/cancelled-before-start.ts +1 -1
  78. package/src/runtime/query/checkpoint.ts +2 -1
  79. package/src/runtime/query/index.ts +4 -1
  80. package/src/runtime/query/iteration/phases/compaction.ts +18 -0
  81. package/src/runtime/query/prepare-turn.ts +10 -3
  82. package/src/store/session-log/core.ts +129 -10
  83. package/src/store/session-log/disk.ts +32 -0
  84. package/src/store/session-log/fold.ts +19 -2
  85. package/src/store/session-log/incremental.ts +15 -2
  86. package/src/store/session-log/index.ts +1 -0
  87. package/src/store/session-log/verified-prefix.ts +243 -0
  88. package/src/types/session/records.ts +25 -0
@@ -4,7 +4,12 @@ import { resolveTriggerThreshold } from './context-window.js'
4
4
  import { findSafeTrimIndex } from './dangling.js'
5
5
  import { buildGoal } from './salience/goal.js'
6
6
  import { scoreMessages } from './salience/score.js'
7
- import { type WorkingSetPlan, planWorkingSet } from './salience/working-set.js'
7
+ import {
8
+ type WorkingSetPlan,
9
+ hasEvictableMessage,
10
+ planNothingToShed,
11
+ planWorkingSet,
12
+ } from './salience/working-set.js'
8
13
  import { estimateMessageTokens, estimateMessagesTokens } from './token-estimate.js'
9
14
  import { clearStaleToolResults } from './tool-result-editing.js'
10
15
 
@@ -136,6 +141,33 @@ export function naiveKeepStartByTokens(messages: readonly Message[], budgetToken
136
141
  /** Where the salience pass holds the context, as a fraction of the window. */
137
142
  export const DEFAULT_SOFT_TARGET = 0.5
138
143
 
144
+ /**
145
+ * The context size, in tokens, a salience pass sheds down to: the soft
146
+ * target less the configured headroom. With no headroom (the default) this
147
+ * is the soft target, computed exactly as it always was.
148
+ */
149
+ export function salienceTargetTokens(config: CompactionConfig, windowTokens: number): number {
150
+ const soft = config.softTarget ?? DEFAULT_SOFT_TARGET
151
+ const headroom = config.softTargetHeadroom ?? 0
152
+ return Math.floor(windowTokens * (headroom > 0 ? Math.max(0, soft - headroom) : soft))
153
+ }
154
+
155
+ /**
156
+ * Whether a salience pass could shed anything from `messages` at all: false
157
+ * is exact (the pass would make no action whatever the scores are), true is
158
+ * "go and score". See {@link hasEvictableMessage}.
159
+ */
160
+ export function canShedSalienceWorkingSet(
161
+ messages: readonly Message[],
162
+ config: CompactionConfig,
163
+ ): boolean {
164
+ return hasEvictableMessage(messages, {
165
+ minToolResultChars: config.minToolResultCharsToClear,
166
+ ...(config.preserveToolResultsFrom ? { preserveTools: config.preserveToolResultsFrom } : {}),
167
+ keepRecentMessages: config.keepRecentMessages,
168
+ })
169
+ }
170
+
139
171
  export function planSalienceWorkingSet(input: {
140
172
  readonly messages: readonly Message[]
141
173
  readonly config: CompactionConfig
@@ -146,19 +178,29 @@ export function planSalienceWorkingSet(input: {
146
178
  readonly openTasks?: readonly string[]
147
179
  }): Extract<CompactionPlan, { kind: 'cleared' }> & { readonly working: WorkingSetPlan } {
148
180
  const { config, estimatedTokens, contextWindowTokens: budget } = input
149
- const goal = buildGoal(input.messages, {
150
- ...(input.openTasks ? { openTasks: input.openTasks } : {}),
151
- })
152
- const scored = scoreMessages(input.messages, {
153
- goal,
154
- keepRecentMessages: config.keepRecentMessages,
155
- })
156
- const working = planWorkingSet(input.messages, scored, {
181
+ const workingOptions = {
157
182
  estimatedTokens,
158
- targetTokens: Math.floor(budget * (config.softTarget ?? DEFAULT_SOFT_TARGET)),
183
+ targetTokens: salienceTargetTokens(config, budget),
159
184
  minToolResultChars: config.minToolResultCharsToClear,
160
185
  ...(config.preserveToolResultsFrom ? { preserveTools: config.preserveToolResultsFrom } : {}),
161
- })
186
+ }
187
+ // Nothing the pass may shed, whatever the scores say: every tool result
188
+ // is small, cleared, an error or from a preserved tool, and every
189
+ // narration is short or already cut. Scoring every message to learn that
190
+ // is the whole cost of a pass that can do nothing, and it ran on every
191
+ // turn from the soft target until the summary trigger.
192
+ const working = canShedSalienceWorkingSet(input.messages, config)
193
+ ? planWorkingSet(
194
+ input.messages,
195
+ scoreMessages(input.messages, {
196
+ goal: buildGoal(input.messages, {
197
+ ...(input.openTasks ? { openTasks: input.openTasks } : {}),
198
+ }),
199
+ keepRecentMessages: config.keepRecentMessages,
200
+ }),
201
+ workingOptions,
202
+ )
203
+ : planNothingToShed(input.messages, workingOptions)
162
204
  return {
163
205
  kind: 'cleared',
164
206
  messages: working.messages,
@@ -48,6 +48,26 @@ export function similarity(a: Uint32Array, b: Uint32Array): number {
48
48
  return same / HASHES
49
49
  }
50
50
 
51
+ /**
52
+ * {@link similarity}, or 0 when it is below `floor`.
53
+ *
54
+ * The scorer reads a similarity below one half as no similarity at all, so
55
+ * a comparison that has already missed enough slots to land under the floor
56
+ * can stop there. For a pair that reaches the floor the answer is the same
57
+ * number `similarity` returns, slot for slot.
58
+ */
59
+ export function similarityAtLeast(a: Uint32Array, b: Uint32Array, floor: number): number {
60
+ const allowedMisses = HASHES - Math.ceil(floor * HASHES)
61
+ let misses = 0
62
+ for (let slot = 0; slot < HASHES; slot += 1) {
63
+ if (a[slot] !== b[slot]) {
64
+ misses += 1
65
+ if (misses > allowedMisses) return 0
66
+ }
67
+ }
68
+ return (HASHES - misses) / HASHES
69
+ }
70
+
51
71
  export function isEmptySignature(signature: Uint32Array): boolean {
52
72
  return signature.every((h) => h === 0xffffffff)
53
73
  }
@@ -26,7 +26,7 @@ import type { AssistantMessage, Message, ToolMessage } from '../../types/message
26
26
  import { findRetainedIndices } from '../retention.js'
27
27
  import { estimateMessageTokens } from '../token-estimate.js'
28
28
  import { type Bm25Document, bm25Score, buildIndex, indexDocument } from './bm25.js'
29
- import { isEmptySignature, minhash, similarity } from './minhash.js'
29
+ import { isEmptySignature, minhash, similarityAtLeast } from './minhash.js'
30
30
  import { tokenize } from './tokenize.js'
31
31
 
32
32
  export interface SalienceWeights {
@@ -119,6 +119,54 @@ function citable(tokens: readonly string[]): Set<string> {
119
119
  return out
120
120
  }
121
121
 
122
+ /**
123
+ * What the scorer derives from one message's text, and nothing that depends
124
+ * on the rest of the history: the term frequencies BM25 reads, the
125
+ * identifiers a later turn could cite, the MinHash signature and a key for
126
+ * "byte-identical signature".
127
+ *
128
+ * A pass scored every message from its raw text every time, and the text of
129
+ * a message that is already in the history does not change between passes.
130
+ * The cost of a pass was therefore proportional to every byte in the
131
+ * history, paid again on each pass, and the largest part of it was hashing
132
+ * shingles. Held per message OBJECT, a pass pays for the messages it has not
133
+ * seen and for the arithmetic that does depend on the whole history (the
134
+ * corpus statistics, recency, the redundancy comparison).
135
+ *
136
+ * Keyed by object identity and validated by the text it was computed from: a
137
+ * message that is replaced (a stub is a copy) is a different object, and one
138
+ * that is edited in place fails the text comparison, so a stale entry is
139
+ * never read. Weakly held: an entry goes with its message.
140
+ */
141
+ interface MessageFeatures {
142
+ readonly text: string
143
+ readonly tokenCount: number
144
+ readonly document: Bm25Document
145
+ readonly citable: ReadonlySet<string>
146
+ readonly signature: Uint32Array
147
+ /** Equal keys mean equal signatures, so a similarity of exactly 1. */
148
+ readonly signatureKey: string
149
+ }
150
+
151
+ const FEATURES = new WeakMap<Message, MessageFeatures>()
152
+
153
+ function featuresOf(message: Message, text: string): MessageFeatures {
154
+ const cached = FEATURES.get(message)
155
+ if (cached !== undefined && cached.text === text) return cached
156
+ const tokens = tokenize(text)
157
+ const signature = minhash(tokens)
158
+ const features: MessageFeatures = {
159
+ text,
160
+ tokenCount: tokens.length,
161
+ document: indexDocument(text, tokens),
162
+ citable: citable(tokens),
163
+ signature,
164
+ signatureKey: String.fromCharCode(...new Uint16Array(signature.buffer)),
165
+ }
166
+ FEATURES.set(message, features)
167
+ return features
168
+ }
169
+
122
170
  export function scoreMessages(
123
171
  messages: readonly Message[],
124
172
  options: ScoreOptions,
@@ -129,12 +177,9 @@ export function scoreMessages(
129
177
  weights: { ...DEFAULT_SALIENCE_WEIGHTS, ...options.config?.weights },
130
178
  }
131
179
  const n = messages.length
132
- const texts = messages.map(messageText)
180
+ const features = messages.map((message) => featuresOf(message, messageText(message)))
133
181
  const richContent = messages.map(hasRichContent)
134
- const tokenLists = texts.map(tokenize)
135
- const documents: Bm25Document[] = texts.map((text, i) =>
136
- indexDocument(text, tokenLists[i] as string[]),
137
- )
182
+ const documents: Bm25Document[] = features.map((f) => f.document)
138
183
  const index = buildIndex(documents)
139
184
  const goalTokens = tokenize(options.goal)
140
185
 
@@ -156,33 +201,64 @@ export function scoreMessages(
156
201
  )
157
202
 
158
203
  // Citation index: everything a LATER assistant turn or tool input names.
159
- // Built once, suffix-wise, so each message asks "does anything after me
160
- // cite my identifiers" in one lookup.
161
- const citedAfter: Set<string>[] = new Array(n)
162
- let running = new Set<string>()
163
- for (let i = n - 1; i >= 0; i -= 1) {
164
- citedAfter[i] = running
204
+ // For each identifier, the last message that names it: a message asks
205
+ // "does anything after me cite my identifiers" as "is the last citation
206
+ // of this identifier after me". The same answer as a set of everything
207
+ // after each message, without building n sets of up to n identifiers.
208
+ const lastCited = new Map<string, number>()
209
+ for (let i = 0; i < n; i += 1) {
165
210
  const message = messages[i] as Message
166
- if (message.role === 'assistant' || message.role === 'user') {
167
- running = new Set([...running, ...citable(tokenLists[i] as string[])])
168
- }
211
+ if (message.role !== 'assistant' && message.role !== 'user') continue
212
+ for (const token of (features[i] as MessageFeatures).citable) lastCited.set(token, i)
169
213
  }
170
214
 
171
- // Redundancy: the highest similarity to any later message of the same
172
- // role, and a full demotion when a later assistant turn repeats this
173
- // message's tool call exactly.
174
- const signatures = tokenLists.map(minhash)
215
+ // A full demotion when a later assistant turn repeats this message's
216
+ // tool call exactly: for each call, the last message that makes it.
175
217
  const callKeys = messages.map((m) =>
176
218
  isAssistantWithCalls(m)
177
219
  ? (m.toolCalls ?? []).map((c) => `${c.function.name}:${c.function.arguments}`)
178
220
  : [],
179
221
  )
180
- const laterCallKeys: Set<string>[] = new Array(n)
181
- let calls = new Set<string>()
222
+ const lastCalled = new Map<string, number>()
223
+ for (let i = 0; i < n; i += 1) {
224
+ for (const key of callKeys[i] as string[]) lastCalled.set(key, i)
225
+ }
226
+
227
+ // Redundancy: the highest similarity to any later message of the same
228
+ // role. Walked from the end so the later messages of each role are the
229
+ // ones already seen; a signature identical to one of them is a
230
+ // similarity of exactly 1, the most there is, and ends the scan.
231
+ // Below the lower of one half and the duplicate threshold a similarity
232
+ // is read as none (see the redundancy step of the score), so the
233
+ // comparison need not finish it.
234
+ const floor = Math.min(0.5, config.duplicateThreshold)
235
+ const redundancyOf: number[] = new Array(n).fill(0)
236
+ const laterSignatures = new Map<Message['role'], Uint32Array[]>()
237
+ const laterKeys = new Map<Message['role'], Set<string>>()
182
238
  for (let i = n - 1; i >= 0; i -= 1) {
183
- laterCallKeys[i] = calls
184
- if ((callKeys[i] as string[]).length > 0)
185
- calls = new Set([...calls, ...(callKeys[i] as string[])])
239
+ const message = messages[i] as Message
240
+ // A caption or a repeated call cannot prove that two screenshots or
241
+ // documents contain the same evidence. The lexical scorer has no
242
+ // access to their meaning, so it must not call those messages duplicates.
243
+ if (richContent[i]) continue
244
+ const own = features[i] as MessageFeatures
245
+ const sameRole = laterSignatures.get(message.role) ?? []
246
+ const sameRoleKeys = laterKeys.get(message.role) ?? new Set<string>()
247
+ let redundancy = 0
248
+ if (!isEmptySignature(own.signature)) {
249
+ if (sameRoleKeys.has(own.signatureKey)) redundancy = 1
250
+ else {
251
+ for (const other of sameRole) {
252
+ const sim = similarityAtLeast(own.signature, other, floor)
253
+ if (sim > redundancy) redundancy = sim
254
+ }
255
+ }
256
+ }
257
+ redundancyOf[i] = redundancy
258
+ sameRole.push(own.signature)
259
+ sameRoleKeys.add(own.signatureKey)
260
+ laterSignatures.set(message.role, sameRole)
261
+ laterKeys.set(message.role, sameRoleKeys)
186
262
  }
187
263
 
188
264
  // Protection.
@@ -239,8 +315,8 @@ export function scoreMessages(
239
315
  // An instruction is used by everything after it; an acknowledgment
240
316
  // ("ok, go on") is not an instruction. The difference is whether it
241
317
  // names anything or says more than a few words.
242
- const tokens = tokenLists[i] as string[]
243
- utility = citable(tokens).size > 0 || tokens.length >= 6 ? 1 : 0.2
318
+ const own = features[i] as MessageFeatures
319
+ utility = own.citable.size > 0 || own.tokenCount >= 6 ? 1 : 0.2
244
320
  } else if (message.role === 'tool') {
245
321
  // Used is used: one identifier from this result named by a later
246
322
  // turn is the evidence, and a count would let the recency of
@@ -248,24 +324,17 @@ export function scoreMessages(
248
324
  // for. The eval that placed the account id deep in an early dump
249
325
  // and cited it later watched the salience pass clear it under a
250
326
  // thirds rule.
251
- const mine = citable(tokenLists[i] as string[])
252
- let hits = 0
253
- for (const token of mine) if ((citedAfter[i] as Set<string>).has(token)) hits += 1
254
- utility = hits > 0 ? 1 : 0
327
+ let cited = false
328
+ for (const token of (features[i] as MessageFeatures).citable) {
329
+ if ((lastCited.get(token) ?? -1) > i) {
330
+ cited = true
331
+ break
332
+ }
333
+ }
334
+ utility = cited ? 1 : 0
255
335
  } else if (isAssistantWithCalls(message)) utility = 0.5
256
336
 
257
- let redundancy = 0
258
- const signature = signatures[i] as Uint32Array
259
- // A caption or a repeated call cannot prove that two screenshots or
260
- // documents contain the same evidence. The lexical scorer has no
261
- // access to their meaning, so it must not call those messages duplicates.
262
- if (!richContent[i] && !isEmptySignature(signature)) {
263
- for (let j = i + 1; j < n; j += 1) {
264
- if (richContent[j] || (messages[j] as Message).role !== message.role) continue
265
- const sim = similarity(signature, signatures[j] as Uint32Array)
266
- if (sim > redundancy) redundancy = sim
267
- }
268
- }
337
+ let redundancy = redundancyOf[i] as number
269
338
  if (redundancy < config.duplicateThreshold) redundancy = redundancy >= 0.5 ? redundancy : 0
270
339
  else redundancy = 1
271
340
  if (message.role === 'tool' && !richContent[i]) {
@@ -279,7 +348,7 @@ export function scoreMessages(
279
348
  )
280
349
  if (call) {
281
350
  const key = `${call.function.name}:${call.function.arguments}`
282
- if ((laterCallKeys[i] as Set<string>).has(key)) redundancy = 1
351
+ if ((lastCalled.get(key) ?? -1) > i) redundancy = 1
283
352
  break
284
353
  }
285
354
  }
@@ -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