@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
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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 {
|
|
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({
|
|
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({
|
|
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
|
}
|
package/src/sandbox/file-walk.ts
CHANGED
|
@@ -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
|
-
|
|
331
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
}
|