memhtml 0.6.0 → 0.7.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/src/contract.ts CHANGED
@@ -67,6 +67,38 @@ export type CommitmentActor = (typeof COMMITMENT_ACTORS)[number]
67
67
  /** Ceiling on a commitment's statement. One sentence, the same bound a claim carries. */
68
68
  export const MAX_STATEMENT_CHARS = 300
69
69
 
70
+ /**
71
+ * Ceilings on the LIST fields, so one answer is finite by contract rather than by good behavior.
72
+ *
73
+ * Every scalar field above is bounded and the lists were not, so a single turn could return an answer
74
+ * whose size only the model chose: each candidate is up to ~21 KB of prose plus its evidence, and each
75
+ * evidence quote costs a containment walk over the cited transcript in `fabricatedQuoteReason`. The
76
+ * bounds are generous against the instructions — `agent/instructions.md` calls six candidates plenty
77
+ * and asks for a handful of commitments — so a decode that trips one is an off-contract answer, not a
78
+ * thorough one.
79
+ */
80
+ export const MAX_CANDIDATES_PER_RESULT = 200
81
+ export const MAX_COMMITMENTS_PER_RESULT = 200
82
+ /** Per candidate. Two is the floor (the TRACE-2 bar); this is the matching ceiling. */
83
+ export const MAX_EVIDENCE_PER_CANDIDATE = 32
84
+ /** Per candidate. Concrete names, not an inventory of every file a session touched. */
85
+ export const MAX_ENTITIES_PER_CANDIDATE = 64
86
+
87
+ /**
88
+ * Ceiling on transcripts per run.
89
+ *
90
+ * Not a bound on resident bytes — the mount does not copy — but on how many files one agent session
91
+ * is asked to hold in attention, and the guard against a caller handing over five thousand sessions,
92
+ * which is well within what one sleep cycle could find unconsolidated. The sleep phase's own
93
+ * `TRACE_SESSIONS_PER_RUN` is lower and binds first; this is the client's independent backstop
94
+ * against a different caller.
95
+ *
96
+ * Declared with the other ceilings rather than beside the mount notes below, because
97
+ * {@link ConsolidationPayload} bounds its read receipt by it and a class body evaluates where it is
98
+ * written — a `const` declared further down would be in its temporal dead zone.
99
+ */
100
+ export const MAX_TRANSCRIPTS_PER_RUN = 32
101
+
70
102
  /**
71
103
  * One transcript line the candidate rests on, tied to the session it came from.
72
104
  *
@@ -89,6 +121,41 @@ export class CandidateEvidence extends Schema.Class<CandidateEvidence>("Candidat
89
121
  quote: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_QUOTE_CHARS))
90
122
  }) {}
91
123
 
124
+ /**
125
+ * One entity a candidate names, as a TYPE and a NAME rather than as one bare string.
126
+ *
127
+ * ## Why the type half is structural
128
+ *
129
+ * The corpus keys an entity on `(entity_type, entity_name)`, and the `entity` retrieval scope compares
130
+ * a whole `type:name` reference (`packages/index/src/scope.ts`). A reference carrying no separator is
131
+ * filed under the type `unknown` (`packages/index/src/project.ts`), which keeps the name as a handle
132
+ * and costs reachability: a memory stored under `unknown:checkout-api` answers
133
+ * `service:checkout-api` — the reference a caller would ask for — with an empty set, which is the same
134
+ * answer an absent memory gives. So a producer emitting bare names writes memories nothing can reach
135
+ * by entity.
136
+ *
137
+ * ## A required OBJECT FIELD, never a `pattern` on a string
138
+ *
139
+ * The other entity producer in this repo already ships this shape: `apps/cli/src/extraction.ts` sends
140
+ * `{type, name}` with `required: ["type", "name"]` and `additionalProperties: false` under the
141
+ * Responses API's `strict: true`, and joins the pair as `type:name`. A JSON-Schema `pattern` is not
142
+ * reliably enforced by a provider's strict-mode structured output, while a required object field is,
143
+ * so the type half arrives because the shape has nowhere else to put it.
144
+ *
145
+ * ## The type vocabulary is OPEN
146
+ *
147
+ * `type` is any non-empty term, not a literal union. memhtml does not dictate a consumer's entity
148
+ * taxonomy: the types `agent/instructions.md` offers are a prompt-level suggestion, `unknown` remains
149
+ * a valid store type, and a consumer modelling its own domain adds its own terms without a change
150
+ * here. What this schema requires is that the type is STATED, never which one it is.
151
+ */
152
+ export class CandidateEntity extends Schema.Class<CandidateEntity>("CandidateEntity")({
153
+ /** What kind of thing it is — `service`, `person`, `file`, or any other term. See the class note. */
154
+ type: Schema.String.check(Schema.isMinLength(1)),
155
+ /** Its concrete name, as the transcript spells it. */
156
+ name: Schema.String.check(Schema.isMinLength(1))
157
+ }) {}
158
+
92
159
  /**
93
160
  * One distilled candidate. Not yet a memory: the next task decides what reaches the corpus.
94
161
  *
@@ -105,8 +172,11 @@ export class CandidateMemory extends Schema.Class<CandidateMemory>("CandidateMem
105
172
  /** The supporting detail: what recurs, where, and what it implies. */
106
173
  gist: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_GIST_CHARS)),
107
174
  /** Tools, files, commands, packages, people the claim is about. May be empty. */
108
- entities: Schema.Array(Schema.String.check(Schema.isMinLength(1))),
109
- evidence: Schema.Array(CandidateEvidence).check(Schema.isMinLength(2))
175
+ entities: Schema.Array(CandidateEntity).check(Schema.isMaxLength(MAX_ENTITIES_PER_CANDIDATE)),
176
+ evidence: Schema.Array(CandidateEvidence).check(
177
+ Schema.isMinLength(2),
178
+ Schema.isMaxLength(MAX_EVIDENCE_PER_CANDIDATE)
179
+ )
110
180
  }) {}
111
181
 
112
182
  /**
@@ -165,7 +235,7 @@ export class CandidateCommitment extends Schema.Class<CandidateCommitment>("Cand
165
235
  }) {}
166
236
 
167
237
  /**
168
- * What one run produced, what it cost in model calls, and WHICH SESSIONS IT ACTUALLY REACHED.
238
+ * What one run produced, what it cost in model calls, and WHICH SESSIONS IT ACTUALLY READ.
169
239
  *
170
240
  * `analyzedSessionIds` is the value a caller watermarks from rather than a reporting field. It exists
171
241
  * because the alternative, watermarking the batch that was ASKED about, records a transcript that
@@ -173,16 +243,19 @@ export class CandidateCommitment extends Schema.Class<CandidateCommitment>("Cand
173
243
  * rotated away, or sits behind a symlink the sandbox will not follow, is not ten sessions read.
174
244
  *
175
245
  * The field is REQUIRED rather than optional, and that is what makes the rule structural instead of
176
- * advisory: nothing can produce a `ConsolidationResult` without stating what it reached, so a caller
177
- * has the honest set at hand and never has to fall back on the batch. `markSessionsConsolidated`'s
178
- * only correct input is this set, intersected with the batch. See
246
+ * advisory: nothing can produce a `ConsolidationResult` without stating what it read, so a caller has
247
+ * the honest set at hand and never has to fall back on the batch. `markSessionsConsolidated`'s only
248
+ * correct input is this set, intersected with the batch. See
179
249
  * `packages/sleep/src/phases/trace-consolidation.ts`.
180
250
  *
181
- * It is the set of transcripts whose files RESOLVE AT THEIR GUEST PATH inside the sandbox's
182
- * read-only mount, not the set the model chose to open. Those are different claims and only the
183
- * first is checkable: nothing outside the model can prove a file was read, while a file that does
184
- * not resolve was categorically not read. The pre-existing semantics of a watermark, "the agent saw
185
- * this session and correctly found nothing above the bar", needs exactly the first.
251
+ * It is the intersection of two sets, gated on the answer carrying at least one finding: the
252
+ * transcripts whose files RESOLVE AT THEIR GUEST PATH inside the sandbox's read-only mount, and the
253
+ * sessions the agent's own read receipt names. Resolution is checkable where "the model opened it" is
254
+ * not, and it is measured before the model runs so it bounds the claim rather than proving it, while
255
+ * the receipt narrows it to what the agent says it opened. Never the batch that was asked about, and
256
+ * never merely the ids the answer CITES: a barren-but-read session must advance, or every quiet
257
+ * transcript is re-read at full model cost every night. {@link watermarkableSessionIds} holds the whole
258
+ * rule, and the client logs the empty arm loudly.
186
259
  */
187
260
  export class ConsolidationResult extends Schema.Class<ConsolidationResult>("ConsolidationResult")({
188
261
  candidates: Schema.Array(CandidateMemory),
@@ -282,6 +355,151 @@ const ungroundedReason = (
282
355
  `${label} ${String(offset)} cites session ${sessionId}, which this run did ` +
283
356
  `not make readable (${String(readableCount)} transcript(s) resolved in the sandbox)`
284
357
 
358
+ /**
359
+ * Which of the reachable sessions a caller may WATERMARK from this answer.
360
+ *
361
+ * TWO conditions, and both are necessary because each covers what the other cannot.
362
+ *
363
+ * ## One: the answer must carry a finding, which is the only VERIFIED receipt
364
+ *
365
+ * Reachability is decided by this process before the model runs, so it proves the files could be read
366
+ * and never that anything read them. Quotes are the only receipt an answer carries that something
367
+ * outside the model checks: `fabricatedQuoteReason` (`client.ts`) re-reads each cited transcript and
368
+ * refuses the turn unless the quoted text is really in it. So an answer with NO candidates and NO
369
+ * commitments proves nothing and advances nothing, whatever its {@link ConsolidationPayload.readSessionIds}
370
+ * claims — a misrouted listener answering with empty lists and a full read receipt would otherwise
371
+ * watermark a batch nothing opened. The batch stays unwatermarked and the next night asks again.
372
+ *
373
+ * ## Two: the advance covers what the agent SAYS it read, intersected with what was reachable
374
+ *
375
+ * The receipt behind the quote gate is per-RUN: it proves SOME file in the batch was opened, and says
376
+ * nothing about the others. Advancing every reachable session on that receipt loses transcripts
377
+ * permanently — a turn that opens 1 of 32 and returns one candidate with two real quotes advances all
378
+ * 32, and `trace_consolidations` is an anti-join, so the other 31 are never selected again. That is the
379
+ * shape a step-budget-truncated turn takes.
380
+ *
381
+ * `readSessionIds` closes it: the agent names the sessions it opened or grepped, and only those
382
+ * advance. A barren-but-READ session still advances, which is what keeps the cost bounded — "the agent
383
+ * read it and found nothing above the bar" is the watermark's meaning, and gating each session on its
384
+ * own CITATION would re-read every quiet transcript at full model cost every night forever.
385
+ *
386
+ * The intersection is what bounds the claim. A session id the run did not make reachable cannot be
387
+ * watermarked however the answer names it, so the receipt can only ever NARROW the reachable set. That
388
+ * is the same authority `analyzedFrom` gives the client's answer against the phase's batch.
389
+ *
390
+ * ## What is still unverified, stated as the residual it is
391
+ *
392
+ * `readSessionIds` is a model CLAIM. An agent that opens one transcript and names thirty-two advances
393
+ * thirty-two, and nothing here can tell that from a thorough run — the quote gate proves reading
394
+ * happened, not how much. {@link underCitedWatermarkWarning} is what makes that shape
395
+ * visible: it compares the sessions the answer QUOTES against the sessions it claims to have read, so a
396
+ * wide claim behind a narrow set of quotes is logged rather than silent.
397
+ *
398
+ * Ids are trimmed before comparison, so a receipt whose entries carry stray whitespace still matches
399
+ * the reachable ids the manifest handed over.
400
+ *
401
+ * In the contract rather than inline in `client.ts`, matching {@link ungroundedEvidenceReason}: the
402
+ * rule is pure over the answer and the reachable ids, and the test tier exercises it with no server.
403
+ */
404
+ export const watermarkableSessionIds = (
405
+ answer: {
406
+ readonly candidates: ReadonlyArray<unknown>
407
+ readonly commitments: ReadonlyArray<unknown>
408
+ readonly readSessionIds: ReadonlyArray<string>
409
+ },
410
+ readableSessionIds: ReadonlyArray<string>
411
+ ): ReadonlyArray<string> => {
412
+ if (answer.candidates.length === 0 && answer.commitments.length === 0) return []
413
+ const read = new Set(answer.readSessionIds.map((id) => id.trim()))
414
+ return readableSessionIds.filter((id) => read.has(id))
415
+ }
416
+
417
+ /**
418
+ * The share of an ADVANCING set that must be CITED for the advance to pass without a warning.
419
+ *
420
+ * A quarter. The instructions call six candidates plenty for a batch of up to
421
+ * {@link MAX_TRANSCRIPTS_PER_RUN} transcripts, and each candidate cites at least two quotes, so an
422
+ * honest thorough turn claiming 32 sessions read cites somewhere around 4 to 12 of them and sits near
423
+ * this line; the shape this exists to surface — one candidate quoting one session while the receipt
424
+ * claims 32 — is at 3%. Set to fire rather than to stay quiet, because the log line is the only place
425
+ * the claim's breadth is measured against a verified receipt, and a warning costs a line while the
426
+ * shape it describes costs transcripts.
427
+ */
428
+ const WATERMARK_CITED_SHARE_FLOOR = 0.25
429
+
430
+ /**
431
+ * Advances smaller than this never warn.
432
+ *
433
+ * Below eight sessions the ratio carries no signal: a two-session advance with one citation is at the
434
+ * floor and is also the ordinary shape of a night with two transcripts, so warning there would train an
435
+ * operator to ignore the line by the time a claim of 32 advancing on one citation arrives. It is also
436
+ * what keeps an HONEST narrow turn quiet — a run that opens one transcript and names one advances one.
437
+ */
438
+ const WATERMARK_WARN_MIN_READABLE = 8
439
+
440
+ /**
441
+ * The warning for a watermark that advances many sessions on the citations of a small fraction of them,
442
+ * or `null` when the advance is unremarkable.
443
+ *
444
+ * This is OBSERVABILITY over the one thing {@link watermarkableSessionIds} cannot check, not a second
445
+ * gate. It changes no semantics: the advance happens either way.
446
+ *
447
+ * What it measures is the gap between two receipts of different strength. `readSessionIds` is the
448
+ * agent's own CLAIM about what it opened, and the advance is derived from it; the quotes are the
449
+ * VERIFIED half, re-read against the real transcripts by `fabricatedQuoteReason`. So an answer claiming
450
+ * thirty-two sessions read while quoting one is the shape a truncated or lazy turn takes, and it is
451
+ * indistinguishable here from a thorough run whose thirty-one quiet sessions genuinely held nothing.
452
+ * The log line is the only place that gap is visible.
453
+ *
454
+ * An HONEST narrow turn does not warn, and that follows from the advance being the claim: a turn that
455
+ * opens one transcript and names one advances one, which is below {@link WATERMARK_WARN_MIN_READABLE}.
456
+ * The line fires for a WIDE claim behind a NARROW set of quotes, which is exactly the case worth an
457
+ * operator's attention.
458
+ *
459
+ * The count is of DISTINCT cited session ids INSIDE the advancing set, because both numbers in the line
460
+ * have to name one space. A citation of a session that is not advancing — one outside the receipt, or
461
+ * one the run never made reachable — is evidence about a different set, and counting it both understates
462
+ * the uncited remainder and suppresses the line in the case it exists for: eight sessions advancing on
463
+ * the receipt alone, with two quotes naming sessions none of them, reads as a quarter cited when zero
464
+ * of the advance is. Distinct rather than per-quote, because a candidate citing one session twice is one
465
+ * session's receipt and a per-quote count would read as breadth. Pure over the answer and the readable
466
+ * ids, in the contract for the reason {@link ungroundedEvidenceReason} records: the test tier drives it
467
+ * with no server.
468
+ */
469
+ export const underCitedWatermarkWarning = (
470
+ answer: {
471
+ readonly candidates: ReadonlyArray<{
472
+ readonly evidence: ReadonlyArray<{ readonly sessionId: string }>
473
+ }>
474
+ readonly commitments: ReadonlyArray<{ readonly evidence: { readonly sessionId: string } }>
475
+ readonly readSessionIds: ReadonlyArray<string>
476
+ },
477
+ readableSessionIds: ReadonlyArray<string>
478
+ ): string | null => {
479
+ const advance = watermarkableSessionIds(answer, readableSessionIds)
480
+ const advancing = advance.length
481
+ if (advancing < WATERMARK_WARN_MIN_READABLE) return null
482
+
483
+ const advancingIds = new Set(advance)
484
+ const cited = new Set<string>()
485
+ const cite = (sessionId: string): void => {
486
+ const id = sessionId.trim()
487
+ if (advancingIds.has(id)) cited.add(id)
488
+ }
489
+ for (const candidate of answer.candidates) {
490
+ for (const quote of candidate.evidence) cite(quote.sessionId)
491
+ }
492
+ for (const commitment of answer.commitments) cite(commitment.evidence.sessionId)
493
+ if (cited.size >= advancing * WATERMARK_CITED_SHARE_FLOOR) return null
494
+
495
+ return (
496
+ `consolidation is watermarking ${String(advancing)} session(s) the agent reports having read, on ` +
497
+ `quotes from only ${String(cited.size)} of them; the other ${String(advancing - cited.size)} ` +
498
+ "advance on the reported receipt alone, and a watermarked session is never selected again. " +
499
+ "Check the turn's step budget if it should have read more."
500
+ )
501
+ }
502
+
285
503
  /**
286
504
  * Whether a quote appears in a text, compared after collapsing whitespace runs on BOTH sides.
287
505
  *
@@ -295,11 +513,48 @@ const ungroundedReason = (
295
513
  * strings — see {@link decodedTranscriptStrings} for why either alone fails honest quotes.
296
514
  */
297
515
  export const quoteAppearsIn = (quote: string, text: string): boolean => {
298
- const flatten = (value: string): string => value.replace(/\s+/g, " ").trim()
299
- const needle = flatten(quote)
516
+ const needle = flattenWhitespace(quote)
300
517
  /** An empty needle is `includes`-true against anything, which would gate nothing. */
301
518
  if (needle === "") return false
302
- return flatten(text).includes(needle)
519
+ return flattenWhitespace(text).includes(needle)
520
+ }
521
+
522
+ /** The one normalization both sides get. See {@link quoteAppearsIn} for why nothing else is. */
523
+ const flattenWhitespace = (value: string): string => value.replace(/\s+/g, " ").trim()
524
+
525
+ /**
526
+ * Containment checks for many quotes against ONE transcript, paying its normalization once.
527
+ *
528
+ * {@link quoteAppearsIn} flattens BOTH sides per call, so checking a transcript's quotes through it
529
+ * directly re-flattens the whole file once per quote — against the corpus's measured p99 of 4.68 MB
530
+ * and a 37.2 MB maximum, that is megabytes of regex work multiplied by however many quotes the model
531
+ * cited from one session. This closure flattens the raw bytes at construction and the decoded strings
532
+ * on the first quote that needs them, so the per-quote cost is one `includes` (plus one more per
533
+ * decoded string when the raw arm misses).
534
+ *
535
+ * Same two arms, same semantics as the caller composing {@link quoteAppearsIn} with
536
+ * {@link decodedTranscriptStrings}: raw bytes first because most quotes are verbatim in the source,
537
+ * decoded strings each tested SEPARATELY so a quote stitched across two messages still refuses.
538
+ * `fabricatedQuoteReason` (`client.ts`) builds one of these per cited session; a run that cites
539
+ * nothing builds none.
540
+ */
541
+ export interface TranscriptQuoteChecker {
542
+ readonly contains: (quote: string) => boolean
543
+ }
544
+
545
+ export const transcriptQuoteChecker = (transcript: string): TranscriptQuoteChecker => {
546
+ const flatRaw = flattenWhitespace(transcript)
547
+ /** Decoded lazily: a session whose every quote is verbatim in the bytes never pays for a parse. */
548
+ let flatDecoded: ReadonlyArray<string> | null = null
549
+ return {
550
+ contains: (quote) => {
551
+ const needle = flattenWhitespace(quote)
552
+ if (needle === "") return false
553
+ if (flatRaw.includes(needle)) return true
554
+ flatDecoded ??= decodedTranscriptStrings(transcript).map(flattenWhitespace)
555
+ return flatDecoded.some((text) => text.includes(needle))
556
+ }
557
+ }
303
558
  }
304
559
 
305
560
  /**
@@ -319,7 +574,7 @@ export const quoteAppearsIn = (quote: string, text: string): boolean => {
319
574
  *
320
575
  * The cost of that mismatch is not one lost commitment. `fabricatedQuoteReason` (`client.ts`) refuses
321
576
  * the WHOLE turn, so the batch produces nothing, so `markSessionsConsolidated` never runs, so the
322
- * next night selects the same batch and fails identically — an honest answer livelocking a nightly
577
+ * next run selects the same batch and fails identically — an honest answer livelocking an unattended
323
578
  * job. PR #47's review gauntlet found exactly this against real JSONL bytes.
324
579
  *
325
580
  * ## Values only, and each value SEPARATELY
@@ -383,32 +638,19 @@ export const decodedTranscriptStrings = (transcript: string): ReadonlyArray<stri
383
638
  }
384
639
 
385
640
  /**
386
- * ── The origin validation that used to live here is DELETED, with the parse it defended ──────────
387
- *
388
- * `loopbackOriginFrom`, `nonLoopbackOrigin`, `isLoopbackHostname`, `ANSI_ESCAPE`, and
389
- * `URL_CANDIDATE` existed for one caller: `startServer` spawned `eve start --port 0` and read the
390
- * bound port back off the child's stdout, so the address this process posted transcripts to was a
391
- * string a child wrote, and validating it as loopback was the only thing standing between "eve
392
- * printed a URL" and "the batch was posted to it".
393
- *
394
- * `client.ts` now chooses the port itself (`reserveLoopbackPort`) and passes it to
395
- * `eve start --port <n>`, so the origin is composed from a constant and an integer this process got
396
- * from the kernel. There is no untrusted string in the path any more, and nothing left to validate:
397
- * a "defense" over a value we constructed asserts that we typed our own constant correctly.
398
- *
399
- * Kept as belt-and-braces it would have been WORSE than deleted, because it would have kept
400
- * asserting a threat model that no longer holds. The deletion also costs nothing in practice:
401
- * the readiness poll now refuses any listener that does not answer `/eve/v1/health` as eve, which
402
- * covers the reachable case (something else on the port) more directly than a hostname check on a
403
- * self-composed URL ever did.
404
- *
405
- * One measured correction to leave behind, since the old comment asserted the opposite. It claimed
406
- * eve's piped stdout carries zero ANSI escape bytes. It does not: probed 2026-08-09 with stdout
407
- * redirected to a file and no TTY, a failing `eve start` emitted
408
- * `ESC[90mStopping server gracefully (5s)... Press ESC[1mCtrl+CESC[22m again…ESC[39m`. So an escape
409
- * on that stream is real rather than theoretical. It is simply no longer on any path that decides an
410
- * address. If anything ever parses that stream again it needs the strip, and it needs the ESC byte
411
- * built via `String.fromCharCode` because biome's `noControlCharactersInRegex` refuses a control
641
+ * ── This module holds NO origin validation, and nothing may parse a child's stdout for one ───────
642
+ *
643
+ * The server's origin is composed in `client.ts` from `LOOPBACK_HOST` and a port this process
644
+ * obtained from the kernel (`reserveLoopbackPort`), then passed to `eve start --port <n>`. No string
645
+ * a child process writes is ever on the path that decides where a transcript or a run token is sent,
646
+ * so there is no untrusted origin to validate here. The readiness poll covers the reachable hazard
647
+ * (something else on the port) by refusing any listener that does not answer `/eve/v1/health` with
648
+ * eve's own body.
649
+ *
650
+ * A constraint on anything that ever parses eve's stdout again: the stream carries ANSI escapes even
651
+ * when piped with no TTY (measured 2026-08-09, eve 0.33.0: a failing `eve start` emitted
652
+ * `ESC[90m…ESC[39m` into a redirected file). Such a parser needs an escape strip, with the ESC byte
653
+ * built via `String.fromCharCode`, because biome's `noControlCharactersInRegex` refuses a control
412
654
  * character in regex source however it is spelled.
413
655
  */
414
656
 
@@ -427,8 +669,28 @@ export const decodedTranscriptStrings = (transcript: string): ReadonlyArray<stri
427
669
  export class ConsolidationPayload extends Schema.Class<ConsolidationPayload>(
428
670
  "ConsolidationPayload"
429
671
  )({
430
- candidates: Schema.Array(CandidateMemory),
431
- commitments: Schema.Array(CandidateCommitment)
672
+ candidates: Schema.Array(CandidateMemory).check(Schema.isMaxLength(MAX_CANDIDATES_PER_RESULT)),
673
+ commitments: Schema.Array(CandidateCommitment).check(
674
+ Schema.isMaxLength(MAX_COMMITMENTS_PER_RESULT)
675
+ ),
676
+ /**
677
+ * The `sessionId` of every session the agent opened or grepped: the PER-SESSION READ RECEIPT the
678
+ * watermark advances over.
679
+ *
680
+ * REQUIRED, and that is what makes it a receipt rather than a hint. An optional field would let an
681
+ * agent that reported nothing be indistinguishable from one that read nothing, and the fallback for
682
+ * an absent receipt is the whole reachable set — which is exactly the advance this field exists to
683
+ * narrow. Nothing downstream defaults it.
684
+ *
685
+ * Bounded by {@link MAX_TRANSCRIPTS_PER_RUN}, because a run mounts at most that many transcripts, so
686
+ * a longer list names sessions no run was handed.
687
+ *
688
+ * {@link watermarkableSessionIds} intersects it with the reachable set, so an id outside that set is
689
+ * INERT. The whole turn is not refused for one, unlike a fabricated EVIDENCE id
690
+ * ({@link ungroundedEvidenceReason}): that one rides into a commit message as provenance a reviewer
691
+ * trusts, while this one changes nothing a caller can act on.
692
+ */
693
+ readSessionIds: Schema.Array(Schema.String).check(Schema.isMaxLength(MAX_TRANSCRIPTS_PER_RUN))
432
694
  }) {}
433
695
 
434
696
  /**
@@ -507,39 +769,20 @@ export const toJsonSchema = (schema: Schema.Top): JsonObject => {
507
769
  export const CONSOLIDATION_OUTPUT_JSON_SCHEMA = toJsonSchema(ConsolidationPayload)
508
770
 
509
771
  /**
510
- * ── `DEFAULT_TAIL_BYTES` is DELETED, and so is the reason it existed ──────────────────────────────
511
- *
512
- * It was a 256 KiB per-file cap on how much of each transcript reached the sandbox, and the cap
513
- * bounded a mechanism that is gone: the client SEEDED transcripts, so every seeded byte was
514
- * resident in the server process for the session's lifetime (just-bash is a pure-JS VFS holding file
515
- * content in memory), and 256 KiB x 32 files was what bounded that at 8 MiB.
516
- *
517
- * Transcripts now arrive on a read-only `OverlayFs` mount that reads THROUGH to the host on demand
518
- * (`src/mount.ts`), so nothing is resident because nothing is copied. A 37.2 MB transcript, the
519
- * measured maximum over the live corpus, now costs whatever the model actually reads of it, and eve
520
- * bounds each `read_file` at 2000 lines or 50 KB
521
- * (node_modules/eve/dist/src/execution/sandbox/truncate-output.js). The budget moved from the seeding
522
- * path to the reader, where the model spends it deliberately.
523
- *
524
- * Keeping the constant would have been worse than deleting it: a 256 KiB number labelled "how many
525
- * bytes reach the sandbox" is now FALSE, and a future reader would have taken it as a live limit.
526
- * The distribution it was measured against is still recorded (11,360 transcripts, 6.59 GB, p50
527
- * 332 KB, p90 915 KB, p99 4.68 MB, max 37.2 MB, 2026-08-08) because
528
- * `packages/traces/src/parse.ts:16-21` reasons about the same shape.
772
+ * ── There is deliberately NO per-file byte cap on what a transcript exposes to the sandbox ───────
773
+ *
774
+ * Transcripts arrive on a read-only `OverlayFs` mount that reads THROUGH to the host on demand
775
+ * (`src/mount.ts`), so nothing is resident in the server process and there is no seeded byte count
776
+ * to bound. A large transcript costs whatever the model actually reads of it, and eve bounds each
777
+ * `read_file` at 2000 lines or 50 KB
778
+ * (node_modules/eve/dist/src/execution/sandbox/truncate-output.js), so the budget sits with the
779
+ * reader, spent deliberately per call.
780
+ *
781
+ * The corpus distribution this holds against, measured 2026-08-08: 11,360 transcripts, 6.59 GB, p50
782
+ * 332 KB, p90 915 KB, p99 4.68 MB, max 37.2 MB. `packages/traces/src/parse.ts:16-21` reasons about
783
+ * the same shape.
529
784
  */
530
785
 
531
- /**
532
- * Ceiling on transcripts per run.
533
- *
534
- * This one SURVIVES the seeding path's removal, and its justification changes rather than
535
- * disappearing. It no longer bounds resident bytes, since the mount does not copy, but it bounds
536
- * how many files one agent session is asked to hold in attention, and it is the guard against a
537
- * caller handing over five thousand sessions, which is well within what one sleep cycle could find
538
- * unconsolidated. The sleep phase's own `TRACE_SESSIONS_PER_RUN` is lower and binds first; this is
539
- * the client's independent backstop against a different caller.
540
- */
541
- export const MAX_TRANSCRIPTS_PER_RUN = 32
542
-
543
786
  /** One transcript the caller wants read, named by the session it belongs to. */
544
787
  export interface TranscriptRef {
545
788
  readonly sessionId: string
package/src/mount.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process"
2
2
  import { mkdtempSync, statSync } from "node:fs"
3
+ import { rm } from "node:fs/promises"
3
4
  import { tmpdir } from "node:os"
4
5
  import { join, normalize } from "node:path"
5
6
  import { promisify } from "node:util"
@@ -12,13 +13,16 @@ import { InMemoryFs, MountableFs, OverlayFs } from "just-bash"
12
13
  * Two consumers need the SAME shape and it is built once here: this app's consolidator, which
13
14
  * mounts the transcript root so the agent reads transcripts off a filesystem, and `memhtml exec`, which
14
15
  * mounts the memory corpus so a sandboxed script can traverse it. The module lives in
15
- * `apps/consolidator` because that is where `just-bash` is a real dependency, pinned to 3.2.0,
16
- * the version eve 0.33.0 loads through its own optional-package path
17
- * (node_modules/eve/dist/src/execution/sandbox/bindings/just-bash-runtime.js). `memhtml exec` imports it
18
- * from `@memhtml/consolidator`.
16
+ * `apps/consolidator` because that is where `just-bash` is a real dependency (pinned in
17
+ * `package.json`, inside the `^3.0.0` range eve loads through its own optional-package path,
18
+ * node_modules/eve/dist/src/execution/sandbox/bindings/just-bash-runtime.js). `memhtml exec` imports
19
+ * it from `@memhtml/consolidator`.
19
20
  *
20
- * Every fact below was measured against just-bash 3.2.0 on 2026-08-09, re-probing the 2026-08
21
- * spike's findings rather than citing them.
21
+ * The facts below were first measured against just-bash 3.2.0 (2026-08-09). The load-bearing ones
22
+ * EROFS on write, symlinks not followed, the mountPoint spellings, nesting/trailing-slash refusals,
23
+ * the base surviving — are re-proven against the INSTALLED just-bash on every run of
24
+ * `tests/mount.test.ts`, so a behavior change in an upgrade fails there rather than aging in this
25
+ * comment.
22
26
  */
23
27
 
24
28
  /**
@@ -231,6 +235,17 @@ export const decodeSandboxMounts = (
231
235
  return roots
232
236
  }
233
237
 
238
+ /**
239
+ * The temp directory prefix a pinned snapshot lives under, named once so the `mkdtemp` and the sweep
240
+ * that reclaims an orphan cannot drift.
241
+ *
242
+ * Exported because the sweep is `client.ts`'s — one startup sweep covers every temp prefix this app
243
+ * creates, and it matches literal prefixes rather than a glob, so each prefix has to be a value it can
244
+ * import. {@link pinCorpusSnapshot} is reached on the `memhtml exec` path, where a SIGKILL leaves the
245
+ * mkdtemp parent behind with no finalizer able to reach it.
246
+ */
247
+ export const CORPUS_SNAPSHOT_TMPDIR_PREFIX = "memhtml-corpus-snapshot-"
248
+
234
249
  /** A materialized commit, and how to remove it. */
235
250
  export interface CorpusSnapshot {
236
251
  /** The detached worktree's directory, suitable as a {@link ReadOnlyRoot} `hostPath`. */
@@ -259,7 +274,7 @@ export const pinCorpusSnapshot = async (input: {
259
274
  readonly repoRoot: string
260
275
  readonly sha: string
261
276
  }): Promise<CorpusSnapshot> => {
262
- const parent = mkdtempSync(join(tmpdir(), "memhtml-corpus-snapshot-"))
277
+ const parent = mkdtempSync(join(tmpdir(), CORPUS_SNAPSHOT_TMPDIR_PREFIX))
263
278
  const hostPath = join(parent, "tree")
264
279
  await run("git", ["-C", input.repoRoot, "worktree", "add", "--detach", hostPath, input.sha])
265
280
 
@@ -274,6 +289,15 @@ export const pinCorpusSnapshot = async (input: {
274
289
  await run("git", ["-C", input.repoRoot, "worktree", "remove", "--force", hostPath]).catch(
275
290
  () => {}
276
291
  )
292
+ /**
293
+ * The mkdtemp PARENT is this function's to remove, and it is a second step because `git worktree
294
+ * remove` deletes only the tree it was handed. Releasing without it leaves one empty
295
+ * `${CORPUS_SNAPSHOT_TMPDIR_PREFIX}*` directory per `memhtml exec` on the CLEAN path, where
296
+ * nothing failed and nothing looks wrong. Unconditional on the git call's outcome: a worktree
297
+ * that could not be removed is a stale administrative entry `git worktree prune` reclaims, and
298
+ * keeping the directory around does not fix it.
299
+ */
300
+ await rm(parent, { recursive: true, force: true }).catch(() => {})
277
301
  }
278
302
  }
279
303
  }
package/src/run-auth.ts CHANGED
@@ -3,19 +3,19 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
3
3
  /**
4
4
  * The per-run credential the agent server demands and the client presents.
5
5
  *
6
- * ## What this replaces, and why the composition was worse than either half
6
+ * ## Why loopback alone is not the boundary
7
7
  *
8
- * `agent/channels/eve.ts` used to authenticate every caller anonymously with `none()`, and the only
9
- * thing keeping the agent off the network was the bind address. Loopback is not an authorization
10
- * boundary on a shared host: any local UID could drive the session endpoint for a run's duration,
11
- * which is free Opus tokens plus a bash sandbox. That alone was rated MEDIUM (CWE-306).
8
+ * An anonymous channel — `none()` in `agent/channels/eve.ts` leaves the bind address as the only
9
+ * thing keeping the agent off the network, and loopback is not an authorization boundary on a shared
10
+ * host: any local UID can drive the session endpoint for a run's duration, which is free Opus tokens
11
+ * plus a bash sandbox. That alone rates MEDIUM (CWE-306).
12
12
  *
13
13
  * The sandbox half is what makes it more than that. The sandbox has FULL network egress and this app
14
14
  * cannot turn it off: `network:{dangerouslyAllowFullInternetAccess:!0}` is a hardcoded literal in
15
15
  * node_modules/eve/dist/src/execution/sandbox/bindings/just-bash-runtime.js, and
16
16
  * `justBashSetNetworkPolicyUnsupported()` throws by design. Measured 2026-08-09
17
17
  * (`node scripts/probe-sandbox-egress.mjs`): `curl` reaches example.com, an IMDSv2 token PUT returns
18
- * 56 bytes, and the instance-role name comes back. So the unauthenticated endpoint was a handle on a
18
+ * 56 bytes, and the instance-role name comes back. So an unauthenticated endpoint is a handle on a
19
19
  * sandbox that reaches IMDS. `agent/sandbox/sandbox.ts` records that egress cannot be closed here;
20
20
  * this module closes the handle.
21
21
  *
@@ -32,11 +32,12 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
32
32
  * eve out of `src/`'s import graph so the test tier stays server-free. TypeScript is structural, so
33
33
  * the value {@link runVerifierConfig} returns is assignable to `jwtHmac`'s parameter with no cast.
34
34
  *
35
- * Every claim and bound below was verified against the installed eve 0.33.0 by driving
36
- * `verifyJwtHmac` directly (2026-08-14): a token from {@link signRunToken} verifies as
37
- * `principalType: "service"`, and `null`, a non-JWT string, a token signed with a different secret,
38
- * an expired token, one with no `sub`, one with a foreign `sub`, and one with a foreign `aud` each
39
- * return `{ ok: false }`. `tests/run-auth.test.ts` is that probe kept as a test.
35
+ * Every claim and bound below is re-proven against the INSTALLED eve on every run of
36
+ * `tests/run-auth.test.ts`, which drives eve's own `verifyJwtHmac` directly: a token from
37
+ * {@link signRunToken} verifies as `principalType: "service"`, and `null`, a non-JWT string, a token
38
+ * signed with a different secret, an expired token, one with no `sub`, one with a foreign `sub`, and
39
+ * one with a foreign `aud` each return `{ ok: false }`. An eve upgrade that changes any of it fails
40
+ * there rather than aging in this comment.
40
41
  */
41
42
 
42
43
  /**
@@ -193,10 +194,12 @@ const segment = (value: unknown): string =>
193
194
  * Sign one short-lived bearer token for the run.
194
195
  *
195
196
  * Hand-rolled over `node:crypto` because eve exports NO signer: `jwtHmac`, `verifyJwtHmac`, and the
196
- * jose bundle behind them are verify-only on the public surface (checked across every subpath export
197
- * of eve 0.33.0, 46 of them), so the alternative to these six lines is a new dependency for one HMAC. The claims
198
- * are the ones {@link runVerifierConfig} matches, which is the whole correctness condition and the
199
- * reason both live in this module.
197
+ * jose bundle behind them are verify-only on the public surface (measured on eve 0.33.0 across all
198
+ * 46 subpath exports; not re-checked per upgrade if a later eve ships a signer this stays merely
199
+ * redundant, not wrong, and `tests/run-auth.test.ts` keeps proving the verifier accepts these
200
+ * tokens). The alternative to these six lines is a new dependency for one HMAC. The claims are the
201
+ * ones {@link runVerifierConfig} matches, which is the whole correctness condition and the reason
202
+ * both live in this module.
200
203
  *
201
204
  * `exp` is derived from the call, not from the spawn, so each call produces a token valid
202
205
  * {@link TOKEN_TTL_SECONDS} from now, which is what makes the per-request function form work.