memhtml 0.1.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/LICENSE +201 -0
- package/README.md +531 -0
- package/agent/agent.ts +68 -0
- package/agent/channels/eve.ts +73 -0
- package/agent/instructions.md +142 -0
- package/agent/sandbox/sandbox.ts +102 -0
- package/dist/dist-Bubu4ZZa.mjs +3 -0
- package/dist/dist-CrYVXFO2.mjs +12846 -0
- package/dist/dist-CrYVXFO2.mjs.map +1 -0
- package/dist/dist-DUuomISL.mjs +2221 -0
- package/dist/dist-DUuomISL.mjs.map +1 -0
- package/dist/memhtml-mcp.mjs +4077 -0
- package/dist/memhtml-mcp.mjs.map +1 -0
- package/dist/memhtml.mjs +5009 -0
- package/dist/memhtml.mjs.map +1 -0
- package/guest/corpus.mjs +193 -0
- package/migrations/.gitkeep +0 -0
- package/migrations/0001_files.sql +111 -0
- package/migrations/0002_chunks.sql +31 -0
- package/migrations/0003_fts.sql +40 -0
- package/migrations/0004_edges.sql +40 -0
- package/migrations/0005_traces.sql +92 -0
- package/migrations/0006_sleep.sql +33 -0
- package/migrations/0007_watermark.sql +32 -0
- package/migrations/0008_tasks.sql +214 -0
- package/migrations/0009_frame_key.sql +54 -0
- package/migrations/0010_trace_consolidations.sql +45 -0
- package/package.json +59 -0
- package/src/agent-build.ts +280 -0
- package/src/client.ts +1155 -0
- package/src/contract.ts +443 -0
- package/src/index.ts +23 -0
- package/src/mount.ts +279 -0
- package/src/run-auth.ts +231 -0
- package/state-migrations/S0001_access.sql +48 -0
package/src/contract.ts
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { MEMORY_TYPES, type WritableMemoryType } from "@memhtml/contracts"
|
|
2
|
+
import { Schema } from "effect"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a consolidation run is allowed to return, and what a caller may act on.
|
|
6
|
+
*
|
|
7
|
+
* This module is the whole contract and holds no eve import, no network call, and no
|
|
8
|
+
* credential read beyond looking at `process.env` key presence. That is what lets the test
|
|
9
|
+
* tier decode every shape and exercise the preflight with no credentials and no server.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The kinds a consolidated candidate may claim, as a subset of the corpus vocabulary rather
|
|
14
|
+
* than a vocabulary of its own.
|
|
15
|
+
*
|
|
16
|
+
* `packages/contracts/src/types.ts:10-16` records why: three overlapping type vocabularies is
|
|
17
|
+
* what made the predecessor memory system's classification unanswerable. So `kind` here is a `MemoryType` value
|
|
18
|
+
* verbatim, and the next task writes it through the store with no translation step that could
|
|
19
|
+
* drift. The subset is narrower than the nine writable types because the four omitted ones
|
|
20
|
+
* cannot be earned from a transcript pattern:
|
|
21
|
+
*
|
|
22
|
+
* - `task` is work to do, not something observed to have happened.
|
|
23
|
+
* - `user_preference` is a standing instruction the user gave; inferring one from behaviour is
|
|
24
|
+
* how a corpus starts asserting preferences nobody stated.
|
|
25
|
+
* - `verdict` is a judgement this agent is not the one to pass.
|
|
26
|
+
* - `arc` is synthesized by the sleep cycle from many memories and is not writable at all.
|
|
27
|
+
*/
|
|
28
|
+
export const CONSOLIDATION_KINDS = [
|
|
29
|
+
"episodic",
|
|
30
|
+
"semantic",
|
|
31
|
+
"procedural",
|
|
32
|
+
"agent_insight",
|
|
33
|
+
"error_pattern",
|
|
34
|
+
"precedent"
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
export type ConsolidationKind = (typeof CONSOLIDATION_KINDS)[number]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compile-time proof that every kind above is a writable corpus type. If someone adds a kind
|
|
41
|
+
* that `@memhtml/contracts` does not know, or one that only the sleep cycle may write, this line
|
|
42
|
+
* stops the build instead of the next task discovering it against a real repo.
|
|
43
|
+
*/
|
|
44
|
+
const _kindsAreWritableMemoryTypes: readonly WritableMemoryType[] = CONSOLIDATION_KINDS
|
|
45
|
+
void _kindsAreWritableMemoryTypes
|
|
46
|
+
|
|
47
|
+
/** Ceiling on one evidence quote, so a "quote" cannot smuggle a whole transcript through. */
|
|
48
|
+
export const MAX_QUOTE_CHARS = 600
|
|
49
|
+
|
|
50
|
+
/** Ceiling on the prose fields, generous for a sentence and far below a transcript. */
|
|
51
|
+
export const MAX_CLAIM_CHARS = 300
|
|
52
|
+
export const MAX_GIST_CHARS = 1_500
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One transcript line the candidate rests on, tied to the session it came from.
|
|
56
|
+
*
|
|
57
|
+
* Evidence is what makes the TRACE-2 bar checkable by something other than trust: a candidate
|
|
58
|
+
* that names a cross-session pattern has to be able to point at the lines it read it from, and
|
|
59
|
+
* a reviewer can go back to `sessionId` and see whether the quote is really there.
|
|
60
|
+
*/
|
|
61
|
+
export class CandidateEvidence extends Schema.Class<CandidateEvidence>("CandidateEvidence")({
|
|
62
|
+
/**
|
|
63
|
+
* The session the quote was read from.
|
|
64
|
+
*
|
|
65
|
+
* Must be one of the ids this run made READABLE, which the schema cannot express, because a set
|
|
66
|
+
* membership over per-run values is not a schema constraint. {@link ungroundedEvidenceReason} holds
|
|
67
|
+
* that rule, applied by `runTurn` in `client.ts` after decode, where the reachable batch is in scope;
|
|
68
|
+
* a citation of an unreachable id fails the turn as a `ConsolidatorContractViolation`. All the schema
|
|
69
|
+
* itself asks for is that the field is present and non-empty, so a quote cannot be unattributed.
|
|
70
|
+
*/
|
|
71
|
+
sessionId: Schema.String.check(Schema.isMinLength(1)),
|
|
72
|
+
/** A short verbatim span from that session's transcript. */
|
|
73
|
+
quote: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_QUOTE_CHARS))
|
|
74
|
+
}) {}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One distilled candidate. Not yet a memory: the next task decides what reaches the corpus.
|
|
78
|
+
*
|
|
79
|
+
* `evidence` is `minLength(2)`, which expresses the TRACE-2 bar as a type rather than as
|
|
80
|
+
* prose the model may ignore. A pattern that spans lines or sessions has at least two lines
|
|
81
|
+
* behind it; a candidate that can only cite one is a restatement of that one line, which
|
|
82
|
+
* `agent/instructions.md` names as below the bar. Prose in the instructions asks for the bar,
|
|
83
|
+
* this refuses the turn's output without it, and the two are deliberately redundant.
|
|
84
|
+
*/
|
|
85
|
+
export class CandidateMemory extends Schema.Class<CandidateMemory>("CandidateMemory")({
|
|
86
|
+
kind: Schema.Literals(CONSOLIDATION_KINDS),
|
|
87
|
+
/** One sentence stating the pattern. */
|
|
88
|
+
claim: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_CLAIM_CHARS)),
|
|
89
|
+
/** The supporting detail: what recurs, where, and what it implies. */
|
|
90
|
+
gist: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_GIST_CHARS)),
|
|
91
|
+
/** Tools, files, commands, packages, people the claim is about. May be empty. */
|
|
92
|
+
entities: Schema.Array(Schema.String.check(Schema.isMinLength(1))),
|
|
93
|
+
evidence: Schema.Array(CandidateEvidence).check(Schema.isMinLength(2))
|
|
94
|
+
}) {}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What one run produced, what it cost in model calls, and WHICH SESSIONS IT ACTUALLY REACHED.
|
|
98
|
+
*
|
|
99
|
+
* `analyzedSessionIds` is the value a caller watermarks from rather than a reporting field. It exists
|
|
100
|
+
* because the alternative, watermarking the batch that was ASKED about, records a transcript that
|
|
101
|
+
* never arrived as consolidated and never reads it again. A batch of ten where one path has been
|
|
102
|
+
* rotated away, or sits behind a symlink the sandbox will not follow, is not ten sessions read.
|
|
103
|
+
*
|
|
104
|
+
* The field is REQUIRED rather than optional, and that is what makes the rule structural instead of
|
|
105
|
+
* advisory: nothing can produce a `ConsolidationResult` without stating what it reached, so a caller
|
|
106
|
+
* has the honest set at hand and never has to fall back on the batch. `markSessionsConsolidated`'s
|
|
107
|
+
* only correct input is this set, intersected with the batch. See
|
|
108
|
+
* `packages/sleep/src/phases/trace-consolidation.ts`.
|
|
109
|
+
*
|
|
110
|
+
* It is the set of transcripts whose files RESOLVE AT THEIR GUEST PATH inside the sandbox's
|
|
111
|
+
* read-only mount, not the set the model chose to open. Those are different claims and only the
|
|
112
|
+
* first is checkable: nothing outside the model can prove a file was read, while a file that does
|
|
113
|
+
* not resolve was categorically not read. The pre-existing semantics of a watermark, "the agent saw
|
|
114
|
+
* this session and correctly found nothing above the bar", needs exactly the first.
|
|
115
|
+
*/
|
|
116
|
+
export class ConsolidationResult extends Schema.Class<ConsolidationResult>("ConsolidationResult")({
|
|
117
|
+
candidates: Schema.Array(CandidateMemory),
|
|
118
|
+
llmCalls: Schema.Finite,
|
|
119
|
+
analyzedSessionIds: Schema.Array(Schema.String)
|
|
120
|
+
}) {}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The reason a decoded answer is not grounded in what the run made readable, or `null`.
|
|
124
|
+
*
|
|
125
|
+
* A candidate may only cite sessions THIS RUN MADE READABLE, and the schema cannot say so: a set
|
|
126
|
+
* membership over per-run values is not a schema constraint. So the check is a function of the
|
|
127
|
+
* decoded answer and the reachable ids, which is why it lives here in the contract rather than inline
|
|
128
|
+
* in the client. `client.ts` needs a live eve server to reach, and INV-3 keeps this app's test tier
|
|
129
|
+
* credential-free and server-free. Same reasoning `toJsonSchema` records for staying in this module.
|
|
130
|
+
*
|
|
131
|
+
* An id outside that set is a fabricated receipt. The id rides into the sleep phase and then into a
|
|
132
|
+
* commit message as `evidence <id>:`, where a reviewer's whole recourse is to go back to that session
|
|
133
|
+
* and check the quote is really there. An id naming a session nobody read is worse than no evidence,
|
|
134
|
+
* because it reads as provenance.
|
|
135
|
+
*
|
|
136
|
+
* **The whole TURN is refused, not the one candidate**, and that is a deliberate departure from the
|
|
137
|
+
* per-candidate isolation the sleep phase applies to its own gate. Dropping the offender here would
|
|
138
|
+
* be a lenient repair of a model answer, which is the posture `ConsolidationPayload`'s decode already
|
|
139
|
+
* refuses with `onExcessProperty: "error"`: a filtered list is indistinguishable downstream from a
|
|
140
|
+
* list the agent returned. And a fabricated id says the answer is not grounded in the batch handed
|
|
141
|
+
* over, which is a fact about the run rather than a fault in one candidate. The caller loses nothing
|
|
142
|
+
* it can act on: `ConsolidatorContractViolation` degrades the sleep phase to `ok` with the `_tag` in
|
|
143
|
+
* its detail, leaving the batch unwatermarked for the next night.
|
|
144
|
+
*/
|
|
145
|
+
export const ungroundedEvidenceReason = (
|
|
146
|
+
candidates: ReadonlyArray<{
|
|
147
|
+
readonly evidence: ReadonlyArray<{ readonly sessionId: string }>
|
|
148
|
+
}>,
|
|
149
|
+
readableSessionIds: ReadonlyArray<string>
|
|
150
|
+
): string | null => {
|
|
151
|
+
const readable = new Set(readableSessionIds)
|
|
152
|
+
for (const [offset, candidate] of candidates.entries()) {
|
|
153
|
+
const invented = candidate.evidence.find((quote) => !readable.has(quote.sessionId))
|
|
154
|
+
if (invented !== undefined) {
|
|
155
|
+
return (
|
|
156
|
+
`candidate ${String(offset)} cites session ${invented.sessionId}, which this run did ` +
|
|
157
|
+
`not make readable (${String(readable.size)} transcript(s) resolved in the sandbox)`
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* ── The origin validation that used to live here is DELETED, with the parse it defended ──────────
|
|
166
|
+
*
|
|
167
|
+
* `loopbackOriginFrom`, `nonLoopbackOrigin`, `isLoopbackHostname`, `ANSI_ESCAPE`, and
|
|
168
|
+
* `URL_CANDIDATE` existed for one caller: `startServer` spawned `eve start --port 0` and read the
|
|
169
|
+
* bound port back off the child's stdout, so the address this process posted transcripts to was a
|
|
170
|
+
* string a child wrote, and validating it as loopback was the only thing standing between "eve
|
|
171
|
+
* printed a URL" and "the batch was posted to it".
|
|
172
|
+
*
|
|
173
|
+
* `client.ts` now chooses the port itself (`reserveLoopbackPort`) and passes it to
|
|
174
|
+
* `eve start --port <n>`, so the origin is composed from a constant and an integer this process got
|
|
175
|
+
* from the kernel. There is no untrusted string in the path any more, and nothing left to validate:
|
|
176
|
+
* a "defence" over a value we constructed asserts that we typed our own constant correctly.
|
|
177
|
+
*
|
|
178
|
+
* Kept as belt-and-braces it would have been WORSE than deleted, because it would have kept
|
|
179
|
+
* asserting a threat model that no longer holds. The deletion also costs nothing in practice:
|
|
180
|
+
* the readiness poll now refuses any listener that does not answer `/eve/v1/health` as eve, which
|
|
181
|
+
* covers the reachable case (something else on the port) more directly than a hostname check on a
|
|
182
|
+
* self-composed URL ever did.
|
|
183
|
+
*
|
|
184
|
+
* One measured correction to leave behind, since the old comment asserted the opposite. It claimed
|
|
185
|
+
* eve's piped stdout carries zero ANSI escape bytes. It does not: probed 2026-08-09 with stdout
|
|
186
|
+
* redirected to a file and no TTY, a failing `eve start` emitted
|
|
187
|
+
* `ESC[90mStopping server gracefully (5s)... Press ESC[1mCtrl+CESC[22m again…ESC[39m`. So an escape
|
|
188
|
+
* on that stream is real rather than theoretical. It is simply no longer on any path that decides an
|
|
189
|
+
* address. If anything ever parses that stream again it needs the strip, and it needs the ESC byte
|
|
190
|
+
* built via `String.fromCharCode` because biome's `noControlCharactersInRegex` refuses a control
|
|
191
|
+
* character in regex source however it is spelled.
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The structured payload the agent is asked for.
|
|
196
|
+
*
|
|
197
|
+
* A wrapper object rather than a bare array: eve lowers this to the model's structured-output
|
|
198
|
+
* contract, and a top-level array leaves nowhere to say "I found nothing" that is
|
|
199
|
+
* distinguishable from a truncated answer. `candidates: []` is a real, readable result.
|
|
200
|
+
*/
|
|
201
|
+
export class ConsolidationPayload extends Schema.Class<ConsolidationPayload>(
|
|
202
|
+
"ConsolidationPayload"
|
|
203
|
+
)({
|
|
204
|
+
candidates: Schema.Array(CandidateMemory)
|
|
205
|
+
}) {}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A JSON-safe object, structurally identical to eve's own `JsonObject`
|
|
209
|
+
* (node_modules/eve/dist/src/shared/json.d.ts:12).
|
|
210
|
+
*
|
|
211
|
+
* Declared here rather than imported so `contract.ts` keeps its zero-eve-import property, which
|
|
212
|
+
* is what lets the test tier decode every shape with no server and no credentials. TypeScript is
|
|
213
|
+
* structural, so the value below is assignable to eve's `outputSchema` parameter without a cast.
|
|
214
|
+
*/
|
|
215
|
+
export type JsonValue = boolean | number | string | null | readonly JsonValue[] | JsonObject
|
|
216
|
+
export interface JsonObject {
|
|
217
|
+
readonly [key: string]: JsonValue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Derive the JSON Schema eve is handed for `outputSchema`.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately a local seven lines rather than an import of `@memhtml/llm`'s `toInputSchema`
|
|
224
|
+
* (`packages/llm/src/structured.ts:33-38`), for two reasons. It keeps the Bedrock SDK, which
|
|
225
|
+
* `@memhtml/llm` pulls in for its own client, out of this app's dependency closure, and it keeps
|
|
226
|
+
* this app's wire shape independently derived from the same effect schema, so a change in one
|
|
227
|
+
* does not silently redefine the other. The `$defs` fold is the same one `structured.ts`
|
|
228
|
+
* documents: `toJsonSchemaDocument` hoists nested structs into `definitions` and leaves
|
|
229
|
+
* `$ref: "#/$defs/<name>"` behind, so the definitions go back under the root as `$defs`.
|
|
230
|
+
*
|
|
231
|
+
* The `JSON.parse(JSON.stringify(...))` normalization does two jobs, since effect types the emitted
|
|
232
|
+
* document loosely. It proves the value really is JSON-serializable, which matters because the
|
|
233
|
+
* document crosses the wire as a request body and a non-serializable member would fail at the
|
|
234
|
+
* boundary instead of here. It also drops `undefined`-valued keys, which are not JSON and which
|
|
235
|
+
* eve's own `parseJsonValue` treats as omitted.
|
|
236
|
+
*
|
|
237
|
+
* The ROOT `$ref` is then inlined, and that step changes what a consumer reads. Measured against
|
|
238
|
+
* effect 4.0.0-beta.102: `toJsonSchemaDocument(ConsolidationPayload)` returns a root of exactly
|
|
239
|
+
* `{ $ref: "#/$defs/ConsolidationPayloadJsonEncoding", $defs: {...} }`, a root with NO `type`,
|
|
240
|
+
* NO `properties`, and nothing at all describing an object. A nested `$ref` is well-supported
|
|
241
|
+
* (`packages/llm/src/structured.ts:24-27` records it verified live against Bedrock's
|
|
242
|
+
* `input_schema`), but a root that only points elsewhere is a different shape, and a consumer that
|
|
243
|
+
* reads `schema.type` to decide how to constrain the model finds `undefined`. Rather than bet the
|
|
244
|
+
* turn on every layer between here and the model dereferencing a root pointer, the referenced
|
|
245
|
+
* definition is merged into the root and dropped from `$defs`; the remaining definitions stay put
|
|
246
|
+
* for the nested refs that point at them.
|
|
247
|
+
*/
|
|
248
|
+
export const toJsonSchema = (schema: Schema.Top): JsonObject => {
|
|
249
|
+
const document = Schema.toJsonSchemaDocument(schema)
|
|
250
|
+
const serializable = JSON.parse(
|
|
251
|
+
JSON.stringify({ ...document.schema, $defs: document.definitions })
|
|
252
|
+
) as Record<string, JsonValue>
|
|
253
|
+
|
|
254
|
+
const { $ref: rootRef, $defs: rawDefs, ...rest } = serializable
|
|
255
|
+
const defs = (rawDefs ?? {}) as Record<string, JsonValue>
|
|
256
|
+
|
|
257
|
+
const rootName =
|
|
258
|
+
typeof rootRef === "string" && rootRef.startsWith("#/$defs/")
|
|
259
|
+
? rootRef.slice("#/$defs/".length)
|
|
260
|
+
: null
|
|
261
|
+
const rootDef = rootName === null ? null : defs[rootName]
|
|
262
|
+
|
|
263
|
+
const root =
|
|
264
|
+
rootDef !== null &&
|
|
265
|
+
rootDef !== undefined &&
|
|
266
|
+
typeof rootDef === "object" &&
|
|
267
|
+
!Array.isArray(rootDef)
|
|
268
|
+
? { ...rest, ...rootDef }
|
|
269
|
+
: { ...rest, ...(rootRef === undefined ? {} : { $ref: rootRef }) }
|
|
270
|
+
|
|
271
|
+
const remaining =
|
|
272
|
+
rootName === null
|
|
273
|
+
? defs
|
|
274
|
+
: Object.fromEntries(Object.entries(defs).filter(([name]) => name !== rootName))
|
|
275
|
+
|
|
276
|
+
return (Object.keys(remaining).length === 0 ? root : { ...root, $defs: remaining }) as JsonObject
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The `outputSchema` value passed on the turn. Derived once; the schema never varies. */
|
|
280
|
+
export const CONSOLIDATION_OUTPUT_JSON_SCHEMA = toJsonSchema(ConsolidationPayload)
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* ── `DEFAULT_TAIL_BYTES` is DELETED, and so is the reason it existed ──────────────────────────────
|
|
284
|
+
*
|
|
285
|
+
* It was a 256 KiB per-file cap on how much of each transcript reached the sandbox, and the cap
|
|
286
|
+
* bounded a mechanism that is gone: the client SEEDED transcripts, so every seeded byte was
|
|
287
|
+
* resident in the server process for the session's lifetime (just-bash is a pure-JS VFS holding file
|
|
288
|
+
* content in memory), and 256 KiB x 32 files was what bounded that at 8 MiB.
|
|
289
|
+
*
|
|
290
|
+
* Transcripts now arrive on a read-only `OverlayFs` mount that reads THROUGH to the host on demand
|
|
291
|
+
* (`src/mount.ts`), so nothing is resident because nothing is copied. A 37.2 MB transcript, the
|
|
292
|
+
* measured maximum over the live corpus, now costs whatever the model actually reads of it, and eve
|
|
293
|
+
* bounds each `read_file` at 2000 lines or 50 KB
|
|
294
|
+
* (node_modules/eve/dist/src/execution/sandbox/truncate-output.js). The budget moved from the seeding
|
|
295
|
+
* path to the reader, where the model spends it deliberately.
|
|
296
|
+
*
|
|
297
|
+
* Keeping the constant would have been worse than deleting it: a 256 KiB number labelled "how many
|
|
298
|
+
* bytes reach the sandbox" is now FALSE, and a future reader would have taken it as a live limit.
|
|
299
|
+
* The distribution it was measured against is still recorded (11,360 transcripts, 6.59 GB, p50
|
|
300
|
+
* 332 KB, p90 915 KB, p99 4.68 MB, max 37.2 MB, 2026-08-08) because
|
|
301
|
+
* `packages/traces/src/parse.ts:16-21` reasons about the same shape.
|
|
302
|
+
*/
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Ceiling on transcripts per run.
|
|
306
|
+
*
|
|
307
|
+
* This one SURVIVES the seeding path's removal, and its justification changes rather than
|
|
308
|
+
* disappearing. It no longer bounds resident bytes, since the mount does not copy, but it bounds
|
|
309
|
+
* how many files one agent session is asked to hold in attention, and it is the guard against a
|
|
310
|
+
* caller handing over five thousand sessions, which is well within what one sleep cycle could find
|
|
311
|
+
* unconsolidated. The sleep phase's own `TRACE_SESSIONS_PER_RUN` is lower and binds first; this is
|
|
312
|
+
* the client's independent backstop against a different caller.
|
|
313
|
+
*/
|
|
314
|
+
export const MAX_TRANSCRIPTS_PER_RUN = 32
|
|
315
|
+
|
|
316
|
+
/** One transcript the caller wants read, named by the session it belongs to. */
|
|
317
|
+
export interface TranscriptRef {
|
|
318
|
+
readonly sessionId: string
|
|
319
|
+
readonly filePath: string
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Why a run produced nothing usable. Every constructor here is something a caller can branch
|
|
324
|
+
* on: skip the phase, fail it, or report it.
|
|
325
|
+
*
|
|
326
|
+
* Payloads carry no transcript content. A consolidator error can be logged and reported by the
|
|
327
|
+
* sleep cycle, and transcript text must not ride along into a report. That is the same posture
|
|
328
|
+
* `packages/contracts/src/errors.ts:5-8` states for storage failures.
|
|
329
|
+
*/
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* No usable credentials in the environment. Its own case, distinct from a failed call, because
|
|
333
|
+
* INV-3 turns on the caller being able to SKIP rather than fail: a run with no credentials is
|
|
334
|
+
* not a broken run, it is a run that was never possible.
|
|
335
|
+
*/
|
|
336
|
+
export class ConsolidatorCredentialsMissing extends Schema.TaggedError<ConsolidatorCredentialsMissing>()(
|
|
337
|
+
"ConsolidatorCredentialsMissing",
|
|
338
|
+
{
|
|
339
|
+
reason: Schema.String
|
|
340
|
+
}
|
|
341
|
+
) {}
|
|
342
|
+
|
|
343
|
+
/** The agent server could not be built, started, or reached. */
|
|
344
|
+
export class ConsolidatorUnavailable extends Schema.TaggedError<ConsolidatorUnavailable>()(
|
|
345
|
+
"ConsolidatorUnavailable",
|
|
346
|
+
{
|
|
347
|
+
reason: Schema.String
|
|
348
|
+
}
|
|
349
|
+
) {}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The turn reached the model and did not come back with a usable answer.
|
|
353
|
+
*
|
|
354
|
+
* One type over both failure shapes the probe found, discriminated by `phase` rather than split
|
|
355
|
+
* into two error classes, because a caller's decision is the same for both: the run produced
|
|
356
|
+
* nothing. `turn` is eve's `status: "ready"` with `outcome.status: "failed"`; `invocation` is a
|
|
357
|
+
* top-level `status: "failed"`.
|
|
358
|
+
*/
|
|
359
|
+
export class ConsolidatorRunFailed extends Schema.TaggedError<ConsolidatorRunFailed>()(
|
|
360
|
+
"ConsolidatorRunFailed",
|
|
361
|
+
{
|
|
362
|
+
phase: Schema.Literals(["invocation", "turn"]),
|
|
363
|
+
reason: Schema.String
|
|
364
|
+
}
|
|
365
|
+
) {}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The turn settled but its structured payload is not one this contract accepts: absent when a
|
|
369
|
+
* schema was requested, or present and undecodable.
|
|
370
|
+
*
|
|
371
|
+
* Kept apart from {@link ConsolidatorRunFailed} because it says something different about the
|
|
372
|
+
* agent: it answered, and the answer broke the contract. Same posture as
|
|
373
|
+
* `packages/llm/src/structured.ts:52-61`: a coerced object is indistinguishable from a real one
|
|
374
|
+
* downstream, so nothing lenient happens here.
|
|
375
|
+
*/
|
|
376
|
+
export class ConsolidatorContractViolation extends Schema.TaggedError<ConsolidatorContractViolation>()(
|
|
377
|
+
"ConsolidatorContractViolation",
|
|
378
|
+
{
|
|
379
|
+
reason: Schema.String
|
|
380
|
+
}
|
|
381
|
+
) {}
|
|
382
|
+
|
|
383
|
+
/** Everything the client wrapper can fail with. */
|
|
384
|
+
export type ConsolidatorError =
|
|
385
|
+
| ConsolidatorCredentialsMissing
|
|
386
|
+
| ConsolidatorUnavailable
|
|
387
|
+
| ConsolidatorRunFailed
|
|
388
|
+
| ConsolidatorContractViolation
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Which env vars could authenticate the Bedrock provider, in the order the provider reads them.
|
|
392
|
+
*
|
|
393
|
+
* The provider has NO default AWS credential chain, verified live in the probe: no shared
|
|
394
|
+
* config file, no SSO cache, no instance metadata, env vars only. So presence here is the whole
|
|
395
|
+
* question, and a preflight cannot be fooled by a profile that only the AWS CLI can see.
|
|
396
|
+
*/
|
|
397
|
+
const BEARER_VAR = "AWS_BEARER_TOKEN_BEDROCK"
|
|
398
|
+
const SIGV4_VARS = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] as const
|
|
399
|
+
|
|
400
|
+
const present = (env: Record<string, string | undefined>, name: string): boolean => {
|
|
401
|
+
const value = env[name]
|
|
402
|
+
return value !== undefined && value.trim() !== ""
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Whether a consolidation run could authenticate at all, without making a call.
|
|
407
|
+
*
|
|
408
|
+
* Asking cheaply matters because the provider is lazy. `createAmazonBedrock` and
|
|
409
|
+
* `provider(modelId)` both succeed with zero credentials, and nothing fails until the first
|
|
410
|
+
* request, by which time a server has been built, spawned, and handed transcripts. Verified in
|
|
411
|
+
* the probe. So the caller checks this first and skips, which is the INV-3 groundwork: CI has
|
|
412
|
+
* no credentials and must stay green.
|
|
413
|
+
*
|
|
414
|
+
* Empty-string is treated as absent. A blank export is how a credential goes missing in
|
|
415
|
+
* practice, and `""` would authenticate nothing while reading as present.
|
|
416
|
+
*
|
|
417
|
+
* This answers "could a call be attempted", never "would it be authorized". A stale or
|
|
418
|
+
* unentitled key passes here and fails at the call as {@link ConsolidatorRunFailed}, which is the
|
|
419
|
+
* honest split, since the only way to know a key works is to use it.
|
|
420
|
+
*/
|
|
421
|
+
export const hasConsolidatorCredentials = (
|
|
422
|
+
env: Record<string, string | undefined> = process.env
|
|
423
|
+
): boolean => present(env, BEARER_VAR) || SIGV4_VARS.every((name) => present(env, name))
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The message carried on {@link ConsolidatorCredentialsMissing}: which env vars would fix it.
|
|
427
|
+
*
|
|
428
|
+
* Takes no environment on purpose. It names the two accepted MECHANISMS, which never vary, and
|
|
429
|
+
* says nothing about which vars are currently set. A failure message is logged and reported by
|
|
430
|
+
* the sleep cycle, so naming the present-but-rejected variables would put credential-shaped
|
|
431
|
+
* details into a report for no diagnostic gain. Whether a given var is set is what
|
|
432
|
+
* {@link hasConsolidatorCredentials} answers.
|
|
433
|
+
*/
|
|
434
|
+
export const credentialsMissingReason = (): string =>
|
|
435
|
+
`no Bedrock credentials in the environment: set ${BEARER_VAR}, or ${SIGV4_VARS.join(" + ")}`
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Every kind is a real corpus type, restated so a reader of this file alone can see the
|
|
439
|
+
* relationship without opening `@memhtml/contracts`.
|
|
440
|
+
*/
|
|
441
|
+
export const isConsolidationKind = (value: string): value is ConsolidationKind =>
|
|
442
|
+
(CONSOLIDATION_KINDS as readonly string[]).includes(value) &&
|
|
443
|
+
(MEMORY_TYPES as readonly string[]).includes(value)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consolidator app's public surface: the contract, and the client that satisfies it.
|
|
3
|
+
*
|
|
4
|
+
* A standalone app, NOT a `@memhtml/*` library. Nothing here may be reached from a `@memhtml/*` read
|
|
5
|
+
* path, per `.erpaval` lesson `performance/synchronous-detector-on-untrusted-write-path.md` and §8 of
|
|
6
|
+
* the task packet. So no barrel in `packages/*` re-exports this, and only the sleep phase that
|
|
7
|
+
* owns trace consolidation depends on it.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./client.js"
|
|
10
|
+
export * from "./contract.js"
|
|
11
|
+
/**
|
|
12
|
+
* The mount composition is exported because `memhtml exec` builds on it: one shared helper rather than
|
|
13
|
+
* the same `MountableFs` + read-only `OverlayFs` shape written twice, and this package is where
|
|
14
|
+
* `just-bash` is a real dependency pinned to the version eve loads.
|
|
15
|
+
*/
|
|
16
|
+
export * from "./mount.js"
|
|
17
|
+
/**
|
|
18
|
+
* The run credential is exported because `agent/channels/eve.ts` imports it, and that file is compiled
|
|
19
|
+
* by eve into the SERVER process. That is a different build than this package's `tsc -b`, reaching
|
|
20
|
+
* `src/` by relative path exactly as `agent/sandbox/sandbox.ts` reaches `mount.ts`. Nothing outside
|
|
21
|
+
* this app consumes it: the client mints and signs, the channel verifies, and there is no third caller.
|
|
22
|
+
*/
|
|
23
|
+
export * from "./run-auth.js"
|