@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
|
@@ -52,6 +52,12 @@ import {
|
|
|
52
52
|
} from './lease.js'
|
|
53
53
|
import type { SpillReadOptions, SpillRef, SpillStore } from './spill.js'
|
|
54
54
|
import { TOOL_INPUT_RECORD_TYPES, spilledInput, toolInputSlots } from './spilled-input.js'
|
|
55
|
+
import {
|
|
56
|
+
type VerifiedPrefix,
|
|
57
|
+
rehashPrefix,
|
|
58
|
+
startVerifiedPrefix,
|
|
59
|
+
verifiedPrefixOf,
|
|
60
|
+
} from './verified-prefix.js'
|
|
55
61
|
|
|
56
62
|
// ─── public types ─────────────────────────────────────────────────────────
|
|
57
63
|
|
|
@@ -91,6 +97,17 @@ export interface ReadSessionLogOptions {
|
|
|
91
97
|
readonly mode?: 'strict' | 'tolerant'
|
|
92
98
|
/** Resume after this record: its bytes are checked, then the walk continues from it. */
|
|
93
99
|
readonly after?: RecordPointer
|
|
100
|
+
/**
|
|
101
|
+
* With `after`, and in `strict` mode only: also confirm that every byte
|
|
102
|
+
* before the cursor is what this process verified. The prefix is read and
|
|
103
|
+
* hashed line by line against what was remembered (no parsing, nothing
|
|
104
|
+
* kept); when this process remembers nothing of the log, or any byte
|
|
105
|
+
* differs, it is verified in full from byte 0, which refuses a broken chain
|
|
106
|
+
* as a read from byte 0 does. Without it a resumed read trusts the records
|
|
107
|
+
* before the cursor, as a reduction that is advanced rather than recomputed
|
|
108
|
+
* does. Ignored when the medium has no `identity`.
|
|
109
|
+
*/
|
|
110
|
+
readonly reverify?: boolean
|
|
94
111
|
/**
|
|
95
112
|
* The record the log must hold at `expectHead.seq`, byte for byte. Anchors
|
|
96
113
|
* the tail, which no later record vouches for.
|
|
@@ -288,6 +305,25 @@ export interface LogMedium extends LogBytes {
|
|
|
288
305
|
truncate(size: number, expectedSize: number): Promise<void>
|
|
289
306
|
/** The bytes from `offset` to the end, in chunks. */
|
|
290
307
|
stream(offset: number): AsyncIterable<Uint8Array>
|
|
308
|
+
/**
|
|
309
|
+
* A string that names this log's bytes for as long as the same file stands
|
|
310
|
+
* (the disk medium: device, inode and path), or `undefined` when the log
|
|
311
|
+
* has none yet or the medium cannot say. A log that has one lets a later
|
|
312
|
+
* claim in the same process re-check the bytes it already verified without
|
|
313
|
+
* parsing them again; the content is still compared, so a wrong or reused
|
|
314
|
+
* identity can only cost a full verification, never skip one.
|
|
315
|
+
*/
|
|
316
|
+
identity?(): Promise<string | undefined>
|
|
317
|
+
/**
|
|
318
|
+
* Hand the bytes from `offset` to `visit`, chunk by chunk, until `visit`
|
|
319
|
+
* answers `true` or the log ends. The chunk is only valid for the call: the
|
|
320
|
+
* medium may fill the same buffer again for the next one, so a visitor keeps
|
|
321
|
+
* no slice of it. It lets a re-check of a long log read through one buffer
|
|
322
|
+
* instead of allocating a fresh native buffer for every megabyte, which
|
|
323
|
+
* otherwise makes the collector run on every few walks. Optional; `stream`
|
|
324
|
+
* is used when a medium has no `scan`.
|
|
325
|
+
*/
|
|
326
|
+
scan?(offset: number, visit: (chunk: Uint8Array) => boolean): Promise<void>
|
|
291
327
|
}
|
|
292
328
|
|
|
293
329
|
export interface SessionLogCoreOptions {
|
|
@@ -397,6 +433,8 @@ export class SessionLogCore implements SessionLog {
|
|
|
397
433
|
readonly #holding: HeldSessionLease = { release: () => this.#releaseHeld() }
|
|
398
434
|
/** Bytes of the log this instance has verified and applied. */
|
|
399
435
|
#synced = 0
|
|
436
|
+
/** What this process remembers of the log's verified prefix (see `verified-prefix.ts`). */
|
|
437
|
+
#ledgerKey: string | undefined
|
|
400
438
|
#torn = 0
|
|
401
439
|
readonly #degradedBufferBytes: number
|
|
402
440
|
/** Records accepted into the chain that the medium has not taken yet, oldest first. */
|
|
@@ -462,13 +500,48 @@ export class SessionLogCore implements SessionLog {
|
|
|
462
500
|
|
|
463
501
|
// ── state ──
|
|
464
502
|
|
|
465
|
-
/**
|
|
503
|
+
/**
|
|
504
|
+
* Catch up with the medium: verify and apply whatever was appended since the last sync.
|
|
505
|
+
*
|
|
506
|
+
* An instance that has nothing yet (a fresh one) first asks whether this
|
|
507
|
+
* process already verified a prefix of this very log: it then re-reads and
|
|
508
|
+
* hashes every byte of that prefix, and takes it as verified only when
|
|
509
|
+
* every line's length and SHA-256 equal what was verified (no parsing, no
|
|
510
|
+
* schema check). The bytes after it, and every byte when nothing matches,
|
|
511
|
+
* are verified in full.
|
|
512
|
+
*/
|
|
466
513
|
async #catchUp(): Promise<void> {
|
|
467
514
|
// Records waiting for the disk are ahead of it; the lease keeps others out.
|
|
468
515
|
if (this.#pending.length > 0) return
|
|
469
516
|
const size = await this.#medium.size()
|
|
470
517
|
if (size === this.#synced + this.#torn && size >= this.#synced) return
|
|
471
518
|
if (size < this.#synced) this.#reset()
|
|
519
|
+
let ledger: VerifiedPrefix | undefined
|
|
520
|
+
const key = await this.#medium.identity?.()
|
|
521
|
+
this.#ledgerKey = key
|
|
522
|
+
if (key !== undefined) {
|
|
523
|
+
ledger = verifiedPrefixOf(key)
|
|
524
|
+
if (this.#synced === 0 && this.#chain.head === null) {
|
|
525
|
+
const remembered = ledger
|
|
526
|
+
if (
|
|
527
|
+
remembered !== undefined &&
|
|
528
|
+
remembered.count > 0 &&
|
|
529
|
+
remembered.sessionId === this.sessionId &&
|
|
530
|
+
remembered.end <= size &&
|
|
531
|
+
(await rehashPrefix(this.#medium, remembered, remembered.count))
|
|
532
|
+
) {
|
|
533
|
+
this.#chain = new SessionLogChain({
|
|
534
|
+
head: remembered.head,
|
|
535
|
+
sessionId: remembered.sessionId,
|
|
536
|
+
gen: remembered.gen,
|
|
537
|
+
})
|
|
538
|
+
this.#turns = remembered.turns()
|
|
539
|
+
this.#synced = remembered.end
|
|
540
|
+
} else {
|
|
541
|
+
ledger = startVerifiedPrefix(key)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
472
545
|
const splitter = new LineSplitter(this.#synced)
|
|
473
546
|
for await (const chunk of this.#medium.stream(this.#synced)) {
|
|
474
547
|
for (const { line, offset } of splitter.push(chunk)) {
|
|
@@ -483,6 +556,7 @@ export class SessionLogCore implements SessionLog {
|
|
|
483
556
|
}
|
|
484
557
|
this.#turns.apply(entry.record)
|
|
485
558
|
this.#synced = offset + line.byteLength
|
|
559
|
+
ledger?.append(entry)
|
|
486
560
|
}
|
|
487
561
|
}
|
|
488
562
|
this.#torn = splitter.pendingBytes
|
|
@@ -761,9 +835,21 @@ export class SessionLogCore implements SessionLog {
|
|
|
761
835
|
const entry = this.#chain.accept(bytes, this.#synced)
|
|
762
836
|
this.#turns.apply(entry.record)
|
|
763
837
|
this.#synced += bytes.byteLength
|
|
838
|
+
// On the disk and fully verified: a later claim need not parse it again.
|
|
839
|
+
if (this.#pending.length === 0) await this.#rememberWritten(entry)
|
|
764
840
|
return entry
|
|
765
841
|
}
|
|
766
842
|
|
|
843
|
+
/** Let this process remember a record this instance just wrote and verified. */
|
|
844
|
+
async #rememberWritten(entry: SessionLogEntry): Promise<void> {
|
|
845
|
+
if (this.#ledgerKey === undefined) this.#ledgerKey = await this.#medium.identity?.()
|
|
846
|
+
if (this.#ledgerKey === undefined) return
|
|
847
|
+
const ledger =
|
|
848
|
+
verifiedPrefixOf(this.#ledgerKey) ??
|
|
849
|
+
(entry.pointer.seq === 1 ? startVerifiedPrefix(this.#ledgerKey) : undefined)
|
|
850
|
+
ledger?.append(entry)
|
|
851
|
+
}
|
|
852
|
+
|
|
767
853
|
/**
|
|
768
854
|
* Hand a record's bytes to the medium. A volume that refuses writes (full,
|
|
769
855
|
* over quota, read-only) does not fail the record: it waits in a bounded
|
|
@@ -888,6 +974,7 @@ export class SessionLogCore implements SessionLog {
|
|
|
888
974
|
if (
|
|
889
975
|
type !== 'message' &&
|
|
890
976
|
type !== 'message_replaced' &&
|
|
977
|
+
type !== 'message_edited' &&
|
|
891
978
|
type !== 'compaction' &&
|
|
892
979
|
type !== 'turn_completed'
|
|
893
980
|
)
|
|
@@ -926,7 +1013,7 @@ export class SessionLogCore implements SessionLog {
|
|
|
926
1013
|
const key =
|
|
927
1014
|
content.role === 'tool' && typeof content.toolCallId === 'string'
|
|
928
1015
|
? content.toolCallId
|
|
929
|
-
: type === 'message_replaced'
|
|
1016
|
+
: type === 'message_replaced' || type === 'message_edited'
|
|
930
1017
|
? `record:${String(candidate.id)}`
|
|
931
1018
|
: `message:${String(candidate.messageId)}`
|
|
932
1019
|
const ref = await this.#spillOrLose(key, 'message', JSON.stringify(content))
|
|
@@ -1093,7 +1180,7 @@ export async function collect(
|
|
|
1093
1180
|
* both backends and by `readSessionLog`.
|
|
1094
1181
|
*/
|
|
1095
1182
|
export async function* walkSessionLog(
|
|
1096
|
-
medium: Pick<LogMedium, 'size' | 'read' | 'stream'>,
|
|
1183
|
+
medium: Pick<LogMedium, 'size' | 'read' | 'stream' | 'identity' | 'scan'>,
|
|
1097
1184
|
options: ReadSessionLogOptions & { readonly sessionId?: SessionId },
|
|
1098
1185
|
): AsyncGenerator<SessionLogEntry, SessionLogReadSummary> {
|
|
1099
1186
|
const strict = (options.mode ?? 'strict') === 'strict'
|
|
@@ -1105,6 +1192,9 @@ export async function* walkSessionLog(
|
|
|
1105
1192
|
brokeAt = error
|
|
1106
1193
|
}
|
|
1107
1194
|
|
|
1195
|
+
// A resumed read that must also vouch for the bytes before its cursor.
|
|
1196
|
+
let covered = 0
|
|
1197
|
+
let ledger: VerifiedPrefix | undefined
|
|
1108
1198
|
if (options.after !== undefined) {
|
|
1109
1199
|
const after = options.after
|
|
1110
1200
|
if (!(await verifyPointer(medium, after))) {
|
|
@@ -1117,15 +1207,42 @@ export async function* walkSessionLog(
|
|
|
1117
1207
|
fail(error)
|
|
1118
1208
|
return { intact: false, throughSeq: 0, head: null, tornBytes: 0, break: error }
|
|
1119
1209
|
}
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1210
|
+
const key = options.reverify && strict ? await medium.identity?.() : undefined
|
|
1211
|
+
if (key !== undefined) {
|
|
1212
|
+
const remembered = verifiedPrefixOf(key)
|
|
1213
|
+
if (
|
|
1214
|
+
remembered !== undefined &&
|
|
1215
|
+
remembered.sessionId === options.sessionId &&
|
|
1216
|
+
remembered.holds(after) &&
|
|
1217
|
+
(await rehashPrefix(medium, remembered, after.seq))
|
|
1218
|
+
) {
|
|
1219
|
+
ledger = remembered
|
|
1220
|
+
} else {
|
|
1221
|
+
// Nothing remembered, or it no longer matches: verify from byte 0 and
|
|
1222
|
+
// remember it, handing the caller only what follows the cursor.
|
|
1223
|
+
ledger = startVerifiedPrefix(key)
|
|
1224
|
+
covered = after.seq
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
if (covered > 0) {
|
|
1228
|
+
chain = new SessionLogChain({ head: null, sessionId: options.sessionId })
|
|
1229
|
+
} else {
|
|
1230
|
+
const parsed = parseSessionLogLine(await medium.read(after.offset, after.length))
|
|
1231
|
+
chain = new SessionLogChain({
|
|
1232
|
+
head: after,
|
|
1233
|
+
sessionId: options.sessionId ?? parsed.record.sessionId,
|
|
1234
|
+
gen: parsed.record.gen,
|
|
1235
|
+
})
|
|
1236
|
+
start = after.offset + after.length
|
|
1237
|
+
}
|
|
1127
1238
|
} else {
|
|
1128
1239
|
chain = new SessionLogChain({ head: null, sessionId: options.sessionId })
|
|
1240
|
+
// A whole read that asked to reverify is the first of its kind: what it verifies in
|
|
1241
|
+
// full is remembered, so the resumed reads after it can re-check by hash.
|
|
1242
|
+
if (options.reverify && strict) {
|
|
1243
|
+
const key = await medium.identity?.()
|
|
1244
|
+
if (key !== undefined) ledger = verifiedPrefixOf(key) ?? startVerifiedPrefix(key)
|
|
1245
|
+
}
|
|
1129
1246
|
}
|
|
1130
1247
|
|
|
1131
1248
|
const splitter = new LineSplitter(start)
|
|
@@ -1165,6 +1282,8 @@ export async function* walkSessionLog(
|
|
|
1165
1282
|
break outer
|
|
1166
1283
|
}
|
|
1167
1284
|
}
|
|
1285
|
+
ledger?.append(entry)
|
|
1286
|
+
if (entry.pointer.seq <= covered) continue
|
|
1168
1287
|
yield entry
|
|
1169
1288
|
if (options.throughSeq !== undefined && entry.pointer.seq >= options.throughSeq) {
|
|
1170
1289
|
stopped = true
|
|
@@ -59,6 +59,16 @@ export class DiskLogMedium implements LogMedium {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
async identity(): Promise<string | undefined> {
|
|
63
|
+
try {
|
|
64
|
+
const stats = await stat(this.file)
|
|
65
|
+
return `${stats.dev}:${stats.ino}:${this.file}`
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (isErrno(error, 'ENOENT')) return undefined
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
async read(offset: number, length: number): Promise<Uint8Array> {
|
|
63
73
|
let handle: FileHandle
|
|
64
74
|
try {
|
|
@@ -76,6 +86,28 @@ export class DiskLogMedium implements LogMedium {
|
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
async scan(offset: number, visit: (chunk: Uint8Array) => boolean): Promise<void> {
|
|
90
|
+
let handle: FileHandle
|
|
91
|
+
try {
|
|
92
|
+
handle = await open(this.file, 'r')
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (isErrno(error, 'ENOENT')) return
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const buffer = Buffer.allocUnsafe(1 << 20)
|
|
99
|
+
let position = offset
|
|
100
|
+
for (;;) {
|
|
101
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position)
|
|
102
|
+
if (bytesRead === 0) return
|
|
103
|
+
position += bytesRead
|
|
104
|
+
if (visit(buffer.subarray(0, bytesRead))) return
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
await handle.close()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
async *stream(offset: number): AsyncIterable<Uint8Array> {
|
|
80
112
|
if ((await this.size()) <= offset) return
|
|
81
113
|
const stream = createReadStream(this.file, { start: offset, highWaterMark: 1 << 20 })
|
|
@@ -22,7 +22,10 @@ import type { SpillRef } from './spill.js'
|
|
|
22
22
|
* override or a structured-output answer is what every reader of the fold
|
|
23
23
|
* sees; the model's raw text stays in the log for audit only.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* `message_edited` is applied the same way as `message_replaced`: a compaction
|
|
26
|
+
* pass's edit of one message, written under the message's own id.
|
|
27
|
+
*
|
|
28
|
+
* Only `message`, `message_replaced`, `message_edited` and `compaction` records move the fold.
|
|
26
29
|
* The live compaction events (`compaction_completed` and the rest) are
|
|
27
30
|
* reports, not instructions.
|
|
28
31
|
*/
|
|
@@ -118,7 +121,8 @@ export class SessionMessageFold {
|
|
|
118
121
|
case 'message':
|
|
119
122
|
this.#message(record as MessageRecord)
|
|
120
123
|
return
|
|
121
|
-
case 'message_replaced':
|
|
124
|
+
case 'message_replaced':
|
|
125
|
+
case 'message_edited': {
|
|
122
126
|
const spill = (record as { spill?: SpillRef }).spill
|
|
123
127
|
this.#replacements.set(record.targetMessageId, {
|
|
124
128
|
message: record.content,
|
|
@@ -311,6 +315,18 @@ export class SessionTurnState {
|
|
|
311
315
|
return this.#active
|
|
312
316
|
}
|
|
313
317
|
|
|
318
|
+
/**
|
|
319
|
+
* An independent copy at the records applied so far. `apply` replaces the
|
|
320
|
+
* active turn's object rather than editing it, so the copy may share it.
|
|
321
|
+
*/
|
|
322
|
+
clone(): SessionTurnState {
|
|
323
|
+
const copy = new SessionTurnState()
|
|
324
|
+
copy.#active = this.#active
|
|
325
|
+
copy.#lastTurnId = this.#lastTurnId
|
|
326
|
+
copy.#started = this.#started
|
|
327
|
+
return copy
|
|
328
|
+
}
|
|
329
|
+
|
|
314
330
|
/** The last turn that started, active or not. */
|
|
315
331
|
get lastTurnId(): TurnId | undefined {
|
|
316
332
|
return this.#lastTurnId
|
|
@@ -412,5 +428,6 @@ const CLOSES_OR_DECIDES: ReadonlySet<string> = new Set([
|
|
|
412
428
|
'audit',
|
|
413
429
|
'log_repaired',
|
|
414
430
|
'message_replaced',
|
|
431
|
+
'message_edited',
|
|
415
432
|
'session_updated',
|
|
416
433
|
])
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { RecordPointer } from '../../types/session/records.js'
|
|
2
|
+
import { type SessionLogEntry, SessionLogIntegrityError } from './chain.js'
|
|
3
|
+
import type { SessionLog } from './core.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reductions over a whole session log, advanced rather than recomputed.
|
|
7
|
+
*
|
|
8
|
+
* A session log only grows, and every turn asks it the same questions (which
|
|
9
|
+
* checkpoints were committed, which turn ids exist, what the folded context
|
|
10
|
+
* is). Answering each from byte 0 parses and verifies every record the
|
|
11
|
+
* session ever wrote, so a turn cost more the longer the session. A reduction
|
|
12
|
+
* held here remembers the last record it folded and, on the next ask, walks
|
|
13
|
+
* only what was appended after it. `SessionLog.read({ after })` checks that
|
|
14
|
+
* record against the log byte for byte before continuing, so a log that was
|
|
15
|
+
* replaced, truncated or rewritten at that record is noticed and the
|
|
16
|
+
* reduction starts again from byte 0 with the original behaviour (strict reads
|
|
17
|
+
* refuse a broken chain, tolerant reads stop at the break).
|
|
18
|
+
*
|
|
19
|
+
* What a resumed read does not do is re-verify the records before the anchor:
|
|
20
|
+
* they were verified when they were first folded, by this process. A byte
|
|
21
|
+
* flipped in them afterwards, with every later record left as it was, is not
|
|
22
|
+
* noticed by this path; a fresh process (a resume) always starts from byte 0.
|
|
23
|
+
*
|
|
24
|
+
* The cache is keyed by session id and the reduction's own name and mode,
|
|
25
|
+
* holds a few sessions, and is anchored by content, so two logs that share a
|
|
26
|
+
* session id never share a result.
|
|
27
|
+
*
|
|
28
|
+
* Cost that remains: the state of the "every message ever recorded" reduction
|
|
29
|
+
* holds each message the log ever recorded, superseded and compacted ones
|
|
30
|
+
* included, and the earlier texts of an edited message (one per edit, until a
|
|
31
|
+
* replacement clears them), so it grows with the log and not with the live context, and it
|
|
32
|
+
* lives for the life of the process (for the sessions kept). Reading it
|
|
33
|
+
* returns a copy of all of them, which is O(history) per turn. Both are small
|
|
34
|
+
* next to the whole-log walks this file removes, and are left as they are.
|
|
35
|
+
* More than SESSIONS_KEPT sessions in use at once evict one another, and each
|
|
36
|
+
* eviction restarts from byte 0, the cost before this file existed.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const SESSIONS_KEPT = 4
|
|
40
|
+
|
|
41
|
+
interface Kept {
|
|
42
|
+
state: unknown
|
|
43
|
+
/** The last record folded; the next read resumes after it. */
|
|
44
|
+
anchor: RecordPointer | undefined
|
|
45
|
+
/** Calls on one reduction take turns: the state is advanced in place. */
|
|
46
|
+
turn: Promise<unknown>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sessions = new Map<string, Map<string, Kept>>()
|
|
50
|
+
|
|
51
|
+
function keptFor(sessionId: string, key: string): Kept {
|
|
52
|
+
let reductions = sessions.get(sessionId)
|
|
53
|
+
if (reductions === undefined) {
|
|
54
|
+
reductions = new Map()
|
|
55
|
+
if (sessions.size >= SESSIONS_KEPT) {
|
|
56
|
+
const oldest = sessions.keys().next().value
|
|
57
|
+
if (oldest !== undefined) sessions.delete(oldest)
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
sessions.delete(sessionId)
|
|
61
|
+
}
|
|
62
|
+
sessions.set(sessionId, reductions)
|
|
63
|
+
let kept = reductions.get(key)
|
|
64
|
+
if (kept === undefined) {
|
|
65
|
+
kept = { state: undefined, anchor: undefined, turn: Promise.resolve() }
|
|
66
|
+
reductions.set(key, kept)
|
|
67
|
+
}
|
|
68
|
+
return kept
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface IncrementalFold<S, R> {
|
|
72
|
+
/** The reduction's name; one cache slot per name and read mode. */
|
|
73
|
+
readonly name: string
|
|
74
|
+
readonly mode: 'strict' | 'tolerant'
|
|
75
|
+
/**
|
|
76
|
+
* Strict reductions only: before resuming, hash every byte before the
|
|
77
|
+
* resume point against what this process verified (`read({ reverify })`),
|
|
78
|
+
* so a resumed read refuses a log altered anywhere, as a read from byte 0
|
|
79
|
+
* does, without parsing the prefix again. Off by default: the window the
|
|
80
|
+
* header describes then applies.
|
|
81
|
+
*/
|
|
82
|
+
readonly reverify?: boolean
|
|
83
|
+
/** A fresh, empty state. */
|
|
84
|
+
create(): S
|
|
85
|
+
/** Fold one record into the state, in place. */
|
|
86
|
+
apply(state: S, entry: SessionLogEntry): void
|
|
87
|
+
/**
|
|
88
|
+
* The answer, from a state that is current for as long as this call runs.
|
|
89
|
+
* It must not keep or hand out the state itself, which later calls advance.
|
|
90
|
+
*/
|
|
91
|
+
result(state: S): Promise<R> | R
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fold `log` into `fold.create()` through `fold.apply`, resuming from the
|
|
96
|
+
* record the previous call for this session stopped at when the log still
|
|
97
|
+
* holds it, and answer with `fold.result`.
|
|
98
|
+
*/
|
|
99
|
+
export async function foldSessionLog<S, R>(
|
|
100
|
+
log: SessionLog,
|
|
101
|
+
fold: IncrementalFold<S, R>,
|
|
102
|
+
): Promise<R> {
|
|
103
|
+
const kept = keptFor(log.sessionId, `${fold.name}:${fold.mode}`)
|
|
104
|
+
const run = async (): Promise<R> => {
|
|
105
|
+
let state = kept.state as S | undefined
|
|
106
|
+
let anchor = kept.anchor
|
|
107
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
108
|
+
const resuming = state !== undefined && anchor !== undefined
|
|
109
|
+
if (!resuming) {
|
|
110
|
+
state = fold.create()
|
|
111
|
+
anchor = undefined
|
|
112
|
+
}
|
|
113
|
+
let moved = false
|
|
114
|
+
try {
|
|
115
|
+
const walk = log.read({
|
|
116
|
+
mode: fold.mode,
|
|
117
|
+
...(anchor ? { after: anchor } : {}),
|
|
118
|
+
...(fold.reverify && fold.mode === 'strict' ? { reverify: true } : {}),
|
|
119
|
+
})
|
|
120
|
+
let summary: Awaited<ReturnType<typeof walk.next>>
|
|
121
|
+
for (;;) {
|
|
122
|
+
summary = await walk.next()
|
|
123
|
+
if (summary.done) break
|
|
124
|
+
moved = true
|
|
125
|
+
fold.apply(state as S, summary.value)
|
|
126
|
+
}
|
|
127
|
+
const ended = summary.value
|
|
128
|
+
if (resuming && !moved && ended.break?.reason === 'anchor-mismatch') {
|
|
129
|
+
kept.state = undefined
|
|
130
|
+
kept.anchor = undefined
|
|
131
|
+
state = undefined
|
|
132
|
+
anchor = undefined
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
kept.state = state
|
|
136
|
+
kept.anchor = ended.head?.pointer ?? anchor
|
|
137
|
+
return await fold.result(state as S)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// The state may be half-advanced: never reuse it.
|
|
140
|
+
kept.state = undefined
|
|
141
|
+
kept.anchor = undefined
|
|
142
|
+
if (
|
|
143
|
+
resuming &&
|
|
144
|
+
!moved &&
|
|
145
|
+
error instanceof SessionLogIntegrityError &&
|
|
146
|
+
error.reason === 'anchor-mismatch'
|
|
147
|
+
) {
|
|
148
|
+
state = undefined
|
|
149
|
+
anchor = undefined
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
throw new Error('unreachable')
|
|
156
|
+
}
|
|
157
|
+
const result = kept.turn.then(run, run)
|
|
158
|
+
kept.turn = result.catch(() => undefined)
|
|
159
|
+
return result
|
|
160
|
+
}
|