@namzu/sdk 62.7.0 → 62.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/compaction/plan.d.ts +12 -0
  3. package/dist/compaction/plan.d.ts.map +1 -1
  4. package/dist/compaction/plan.js +39 -11
  5. package/dist/compaction/plan.js.map +1 -1
  6. package/dist/compaction/salience/minhash.d.ts +9 -0
  7. package/dist/compaction/salience/minhash.d.ts.map +1 -1
  8. package/dist/compaction/salience/minhash.js +20 -0
  9. package/dist/compaction/salience/minhash.js.map +1 -1
  10. package/dist/compaction/salience/score.d.ts.map +1 -1
  11. package/dist/compaction/salience/score.js +87 -45
  12. package/dist/compaction/salience/score.js.map +1 -1
  13. package/dist/compaction/salience/working-set.d.ts +14 -0
  14. package/dist/compaction/salience/working-set.d.ts.map +1 -1
  15. package/dist/compaction/salience/working-set.js +84 -33
  16. package/dist/compaction/salience/working-set.js.map +1 -1
  17. package/dist/config/runtime.d.ts +2 -0
  18. package/dist/config/runtime.d.ts.map +1 -1
  19. package/dist/config/runtime.js +14 -0
  20. package/dist/config/runtime.js.map +1 -1
  21. package/dist/manager/session/turn-recorder.d.ts +25 -0
  22. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  23. package/dist/manager/session/turn-recorder.js +231 -31
  24. package/dist/manager/session/turn-recorder.js.map +1 -1
  25. package/dist/public-runtime.d.ts +1 -1
  26. package/dist/public-runtime.d.ts.map +1 -1
  27. package/dist/public-runtime.js +1 -1
  28. package/dist/public-runtime.js.map +1 -1
  29. package/dist/runtime/query/cancelled-before-start.js +1 -1
  30. package/dist/runtime/query/cancelled-before-start.js.map +1 -1
  31. package/dist/runtime/query/checkpoint.js +2 -1
  32. package/dist/runtime/query/checkpoint.js.map +1 -1
  33. package/dist/runtime/query/context.d.ts.map +1 -1
  34. package/dist/runtime/query/context.js +28 -2
  35. package/dist/runtime/query/context.js.map +1 -1
  36. package/dist/runtime/query/executor.d.ts.map +1 -1
  37. package/dist/runtime/query/executor.js +24 -2
  38. package/dist/runtime/query/executor.js.map +1 -1
  39. package/dist/runtime/query/index.d.ts.map +1 -1
  40. package/dist/runtime/query/index.js +4 -1
  41. package/dist/runtime/query/index.js.map +1 -1
  42. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  43. package/dist/runtime/query/iteration/phases/compaction.js +15 -1
  44. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  45. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  46. package/dist/runtime/query/prepare-turn.js +27 -12
  47. package/dist/runtime/query/prepare-turn.js.map +1 -1
  48. package/dist/runtime/query/session-storage.d.ts.map +1 -1
  49. package/dist/runtime/query/session-storage.js +44 -3
  50. package/dist/runtime/query/session-storage.js.map +1 -1
  51. package/dist/sandbox/file-walk.d.ts +7 -1
  52. package/dist/sandbox/file-walk.d.ts.map +1 -1
  53. package/dist/sandbox/file-walk.js +30 -4
  54. package/dist/sandbox/file-walk.js.map +1 -1
  55. package/dist/store/checkpoint/contract.d.ts +12 -0
  56. package/dist/store/checkpoint/contract.d.ts.map +1 -1
  57. package/dist/store/checkpoint/contract.js.map +1 -1
  58. package/dist/store/checkpoint/disk.d.ts.map +1 -1
  59. package/dist/store/checkpoint/disk.js +34 -2
  60. package/dist/store/checkpoint/disk.js.map +1 -1
  61. package/dist/store/checkpoint/prune.d.ts.map +1 -1
  62. package/dist/store/checkpoint/prune.js +6 -0
  63. package/dist/store/checkpoint/prune.js.map +1 -1
  64. package/dist/store/evidence/disk.js +40 -3
  65. package/dist/store/evidence/disk.js.map +1 -1
  66. package/dist/store/session-log/core.d.ts +31 -1
  67. package/dist/store/session-log/core.d.ts.map +1 -1
  68. package/dist/store/session-log/core.js +95 -9
  69. package/dist/store/session-log/core.js.map +1 -1
  70. package/dist/store/session-log/disk.d.ts +2 -0
  71. package/dist/store/session-log/disk.d.ts.map +1 -1
  72. package/dist/store/session-log/disk.js +37 -0
  73. package/dist/store/session-log/disk.js.map +1 -1
  74. package/dist/store/session-log/fold.d.ts +9 -1
  75. package/dist/store/session-log/fold.d.ts.map +1 -1
  76. package/dist/store/session-log/fold.js +18 -2
  77. package/dist/store/session-log/fold.js.map +1 -1
  78. package/dist/store/session-log/incremental.d.ts +31 -0
  79. package/dist/store/session-log/incremental.d.ts.map +1 -0
  80. package/dist/store/session-log/incremental.js +122 -0
  81. package/dist/store/session-log/incremental.js.map +1 -0
  82. package/dist/store/session-log/index.d.ts +1 -0
  83. package/dist/store/session-log/index.d.ts.map +1 -1
  84. package/dist/store/session-log/index.js +1 -0
  85. package/dist/store/session-log/index.js.map +1 -1
  86. package/dist/store/session-log/verified-prefix.d.ts +46 -0
  87. package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
  88. package/dist/store/session-log/verified-prefix.js +233 -0
  89. package/dist/store/session-log/verified-prefix.js.map +1 -0
  90. package/dist/tools/admit-facts.d.ts.map +1 -1
  91. package/dist/tools/admit-facts.js +1 -0
  92. package/dist/tools/admit-facts.js.map +1 -1
  93. package/dist/tools/builtins/bash.d.ts.map +1 -1
  94. package/dist/tools/builtins/bash.js +10 -1
  95. package/dist/tools/builtins/bash.js.map +1 -1
  96. package/dist/tools/builtins/bounded-file-read.d.ts +19 -1
  97. package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -1
  98. package/dist/tools/builtins/bounded-file-read.js +56 -3
  99. package/dist/tools/builtins/bounded-file-read.js.map +1 -1
  100. package/dist/tools/builtins/grep.d.ts +8 -0
  101. package/dist/tools/builtins/grep.d.ts.map +1 -1
  102. package/dist/tools/builtins/grep.js +147 -20
  103. package/dist/tools/builtins/grep.js.map +1 -1
  104. package/dist/tools/builtins/search-support.d.ts +8 -0
  105. package/dist/tools/builtins/search-support.d.ts.map +1 -1
  106. package/dist/tools/builtins/search-support.js +66 -0
  107. package/dist/tools/builtins/search-support.js.map +1 -1
  108. package/dist/types/session/records.d.ts +80 -1
  109. package/dist/types/session/records.d.ts.map +1 -1
  110. package/dist/types/session/records.js +22 -0
  111. package/dist/types/session/records.js.map +1 -1
  112. package/dist/types/tool/presentation.d.ts +6 -0
  113. package/dist/types/tool/presentation.d.ts.map +1 -1
  114. package/package.json +1 -1
  115. package/src/compaction/plan.ts +53 -11
  116. package/src/compaction/salience/minhash.ts +20 -0
  117. package/src/compaction/salience/score.ts +112 -43
  118. package/src/compaction/salience/working-set.ts +102 -31
  119. package/src/config/runtime.ts +14 -0
  120. package/src/manager/session/turn-recorder.ts +259 -30
  121. package/src/public-runtime.ts +1 -0
  122. package/src/runtime/query/cancelled-before-start.ts +1 -1
  123. package/src/runtime/query/checkpoint.ts +2 -1
  124. package/src/runtime/query/context.ts +33 -6
  125. package/src/runtime/query/executor.ts +24 -2
  126. package/src/runtime/query/index.ts +4 -1
  127. package/src/runtime/query/iteration/phases/compaction.ts +18 -0
  128. package/src/runtime/query/prepare-turn.ts +26 -11
  129. package/src/runtime/query/session-storage.ts +42 -3
  130. package/src/sandbox/file-walk.ts +41 -4
  131. package/src/store/checkpoint/contract.ts +15 -0
  132. package/src/store/checkpoint/disk.ts +34 -2
  133. package/src/store/checkpoint/prune.ts +13 -0
  134. package/src/store/evidence/disk.ts +57 -2
  135. package/src/store/session-log/core.ts +129 -10
  136. package/src/store/session-log/disk.ts +32 -0
  137. package/src/store/session-log/fold.ts +19 -2
  138. package/src/store/session-log/incremental.ts +160 -0
  139. package/src/store/session-log/index.ts +1 -0
  140. package/src/store/session-log/verified-prefix.ts +243 -0
  141. package/src/tools/admit-facts.ts +1 -0
  142. package/src/tools/builtins/bash.ts +11 -1
  143. package/src/tools/builtins/bounded-file-read.ts +59 -2
  144. package/src/tools/builtins/grep.ts +172 -26
  145. package/src/tools/builtins/search-support.ts +62 -0
  146. package/src/types/session/records.ts +25 -0
  147. package/src/types/tool/presentation.ts +6 -0
@@ -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
- /** Catch up with the medium: verify and apply whatever was appended since the last sync. */
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 parsed = parseSessionLogLine(await medium.read(after.offset, after.length))
1121
- chain = new SessionLogChain({
1122
- head: after,
1123
- sessionId: options.sessionId ?? parsed.record.sessionId,
1124
- gen: parsed.record.gen,
1125
- })
1126
- start = after.offset + after.length
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
- * Only `message`, `message_replaced` and `compaction` records move the fold.
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
+ }
@@ -61,6 +61,7 @@ export {
61
61
  withMessageId,
62
62
  } from './fold.js'
63
63
  export { type TornTailRepair, repairRecordDraft } from './heal.js'
64
+ export { type IncrementalFold, foldSessionLog } from './incremental.js'
64
65
  export {
65
66
  foldChildOutcomes,
66
67
  foldTurnOutcomes,