memhtml 0.5.1 → 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/README.md +77 -72
- package/agent/instructions.md +34 -10
- package/dist/dist-CBhYV3up.mjs +3 -0
- package/dist/{dist-DgKlozi6.mjs → dist-D1wH0oJ0.mjs} +3353 -901
- package/dist/dist-D1wH0oJ0.mjs.map +1 -0
- package/dist/{dist-BCsav-EP.mjs → dist-DHFdTnlp.mjs} +825 -249
- package/dist/dist-DHFdTnlp.mjs.map +1 -0
- package/dist/memhtml-mcp.mjs +1280 -343
- package/dist/memhtml-mcp.mjs.map +1 -1
- package/dist/memhtml.mjs +1221 -376
- package/dist/memhtml.mjs.map +1 -1
- package/migrations/0007_watermark.sql +4 -2
- package/migrations/0011_edge_indexes.sql +78 -0
- package/migrations/0012_origin_path.sql +21 -0
- package/package.json +10 -10
- package/src/agent-build.ts +293 -21
- package/src/child-stderr.ts +36 -0
- package/src/client.ts +284 -171
- package/src/contract.ts +317 -74
- package/src/mount.ts +31 -7
- package/src/run-auth.ts +18 -15
- package/state-migrations/S0002_entity_corroboration.sql +13 -7
- package/dist/dist-BCsav-EP.mjs.map +0 -1
- package/dist/dist-DgKlozi6.mjs.map +0 -1
- package/dist/dist-DuzGralO.mjs +0 -3
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(
|
|
109
|
-
evidence: Schema.Array(CandidateEvidence).check(
|
|
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
|
|
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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* not
|
|
185
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
* ──
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* a
|
|
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
|
-
* ──
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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(),
|
|
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
|
-
* ##
|
|
6
|
+
* ## Why loopback alone is not the boundary
|
|
7
7
|
*
|
|
8
|
-
* `agent/channels/eve.ts`
|
|
9
|
-
* thing keeping the agent off the network
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
|
36
|
-
* `verifyJwtHmac` directly
|
|
37
|
-
* `principalType: "service"`, and `null`, a non-JWT string, a token
|
|
38
|
-
* an expired token, one with no `sub`, one with a foreign `sub`, and
|
|
39
|
-
* return `{ ok: false }`.
|
|
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 (
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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.
|