@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
@@ -705,7 +705,7 @@ export type PriorToolResults = ReadonlyMap<string, PriorToolResult>
705
705
  * for administrator rights is named as that; everything else is a plain block.
706
706
  * Read from the call's own argument, never from the refusal's wording.
707
707
  */
708
- function refusalFacts(toolName: string, input: unknown): ToolFacts {
708
+ function refusalFacts(toolName: string, input: unknown, reason?: string): ToolFacts {
709
709
  const command =
710
710
  input && typeof input === 'object' ? (input as { command?: unknown }).command : undefined
711
711
  const elevated = typeof command === 'string' && /(^|[\s;&|(])(sudo|doas)\b/.test(command)
@@ -713,9 +713,31 @@ function refusalFacts(toolName: string, input: unknown): ToolFacts {
713
713
  kind: 'failure',
714
714
  reason: elevated ? 'needs_administrator' : 'blocked',
715
715
  subject: toolName,
716
+ ...(refusalNote(reason) ? { note: refusalNote(reason) } : {}),
716
717
  }
717
718
  }
718
719
 
720
+ const REFUSAL_NOTE_MAX = 200
721
+
722
+ /**
723
+ * The refusal's own reason as one line for the person's view: the first
724
+ * non-empty line, the leading "Blocked:" label dropped, first letter up, bounded. The policy
725
+ * wrote it; nothing here reads the model's text.
726
+ */
727
+ function refusalNote(reason: string | undefined): string | undefined {
728
+ const line = reason
729
+ ?.split(/\r?\n/)
730
+ .map((part) => part.trim())
731
+ .find(Boolean)
732
+ if (!line) return undefined
733
+ const plain = line.replace(/^Blocked(?: by [^:]{1,60})?:\s*/i, '').trim()
734
+ if (!plain) return undefined
735
+ const sentence = `${plain.charAt(0).toUpperCase()}${plain.slice(1)}`
736
+ return sentence.length > REFUSAL_NOTE_MAX
737
+ ? `${sentence.slice(0, REFUSAL_NOTE_MAX - 1)}…`
738
+ : sentence
739
+ }
740
+
719
741
  export function deniedToolOutput(toolName: string, reason: string): string {
720
742
  return `Error: Tool "${toolName}" was not executed. ${reason}`
721
743
  }
@@ -3633,7 +3655,7 @@ export class ToolExecutor {
3633
3655
  ...(declined ? { presentation: this.declinedPresentation(toolName, input, declined) } : {}),
3634
3656
  // A refusal by policy says what kind it was; the person's own No is
3635
3657
  // already carried by the presentation above.
3636
- ...(declined ? {} : { facts: refusalFacts(toolName, input) }),
3658
+ ...(declined ? {} : { facts: refusalFacts(toolName, input, reason) }),
3637
3659
  isError: true,
3638
3660
  })
3639
3661
 
@@ -2248,7 +2248,10 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2248
2248
  }
2249
2249
  const pushRestored = (message: Message, ids: ReadonlyMap<Message, MessageId>): void => {
2250
2250
  const messageId = ids.get(message)
2251
- ctx.recorder.pushMessage(message, messageId ? { messageId } : {})
2251
+ ctx.recorder.pushMessage(message, {
2252
+ ...(messageId ? { messageId } : {}),
2253
+ restored: true,
2254
+ })
2252
2255
  }
2253
2256
 
2254
2257
  if (params.resumeFromCheckpoint) {
@@ -7,6 +7,7 @@ import { replaceableSummaries, summarizeWithModel } from '../../../../compaction
7
7
  import {
8
8
  type CompactionPlan,
9
9
  DEFAULT_SOFT_TARGET,
10
+ canShedSalienceWorkingSet,
10
11
  planCompaction,
11
12
  planSalienceWorkingSet,
12
13
  } from '../../../../compaction/plan.js'
@@ -787,6 +788,23 @@ async function runCompactionCheckInner(
787
788
  const startAt = salience ? Math.min(config.softTarget ?? DEFAULT_SOFT_TARGET, trigger) : trigger
788
789
  if (!options?.force && usage < startAt) return
789
790
 
791
+ // A pass that can shed nothing and has not reached the summary trigger
792
+ // does nothing but announce itself: two hooks, a started event and a log
793
+ // line around a plan with no action. From the soft target until the
794
+ // trigger it ran on every iteration of every turn once every narration
795
+ // was cut and every large result cleared. Not asked of a forced pass, of
796
+ // a turn with a reducer (it owns the reduction), or at the trigger, where
797
+ // the summary path is what the pass is for.
798
+ if (
799
+ salience &&
800
+ !options?.force &&
801
+ !ctx.contextReducer &&
802
+ usage < trigger &&
803
+ !canShedSalienceWorkingSet(ctx.recorder.messages, config)
804
+ ) {
805
+ return
806
+ }
807
+
790
808
  // Three failed passes in a row: stop proactive compaction for the rest of
791
809
  // the turn. A pass the provider forced is never gated.
792
810
  const breaker = compactionBreaker(ctx)
@@ -8,7 +8,7 @@ import {
8
8
  import { assertSessionLogAttribution } from '../../manager/session/attribution.js'
9
9
  import {
10
10
  type RecordedMessage,
11
- readEverRecordedMessages,
11
+ readEverRecordedMessageIndex,
12
12
  readFoldedHistory,
13
13
  } from '../../manager/session/turn-recorder.js'
14
14
  import { resolveModelPricing } from '../../pricing/index.js'
@@ -23,6 +23,7 @@ import { withProviderRetry } from '../../provider/retry.js'
23
23
  import { withTokenBudget } from '../../provider/token-budget.js'
24
24
  import { resolveAttachments } from '../../store/attachment/index.js'
25
25
  import type { SessionTokenBudget } from '../../store/budget/index.js'
26
+ import { foldSessionLog } from '../../store/session-log/incremental.js'
26
27
  import type { SessionLog } from '../../store/session-log/index.js'
27
28
  import { NAMZU } from '../../telemetry/attributes.js'
28
29
  import type { SerializedSpanContext } from '../../telemetry/attributes.js'
@@ -1163,7 +1164,8 @@ async function reconcileCallerMessages(
1163
1164
  continuationMode: boolean,
1164
1165
  ): Promise<Message[]> {
1165
1166
  const hasId = messages.some((message) => message.id !== undefined)
1166
- const recordedById = hasId ? await readEverRecordedMessages(log) : undefined
1167
+ const everRecorded = hasId ? await readEverRecordedMessageIndex(log) : undefined
1168
+ const recordedById = everRecorded?.latest
1167
1169
  // Ids a caller message already claimed by direct lookup above: a fold
1168
1170
  // entry that gave one of THESE out must not also be offered to the no-id
1169
1171
  // alignment below, or a message that already reconciled by id could be
@@ -1181,7 +1183,13 @@ async function reconcileCallerMessages(
1181
1183
  if (id !== undefined) {
1182
1184
  const recorded = recordedById?.get(id)
1183
1185
  if (recorded === undefined) throw staleCachedHistoryError('foreign', message)
1184
- if (!sameMessageContent(recorded, message)) throw staleCachedHistoryError('edited', message)
1186
+ if (!sameMessageContent(recorded, message)) {
1187
+ // A compaction pass edits a message under its own id; a host that
1188
+ // cached the history before the pass still holds the first text.
1189
+ // Answered from the reduction already read: no read of the log.
1190
+ if (!(await everRecorded?.heldBeforeAnEdit(id, message)))
1191
+ throw staleCachedHistoryError('edited', message)
1192
+ }
1185
1193
  claimedIds.add(id)
1186
1194
  continue // already durable: the fold supplies it
1187
1195
  }
@@ -1232,14 +1240,21 @@ async function assertTurnIdIsNew(
1232
1240
  storage: SessionStorage,
1233
1241
  ): Promise<void> {
1234
1242
  if (params.turnId === undefined) return
1235
- for await (const { record } of storage.log.read({ mode: 'tolerant' })) {
1236
- if (record.type === 'turn_started' && record.turnId === turnId) {
1237
- throw new NamzuError({
1238
- code: 'invalid_config',
1239
- message: `Turn ${turnId} already exists in session ${params.sessionId}. A new turn needs a new id; to continue that turn, pass resumeFromCheckpoint.`,
1240
- details: { sessionId: params.sessionId, turnId },
1241
- })
1242
- }
1243
+ const exists = await foldSessionLog(storage.log, {
1244
+ name: 'turn-ids',
1245
+ mode: 'tolerant',
1246
+ create: () => new Set<string>(),
1247
+ apply: (ids, { record }) => {
1248
+ if (record.type === 'turn_started') ids.add(record.turnId)
1249
+ },
1250
+ result: (ids) => ids.has(turnId),
1251
+ })
1252
+ if (exists) {
1253
+ throw new NamzuError({
1254
+ code: 'invalid_config',
1255
+ message: `Turn ${turnId} already exists in session ${params.sessionId}. A new turn needs a new id; to continue that turn, pass resumeFromCheckpoint.`,
1256
+ details: { sessionId: params.sessionId, turnId },
1257
+ })
1243
1258
  }
1244
1259
  }
1245
1260
 
@@ -14,6 +14,7 @@ import {
14
14
  InMemorySessionCheckpointStore,
15
15
  type SessionCheckpointStore,
16
16
  } from '../../store/checkpoint/index.js'
17
+ import { foldSessionLog } from '../../store/session-log/incremental.js'
17
18
  import {
18
19
  DiskSessionLog,
19
20
  type InMemoryLogMedium,
@@ -175,7 +176,10 @@ export async function locateSession(
175
176
  const parent = (await findSessionLocator(paths, parentSessionId)) ?? {
176
177
  sessionId: parentSessionId,
177
178
  }
178
- return { sessionId, ancestors: [...(parent.ancestors ?? []), parent.sessionId] }
179
+ return {
180
+ sessionId,
181
+ ancestors: [...(parent.ancestors ?? []), parent.sessionId],
182
+ }
179
183
  }
180
184
 
181
185
  /** Deeper than any delegation tree a project allows; a guard against a cyclic tree on disk. */
@@ -234,7 +238,10 @@ async function listDirectory(
234
238
  /** `SessionPaths` for the working directory's project under `resolveNamzuHome()`. */
235
239
  export async function defaultSessionPaths(workingDirectory?: string): Promise<SessionPaths> {
236
240
  const home = resolveNamzuHome()
237
- const project = await ensureProject({ home, cwd: workingDirectory ?? process.cwd() })
241
+ const project = await ensureProject({
242
+ home,
243
+ cwd: workingDirectory ?? process.cwd(),
244
+ })
238
245
  return new SessionPaths({ home, slug: project.slug })
239
246
  }
240
247
 
@@ -242,7 +249,9 @@ function heldState(log: InMemorySessionLog): HeldState {
242
249
  const current = held.get(log.medium)
243
250
  if (current) return current
244
251
  const next: HeldState = {
245
- checkpoints: new InMemorySessionCheckpointStore({ log: checkpointLogView(log) }),
252
+ checkpoints: new InMemorySessionCheckpointStore({
253
+ log: checkpointLogView(log),
254
+ }),
246
255
  tokenBudgets: new InMemorySessionTokenBudgetStore(),
247
256
  }
248
257
  held.set(log.medium, next)
@@ -303,5 +312,35 @@ export function checkpointLogView(log: SessionLog): CheckpointLogView {
303
312
  }
304
313
  return open.values()
305
314
  },
315
+ async pruneFacts(scope, checkpointIds) {
316
+ own(scope)
317
+ // Every committed checkpoint and every open decision, advanced from the
318
+ // last record the previous prune folded rather than read from byte 0:
319
+ // a prune runs every iteration of every turn.
320
+ return foldSessionLog(log, {
321
+ name: 'prune-facts',
322
+ mode: 'tolerant',
323
+ create: () => ({
324
+ written: new Map<CheckpointId, string>(),
325
+ open: new Map<string, CheckpointId>(),
326
+ }),
327
+ apply: ({ written, open }, { record }) => {
328
+ if (record.type === 'checkpoint_written')
329
+ written.set(record.checkpointId, record.docSha256)
330
+ if (record.type === 'decision_requested') open.set(record.decisionId, record.checkpointId)
331
+ if (record.type === 'decision_resolved' || record.type === 'decision_expired') {
332
+ open.delete(record.decisionId)
333
+ }
334
+ },
335
+ result: ({ written, open }) => {
336
+ const wanted = new Map<CheckpointId, string>()
337
+ for (const id of checkpointIds) {
338
+ const sha256 = written.get(id)
339
+ if (sha256 !== undefined) wanted.set(id, sha256)
340
+ }
341
+ return { written: wanted, open: [...open.values()] }
342
+ },
343
+ })
344
+ },
306
345
  }
307
346
  }
@@ -15,6 +15,9 @@ import { VCS_NAMES, createIgnoreStack } from './ignore-matcher.js'
15
15
  /** Entries a walk examines before it stops with what it has: a safety valve, not a budget. */
16
16
  export const DEFAULT_MAX_VISITED_ENTRIES = 500_000
17
17
 
18
+ /** Longest the walk holds the event loop between yields. */
19
+ const WALK_SLICE_MS = 8
20
+
18
21
  const GLOBSTAR = Symbol('globstar')
19
22
  type Segment = string | { source: string; flags: string } | typeof GLOBSTAR
20
23
 
@@ -185,6 +188,7 @@ async function* executeWalk(
185
188
  plan: WalkPlan,
186
189
  signal?: AbortSignal,
187
190
  onSummary?: (summary: SandboxWalkSummary) => void,
191
+ withoutSizes = false,
188
192
  ): AsyncGenerator<SandboxFileEntry> {
189
193
  const fs = await import('node:fs/promises')
190
194
  const path = await import('node:path')
@@ -196,6 +200,7 @@ async function* executeWalk(
196
200
  /** Whether leaving this directory also drops its ignore layer. */
197
201
  layered: boolean
198
202
  }[] = []
203
+ let sliceStart = performance.now()
199
204
  let visited = 0
200
205
  let emitted = 0
201
206
  let ignoredDirectories = 0
@@ -327,8 +332,22 @@ async function* executeWalk(
327
332
  while (open.length > 0) {
328
333
  check()
329
334
  const frame = open[open.length - 1] as (typeof open)[number]
330
- const entry = await wait(frame.dir.read())
331
- check()
335
+ let entry: import('node:fs').Dirent | null
336
+ if (withoutSizes) {
337
+ // A local search consumer reads a directory's buffered entries
338
+ // synchronously: one promise per entry costs more than the listing.
339
+ // The loop yields on a time slice, and a blocked read cannot be
340
+ // raced, which only the default path promises.
341
+ if (performance.now() - sliceStart > WALK_SLICE_MS) {
342
+ await new Promise<void>((done) => setImmediate(done))
343
+ sliceStart = performance.now()
344
+ }
345
+ check()
346
+ entry = frame.dir.readSync()
347
+ } else {
348
+ entry = await wait(frame.dir.read())
349
+ check()
350
+ }
332
351
  if (!entry) {
333
352
  open.pop()
334
353
  if (frame.layered) ignore?.leave()
@@ -351,6 +370,13 @@ async function* executeWalk(
351
370
  }
352
371
  if (!entry.isFile() || !matchers.some((matcher) => matcher.test(relative))) continue
353
372
  if (ignore?.isIgnored(absolute, false)) continue
373
+ if (withoutSizes) {
374
+ // The caller opens the file anyway and reads its size from the handle.
375
+ emitted += 1
376
+ yield { path: absolute, size: 0 }
377
+ if (emitted >= plan.maxEntries) return
378
+ continue
379
+ }
354
380
  let info: import('node:fs').Stats
355
381
  try {
356
382
  info = await wait(fs.lstat(absolute))
@@ -384,10 +410,21 @@ function globalIgnoreFiles(): string[] {
384
410
  /** Internal host path; the caller first resolves its authorized filesystem root. */
385
411
  export async function* walkFilesLocally(
386
412
  rootPath: string,
387
- options: SandboxWalkFilesOptions,
413
+ options: SandboxWalkFilesOptions & {
414
+ /**
415
+ * Skip the per-file size read: entries carry `size: 0`. For a caller that
416
+ * opens every file itself and takes the size from the handle.
417
+ */
418
+ readonly withoutSizes?: boolean
419
+ },
388
420
  ): AsyncGenerator<SandboxFileEntry> {
389
421
  options.signal?.throwIfAborted()
390
- yield* executeWalk(planWalk(rootPath, options), options.signal, options.onSummary)
422
+ yield* executeWalk(
423
+ planWalk(rootPath, options),
424
+ options.signal,
425
+ options.onSummary,
426
+ options.withoutSizes === true,
427
+ )
391
428
  }
392
429
 
393
430
  /** The existing sandbox execution seam; no separate worker protocol is needed. */
@@ -62,6 +62,21 @@ export interface CheckpointLogView {
62
62
  * `decision_resolved` or `decision_expired` yet. `prune` never deletes one.
63
63
  */
64
64
  openDecisionCheckpoints(scope: CheckpointScope): Promise<Iterable<CheckpointId>>
65
+ /**
66
+ * Both answers `prune` needs in one read of the log: for each of
67
+ * `checkpointIds`, what `writtenDocSha256` would return (absent from
68
+ * `written` when there is no record), and what `openDecisionCheckpoints`
69
+ * would return. Optional: a view without it is asked the two questions
70
+ * separately, one walk of the log each, which costs a walk per stored
71
+ * checkpoint on every prune.
72
+ */
73
+ pruneFacts?(
74
+ scope: CheckpointScope,
75
+ checkpointIds: readonly CheckpointId[],
76
+ ): Promise<{
77
+ readonly written: ReadonlyMap<CheckpointId, string>
78
+ readonly open: Iterable<CheckpointId>
79
+ }>
65
80
  }
66
81
 
67
82
  /** Why a restore refused a checkpoint. */
@@ -1,4 +1,4 @@
1
- import { link, mkdir, open, readFile, readdir, unlink } from 'node:fs/promises'
1
+ import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
2
2
  import type { FileHandle } from 'node:fs/promises'
3
3
  import { dirname } from 'node:path'
4
4
  import type { SessionLocator, SessionPaths } from '../../session/paths.js'
@@ -38,6 +38,17 @@ export interface DiskSessionCheckpointStoreOptions {
38
38
 
39
39
  const DOCUMENT = /^(.+)\.json$/
40
40
 
41
+ /**
42
+ * Which turn a checkpoint document on disk belongs to, as last read, with the
43
+ * size and mtime it had then. A session keeps its checkpoints for every turn
44
+ * in one directory, so listing one turn's used to read and parse every
45
+ * document of the session, and the directory only grows. A document is not
46
+ * read again while a `stat` says it is the one that was; one that belonged to
47
+ * another turn is skipped on that evidence alone.
48
+ */
49
+ const documentTurns = new Map<string, { turnId: string; size: number; mtimeMs: number }>()
50
+ const DOCUMENT_TURNS_KEPT = 20_000
51
+
41
52
  /**
42
53
  * {@link SessionCheckpointStore} at `<session-dir>/checkpoints/<id>.json`.
43
54
  *
@@ -145,10 +156,31 @@ export class DiskSessionCheckpointStore implements SessionCheckpointStore {
145
156
  // Sidecars (`<id>.json.<pid>.<n>.<hex>.tmp`) and strangers are not documents.
146
157
  const id = DOCUMENT.exec(name)?.[1]
147
158
  if (id === undefined || !isEntityId(id, 'checkpoint')) continue
148
- const text = await readIfPresent(this.#paths.checkpointFile(locator, id))
159
+ const path = this.#paths.checkpointFile(locator, id)
160
+ const seen = await stat(path).catch((error: NodeJS.ErrnoException) => {
161
+ if (error.code === 'ENOENT') return null
162
+ throw error
163
+ })
149
164
  // Deleted between the listing and the read: a concurrent prune, not damage.
165
+ if (seen === null) continue
166
+ const known = documentTurns.get(path)
167
+ if (
168
+ known !== undefined &&
169
+ known.turnId !== checked.turnId &&
170
+ known.size === seen.size &&
171
+ known.mtimeMs === seen.mtimeMs
172
+ ) {
173
+ continue
174
+ }
175
+ const text = await readIfPresent(path)
150
176
  if (text === null) continue
151
177
  const checkpoint = parseStoredCheckpoint(text, checked, id)
178
+ if (documentTurns.size >= DOCUMENT_TURNS_KEPT) documentTurns.clear()
179
+ documentTurns.set(path, {
180
+ turnId: checkpoint.turnId,
181
+ size: seen.size,
182
+ mtimeMs: seen.mtimeMs,
183
+ })
152
184
  if (checkpoint.turnId === checked.turnId) checkpoints.push(checkpoint)
153
185
  }
154
186
  return checkpoints.sort(compareCheckpoints)
@@ -79,6 +79,19 @@ export async function selectStoredCheckpointsToPrune(
79
79
  if (!Number.isSafeInteger(keepLast) || keepLast < 0) {
80
80
  throw new RangeError(`keepLast must be a nonnegative integer, got ${String(keepLast)}`)
81
81
  }
82
+ if (log.pruneFacts) {
83
+ // One read of the log answers both questions for every stored
84
+ // checkpoint, instead of one full walk each.
85
+ const facts = await log.pruneFacts(
86
+ scope,
87
+ stored.map((checkpoint) => checkpoint.checkpointId),
88
+ )
89
+ return selectSessionCheckpointsToPrune(
90
+ stored.filter((checkpoint) => facts.written.has(checkpoint.checkpointId)),
91
+ keepLast,
92
+ new Set(facts.open),
93
+ )
94
+ }
82
95
  const committed: Checkpoint[] = []
83
96
  for (const checkpoint of stored) {
84
97
  if ((await log.writtenDocSha256(scope, checkpoint.checkpointId)) !== null) {
@@ -157,22 +157,64 @@ export function createSessionEvidenceSource(
157
157
  })
158
158
  }
159
159
 
160
+ /** What a scan of a session log's complete records, from byte 0 up to `consumed`, found. */
161
+ interface SessionScan {
162
+ readonly identity: string
163
+ /** Offset just past the last complete record scanned. */
164
+ readonly consumed: number
165
+ readonly first: Buffer
166
+ readonly active: string | undefined
167
+ readonly turnClosed: boolean
168
+ }
169
+
170
+ /**
171
+ * Scans already done, by log file and scope, so that an access reads only the
172
+ * records appended since the last one instead of parsing the whole log again.
173
+ * A session log only grows, and every search page makes several accesses.
174
+ */
175
+ const SCAN_CACHE_LIMIT = 64
176
+ const scanCache = new Map<string, SessionScan>()
177
+
178
+ function remember(key: string, scan: SessionScan): void {
179
+ scanCache.delete(key)
180
+ scanCache.set(key, scan)
181
+ if (scanCache.size > SCAN_CACHE_LIMIT) {
182
+ const oldest = scanCache.keys().next().value
183
+ if (oldest !== undefined) scanCache.delete(oldest)
184
+ }
185
+ }
186
+
160
187
  /**
161
188
  * The first record of a session log (its `session_started`) and whether the
162
189
  * scope is closed: no turn of the session active, or — for a scope narrowed
163
190
  * to one turn — that turn settled.
191
+ *
192
+ * The scan resumes from the one a previous access left in `scanCache` when it
193
+ * is of the same file (same identity, first record unchanged on disk) and
194
+ * ended at or before `size`; otherwise it starts again from byte 0.
164
195
  */
165
196
  async function sessionState(
166
197
  handle: FileHandle,
167
198
  size: number,
168
199
  scope: z.infer<typeof scopeSchema>,
200
+ cacheKey: string,
201
+ identity: string,
169
202
  ): Promise<{ first: Buffer; closed: boolean }> {
170
203
  const unmetered: EvidenceBudget = { bytes: 0, limit: Number.MAX_SAFE_INTEGER }
171
204
  let first: Buffer | undefined
172
205
  let active: string | undefined
173
206
  let turnClosed = false
174
- let buffered = Buffer.alloc(0)
175
207
  let offset = 0
208
+ const cached = scanCache.get(cacheKey)
209
+ if (cached && cached.identity === identity && cached.consumed <= size) {
210
+ const head = await readBytes(handle, 0, cached.first.length, unmetered)
211
+ if (head.equals(cached.first)) {
212
+ ;({ first, active, turnClosed } = cached)
213
+ offset = cached.consumed
214
+ }
215
+ }
216
+ let consumed = offset
217
+ let buffered = Buffer.alloc(0)
176
218
  while (offset < size || buffered.length > 0) {
177
219
  let newline = buffered.indexOf(10)
178
220
  if (newline < 0) {
@@ -186,6 +228,7 @@ async function sessionState(
186
228
  const raw = buffered.subarray(0, newline + 1)
187
229
  buffered = buffered.subarray(newline + 1)
188
230
  newline = -1
231
+ consumed += raw.length
189
232
  const record = JSON.parse(decode(raw)) as Record<string, unknown>
190
233
  if (!first) {
191
234
  if (
@@ -206,6 +249,12 @@ async function sessionState(
206
249
  }
207
250
  }
208
251
  if (!first) throw new Error('The session log is empty; evidence is incomplete.')
252
+ // A source anchored earlier in the log scans less than another access has;
253
+ // keep the longer scan for the next one.
254
+ const kept = scanCache.get(cacheKey)
255
+ if (!kept || kept.identity !== identity || kept.consumed <= consumed) {
256
+ remember(cacheKey, { identity, consumed, first, active, turnClosed })
257
+ }
209
258
  return { first, closed: scope.turnId !== undefined ? turnClosed : active === undefined }
210
259
  }
211
260
 
@@ -258,7 +307,13 @@ function createSource(
258
307
  ? await completePrefix(handle, before.size, budget)
259
308
  : before.size
260
309
  }
261
- const state = await sessionState(handle, size, scope)
310
+ const state = await sessionState(
311
+ handle,
312
+ size,
313
+ scope,
314
+ `${logPath}\0${scopeKey}`,
315
+ fileIdentity(before),
316
+ )
262
317
  if (!state.closed && consistency === 'closed') {
263
318
  throw new Error('Evidence scope has an active turn; it is not closed.')
264
319
  }