@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.
- package/CHANGELOG.md +33 -0
- package/dist/compaction/plan.d.ts +12 -0
- package/dist/compaction/plan.d.ts.map +1 -1
- package/dist/compaction/plan.js +39 -11
- package/dist/compaction/plan.js.map +1 -1
- package/dist/compaction/salience/minhash.d.ts +9 -0
- package/dist/compaction/salience/minhash.d.ts.map +1 -1
- package/dist/compaction/salience/minhash.js +20 -0
- package/dist/compaction/salience/minhash.js.map +1 -1
- package/dist/compaction/salience/score.d.ts.map +1 -1
- package/dist/compaction/salience/score.js +87 -45
- package/dist/compaction/salience/score.js.map +1 -1
- package/dist/compaction/salience/working-set.d.ts +14 -0
- package/dist/compaction/salience/working-set.d.ts.map +1 -1
- package/dist/compaction/salience/working-set.js +84 -33
- package/dist/compaction/salience/working-set.js.map +1 -1
- package/dist/config/runtime.d.ts +2 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +14 -0
- package/dist/config/runtime.js.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts +25 -0
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +231 -31
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/cancelled-before-start.js +1 -1
- package/dist/runtime/query/cancelled-before-start.js.map +1 -1
- package/dist/runtime/query/checkpoint.js +2 -1
- package/dist/runtime/query/checkpoint.js.map +1 -1
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +28 -2
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +24 -2
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +4 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +15 -1
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
- package/dist/runtime/query/prepare-turn.js +27 -12
- package/dist/runtime/query/prepare-turn.js.map +1 -1
- package/dist/runtime/query/session-storage.d.ts.map +1 -1
- package/dist/runtime/query/session-storage.js +44 -3
- package/dist/runtime/query/session-storage.js.map +1 -1
- package/dist/sandbox/file-walk.d.ts +7 -1
- package/dist/sandbox/file-walk.d.ts.map +1 -1
- package/dist/sandbox/file-walk.js +30 -4
- package/dist/sandbox/file-walk.js.map +1 -1
- package/dist/store/checkpoint/contract.d.ts +12 -0
- package/dist/store/checkpoint/contract.d.ts.map +1 -1
- package/dist/store/checkpoint/contract.js.map +1 -1
- package/dist/store/checkpoint/disk.d.ts.map +1 -1
- package/dist/store/checkpoint/disk.js +34 -2
- package/dist/store/checkpoint/disk.js.map +1 -1
- package/dist/store/checkpoint/prune.d.ts.map +1 -1
- package/dist/store/checkpoint/prune.js +6 -0
- package/dist/store/checkpoint/prune.js.map +1 -1
- package/dist/store/evidence/disk.js +40 -3
- package/dist/store/evidence/disk.js.map +1 -1
- package/dist/store/session-log/core.d.ts +31 -1
- package/dist/store/session-log/core.d.ts.map +1 -1
- package/dist/store/session-log/core.js +95 -9
- package/dist/store/session-log/core.js.map +1 -1
- package/dist/store/session-log/disk.d.ts +2 -0
- package/dist/store/session-log/disk.d.ts.map +1 -1
- package/dist/store/session-log/disk.js +37 -0
- package/dist/store/session-log/disk.js.map +1 -1
- package/dist/store/session-log/fold.d.ts +9 -1
- package/dist/store/session-log/fold.d.ts.map +1 -1
- package/dist/store/session-log/fold.js +18 -2
- package/dist/store/session-log/fold.js.map +1 -1
- package/dist/store/session-log/incremental.d.ts +31 -0
- package/dist/store/session-log/incremental.d.ts.map +1 -0
- package/dist/store/session-log/incremental.js +122 -0
- package/dist/store/session-log/incremental.js.map +1 -0
- package/dist/store/session-log/index.d.ts +1 -0
- package/dist/store/session-log/index.d.ts.map +1 -1
- package/dist/store/session-log/index.js +1 -0
- package/dist/store/session-log/index.js.map +1 -1
- package/dist/store/session-log/verified-prefix.d.ts +46 -0
- package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
- package/dist/store/session-log/verified-prefix.js +233 -0
- package/dist/store/session-log/verified-prefix.js.map +1 -0
- package/dist/tools/admit-facts.d.ts.map +1 -1
- package/dist/tools/admit-facts.js +1 -0
- package/dist/tools/admit-facts.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +10 -1
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/bounded-file-read.d.ts +19 -1
- package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -1
- package/dist/tools/builtins/bounded-file-read.js +56 -3
- package/dist/tools/builtins/bounded-file-read.js.map +1 -1
- package/dist/tools/builtins/grep.d.ts +8 -0
- package/dist/tools/builtins/grep.d.ts.map +1 -1
- package/dist/tools/builtins/grep.js +147 -20
- package/dist/tools/builtins/grep.js.map +1 -1
- package/dist/tools/builtins/search-support.d.ts +8 -0
- package/dist/tools/builtins/search-support.d.ts.map +1 -1
- package/dist/tools/builtins/search-support.js +66 -0
- package/dist/tools/builtins/search-support.js.map +1 -1
- package/dist/types/session/records.d.ts +80 -1
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +22 -0
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/tool/presentation.d.ts +6 -0
- package/dist/types/tool/presentation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/compaction/plan.ts +53 -11
- package/src/compaction/salience/minhash.ts +20 -0
- package/src/compaction/salience/score.ts +112 -43
- package/src/compaction/salience/working-set.ts +102 -31
- package/src/config/runtime.ts +14 -0
- package/src/manager/session/turn-recorder.ts +259 -30
- package/src/public-runtime.ts +1 -0
- package/src/runtime/query/cancelled-before-start.ts +1 -1
- package/src/runtime/query/checkpoint.ts +2 -1
- package/src/runtime/query/context.ts +33 -6
- package/src/runtime/query/executor.ts +24 -2
- package/src/runtime/query/index.ts +4 -1
- package/src/runtime/query/iteration/phases/compaction.ts +18 -0
- package/src/runtime/query/prepare-turn.ts +26 -11
- package/src/runtime/query/session-storage.ts +42 -3
- package/src/sandbox/file-walk.ts +41 -4
- package/src/store/checkpoint/contract.ts +15 -0
- package/src/store/checkpoint/disk.ts +34 -2
- package/src/store/checkpoint/prune.ts +13 -0
- package/src/store/evidence/disk.ts +57 -2
- package/src/store/session-log/core.ts +129 -10
- package/src/store/session-log/disk.ts +32 -0
- package/src/store/session-log/fold.ts +19 -2
- package/src/store/session-log/incremental.ts +160 -0
- package/src/store/session-log/index.ts +1 -0
- package/src/store/session-log/verified-prefix.ts +243 -0
- package/src/tools/admit-facts.ts +1 -0
- package/src/tools/builtins/bash.ts +11 -1
- package/src/tools/builtins/bounded-file-read.ts +59 -2
- package/src/tools/builtins/grep.ts +172 -26
- package/src/tools/builtins/search-support.ts +62 -0
- package/src/types/session/records.ts +25 -0
- 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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
192
|
+
if (s.protected !== null) continue
|
|
108
193
|
const m = messages[s.index] as Message
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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)
|
package/src/config/runtime.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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
|
/**
|
package/src/public-runtime.ts
CHANGED
|
@@ -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'
|
|
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
|
|
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({
|
|
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
|