@owlmeans/agent 0.1.18-rc.39 → 0.1.18-rc.41

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.
@@ -0,0 +1,827 @@
1
+ import {
2
+ CumulativeResultSource, DEFAULT_RESULT_SUMMARY_CHARS, DEFAULT_RESULTS_MAX_CHARS,
3
+ DEFAULT_RESULTS_WINDOW, RESULTS_EVERY_STEP, ResultViewMode,
4
+ compareResultOrder, factKey, orderPipelineSteps, pipelineAncestry, renderResultEntry,
5
+ renderResultFact, renderResultSummary, resultLabel, rootRunOf, sortFacts,
6
+ } from '@owlmeans/agent-common'
7
+ import type {
8
+ CumulativeResultEntry, CumulativeResultFact, CumulativeResultsSpec, PipelineSpec, PipelineState,
9
+ ResultSummarySpec, StepResultsSpec,
10
+ } from '@owlmeans/agent-common'
11
+ import { prefixHash } from '@owlmeans/llm'
12
+ import type { CumulativeResults } from '@owlmeans/llm-common'
13
+ import { createMemoryCumulativeResultStore } from '../stores/memory.js'
14
+ import type { CumulativeResultStore } from '../stores/types.js'
15
+ import type {
16
+ PipelineEnterMode, PipelineParentRef, PipelinePlugin, PipelineRunContext, StepResults,
17
+ VisibleResultEntry,
18
+ } from './types.js'
19
+
20
+ /** The plugin's alias. Seated under it, so wiring it twice replaces rather than doubles. */
21
+ export const CUMULATIVE_RESULTS_PLUGIN = 'cumulative-results'
22
+
23
+ /** The answer shape a summary is asked for when its declaration names none. */
24
+ export const DEFAULT_RESULT_SUMMARY_SCHEMA: Record<string, unknown> = {
25
+ type: 'object',
26
+ properties: { summary: { type: 'string' } },
27
+ required: ['summary'],
28
+ additionalProperties: false,
29
+ }
30
+
31
+ /** What a declaration resolver, a seed and a ledger resolver are told about the run. */
32
+ export interface ResultsRunInfo<S extends PipelineState, C> {
33
+ pipeline: string
34
+ spec: PipelineSpec
35
+ runId: string
36
+ scope: string
37
+ entityId?: string
38
+ deps: C
39
+ state: Readonly<S>
40
+ mode: PipelineEnterMode
41
+ parent?: PipelineParentRef
42
+ }
43
+
44
+ /** What an extractor reads facts from. */
45
+ export interface ResultExtractInput<S extends PipelineState, C> {
46
+ pipeline: string
47
+ runId: string
48
+ step: string
49
+ /** Durable: the run's state, restored from its row on a resume. */
50
+ state: Readonly<S>
51
+ deps: C
52
+ /** Every file the entry answers for — its durable scope and whatever its step recorded. */
53
+ files: readonly string[]
54
+ /** What was recorded as touched on THIS pass — empty on a rebuild. */
55
+ changed: readonly string[]
56
+ }
57
+
58
+ export type ResultScopeInput<S extends PipelineState, C> = Omit<
59
+ ResultExtractInput<S, C>, 'files' | 'changed'
60
+ >
61
+
62
+ /**
63
+ * Facts from files. DETERMINISTIC — the same inputs give the same facts — and never a model
64
+ * call: an extractor is what makes a fact a fact, and a rebuild after a crash must find exactly
65
+ * what the first pass found.
66
+ */
67
+ export type ResultExtractFn<S extends PipelineState, C> = (
68
+ input: ResultExtractInput<S, C>,
69
+ ) => Promise<CumulativeResultFact[]> | CumulativeResultFact[]
70
+
71
+ export interface ResultExtractorSpec<S extends PipelineState, C> {
72
+ /**
73
+ * The files this extractor answers for, recomputed from DURABLE inputs — the state's keys, the
74
+ * file tree — and never from what a step happened to report. It is what a rebuild reads when
75
+ * nothing was recorded, so a step's facts can be found again after the process that ran it died.
76
+ */
77
+ scope?: (input: ResultScopeInput<S, C>) => Promise<string[]> | string[]
78
+ extract: ResultExtractFn<S, C>
79
+ }
80
+
81
+ export type ResultExtractor<S extends PipelineState, C> =
82
+ ResultExtractFn<S, C> | ResultExtractorSpec<S, C>
83
+
84
+ /** Facts known before any step of the run ran. Called on every entry of the run; deterministic. */
85
+ export type ResultSeed<S extends PipelineState, C> = (
86
+ run: ResultsRunInfo<S, C>,
87
+ ) => Promise<CumulativeResultFact[]> | CumulativeResultFact[]
88
+
89
+ /** Everything a summarize callback needs, including a ready prompt. */
90
+ export interface ResultSummaryRequest<S extends PipelineState, C> extends ResultSummarySpec {
91
+ schema: Record<string, unknown>
92
+ maxChars: number
93
+ pipeline: string
94
+ runId: string
95
+ step: string
96
+ label: string
97
+ facts: readonly CumulativeResultFact[]
98
+ files: readonly string[]
99
+ state: Readonly<S>
100
+ deps: C
101
+ signal: AbortSignal
102
+ /** The instructions, the step's facts and its files, as one deterministic prompt. */
103
+ prompt: string
104
+ }
105
+
106
+ /**
107
+ * Ask a model for a step's summary; return its schema-shaped answer.
108
+ *
109
+ * Typically `request => model.invoke(request.prompt, request.schema, { action })` on a model
110
+ * resolved for `request.role`. A throw is a warning and the entry goes without a summary.
111
+ */
112
+ export type ResultSummarizer<S extends PipelineState, C> = (
113
+ request: ResultSummaryRequest<S, C>,
114
+ ) => Promise<unknown>
115
+
116
+ export interface CumulativeResultsPluginOptions<S extends PipelineState, C> {
117
+ alias?: string
118
+ order?: number
119
+ /**
120
+ * The declaration, or a function of the run that returns one — or `null`/`false` to leave THIS
121
+ * run without results. Resolved once, on `enter`.
122
+ */
123
+ spec:
124
+ | CumulativeResultsSpec
125
+ | ((run: ResultsRunInfo<S, C>) =>
126
+ CumulativeResultsSpec | null | false | undefined
127
+ | Promise<CumulativeResultsSpec | null | false | undefined>)
128
+ /** Named deterministic extractors, referenced by name from `StepResultsSpec.extractors`. */
129
+ extractors?: Record<string, ResultExtractor<S, C>>
130
+ /** Named facts available before any step ran. Visible to every step, in name order. */
131
+ seeds?: Record<string, ResultSeed<S, C>>
132
+ /**
133
+ * Where entries live. A composed pipeline must be given the SAME store as the pipeline it is a
134
+ * step of, or neither sees what the other produced. Default: an in-process memory store of this
135
+ * plugin instance.
136
+ */
137
+ store?: CumulativeResultStore
138
+ /** Called only for a step whose declaration has a `summary`. */
139
+ summarize?: ResultSummarizer<S, C>
140
+ /** The ledger a run's entries belong to. Default: the parent's, else the root run's id. */
141
+ ledger?: (run: ResultsRunInfo<S, C>) => string
142
+ trace?: (line: string) => void
143
+ }
144
+
145
+ interface Recorded {
146
+ files: Set<string>
147
+ facts: CumulativeResultFact[]
148
+ }
149
+
150
+ interface Item {
151
+ entry: CumulativeResultEntry
152
+ mode: ResultViewMode
153
+ /** Named as a full consumer by its producer — the last thing a budget touches. */
154
+ explicit: boolean
155
+ dropped: boolean
156
+ }
157
+
158
+ interface RunLedger<C> {
159
+ results: CumulativeResultsSpec
160
+ spec: PipelineSpec
161
+ runId: string
162
+ ledger: string
163
+ trail: number[]
164
+ index: Map<string, number>
165
+ entries: Map<string, CumulativeResultEntry>
166
+ upstream: readonly VisibleResultEntry[]
167
+ seeds: CumulativeResultEntry[]
168
+ views: Map<string, StepResults>
169
+ recorded: Map<string, Recorded>
170
+ deps: C
171
+ }
172
+
173
+ interface EntryParts {
174
+ facts: CumulativeResultFact[]
175
+ files: string[]
176
+ source: CumulativeResultSource
177
+ order: number[]
178
+ spec?: StepResultsSpec
179
+ partial?: boolean
180
+ summary?: string
181
+ }
182
+
183
+ const SEPARATOR = '\n\n'
184
+
185
+ const compare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0
186
+
187
+ const keyOf = (runId: string, step: string): string => `${runId}\u0000${step}`
188
+
189
+ const normalizePath = (path: string): string =>
190
+ path.trim().replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/{2,}/g, '/')
191
+
192
+ const uniqueSorted = (paths: Iterable<string>): string[] =>
193
+ [...new Set([...paths].map(normalizePath).filter(path => path !== ''))].sort(compare)
194
+
195
+ const pathsOf = (facts: readonly CumulativeResultFact[]): string[] =>
196
+ facts.flatMap(fact => fact.path != null && fact.path !== '' ? [fact.path] : [])
197
+
198
+ /** First occurrence of each fact key wins — a step's own recorded facts come first. */
199
+ const dedupe = (facts: readonly CumulativeResultFact[]): CumulativeResultFact[] => {
200
+ const seen = new Set<string>()
201
+
202
+ return facts.filter(fact => {
203
+ const key = factKey(fact)
204
+ if (seen.has(key)) {
205
+ return false
206
+ }
207
+ seen.add(key)
208
+
209
+ return true
210
+ })
211
+ }
212
+
213
+ const copyFact = (fact: CumulativeResultFact): CumulativeResultFact => ({
214
+ ...fact,
215
+ ...(fact.members != null ? { members: [...fact.members] } : {}),
216
+ ...(fact.attrs != null ? { attrs: { ...fact.attrs } } : {}),
217
+ })
218
+
219
+ /**
220
+ * Deep-frozen, because an entry is handed out by reference — to every view that shows it and to
221
+ * every caller of `entries()` — and one caller editing it in place would edit all of them.
222
+ */
223
+ const freezeEntry = (entry: CumulativeResultEntry): CumulativeResultEntry => Object.freeze({
224
+ ...entry,
225
+ order: Object.freeze([...entry.order]),
226
+ files: Object.freeze([...entry.files]),
227
+ facts: Object.freeze(entry.facts.map(fact => Object.freeze(copyFact(fact)))),
228
+ ...(Array.isArray(entry.fullFor) ? { fullFor: Object.freeze([...entry.fullFor]) } : {}),
229
+ ...(entry.omitFor != null ? { omitFor: Object.freeze([...entry.omitFor]) } : {}),
230
+ }) as CumulativeResultEntry
231
+
232
+ const hasExtractors = (spec: StepResultsSpec | undefined): boolean =>
233
+ (spec?.extractors?.length ?? 0) > 0
234
+
235
+ const extractFn = <S extends PipelineState, C>(extractor: ResultExtractor<S, C>): ResultExtractFn<S, C> =>
236
+ typeof extractor === 'function' ? extractor : extractor.extract
237
+
238
+ const summaryPrompt = (
239
+ spec: ResultSummarySpec, label: string, facts: readonly CumulativeResultFact[],
240
+ files: readonly string[], maxChars: number,
241
+ ): string => [
242
+ spec.instructions.trim(),
243
+ `The step "${label}" has just finished. For the later steps of the same work, summarize what it `
244
+ + 'decided or produced that the facts below do not already say. Do not repeat the facts and do not '
245
+ + `guess at anything not shown. At most ${maxChars} characters.`,
246
+ `# Facts read from the files after the step finished\n${facts.map(renderResultFact).join('\n') || '(none)'}`,
247
+ `# Files the step answers for\n${files.map(file => `- ${file}`).join('\n') || '(none)'}`,
248
+ ].join(SEPARATOR)
249
+
250
+ /**
251
+ * CUMULATIVE PIPELINE RESULTS: every step is told, in its prompt and through a typed query, what
252
+ * the steps before it produced — read from the files by code, not recalled by a model.
253
+ *
254
+ * - `enter` decides the LEDGER the run's entries belong to (its parent's for a composed run, else
255
+ * the root run's id). A fresh start or a restart forgets the run's own earlier entries; a
256
+ * continuation loads them and silently REBUILDS any that are missing from the finished steps'
257
+ * durable inputs — so a resume never depends on the ledger having survived, only the quality
258
+ * of what the next step is told does.
259
+ * - `beforeStep` cuts the step's VIEW once: every predecessor's entry, full within the window or
260
+ * when the producer names the step, names only otherwise, inside a character budget that drops
261
+ * the oldest entries to names only and then out — never cutting an entry's own text.
262
+ * - `afterStep` extracts the step's entry from its durable scope and what it recorded, leaves any
263
+ * fact an earlier entry already owns to that entry, re-extracts earlier entries whose files the
264
+ * step rewrote, and persists — all before the runner marks the step complete.
265
+ * - `passStep` gives a skipped step the entry its files still support, and an optional step that
266
+ * failed a partial one.
267
+ *
268
+ * Everything here is an enhancement. A store that fails, an extractor that throws or a summary
269
+ * that cannot be had costs a step some context and is logged; it never fails the run.
270
+ */
271
+ export const cumulativeResultsPlugin = <S extends PipelineState, C>(
272
+ options: CumulativeResultsPluginOptions<S, C>,
273
+ ): PipelinePlugin<S, C> => {
274
+ const alias = options.alias ?? CUMULATIVE_RESULTS_PLUGIN
275
+ const store = options.store ?? createMemoryCumulativeResultStore()
276
+ const trace = options.trace ?? (() => undefined)
277
+ const runs = new Map<string, RunLedger<C>>()
278
+
279
+ const warn = (what: string, e?: unknown): void => {
280
+ console.warn(`Cumulative results (${alias}): ${what}`, ...(e != null ? [e] : []))
281
+ }
282
+
283
+ const say = (run: RunLedger<C>, line: string): void => {
284
+ trace(`[results:${run.spec.alias}:${run.runId}] ${line}`)
285
+ }
286
+
287
+ const orderOf = (run: RunLedger<C>, step: string): number[] =>
288
+ [...run.trail, run.index.get(step) ?? 0]
289
+
290
+ /**
291
+ * The ledger as the store holds it — except this run's own entries, which only this plugin writes
292
+ * and whose in-memory copy is therefore never older than the stored one (a failed `put` leaves
293
+ * the store behind, never ahead). A store that cannot be read leaves what is held untouched.
294
+ */
295
+ const reload = async (run: RunLedger<C>): Promise<void> => {
296
+ let listed: CumulativeResultEntry[]
297
+ try {
298
+ listed = await store.list(run.ledger)
299
+ } catch (e) {
300
+ warn(`could not read ledger ${run.ledger}`, e)
301
+ return
302
+ }
303
+ const next = new Map<string, CumulativeResultEntry>()
304
+ for (const entry of listed) {
305
+ next.set(keyOf(entry.runId, entry.step), freezeEntry(entry))
306
+ }
307
+ for (const [key, entry] of run.entries) {
308
+ if (entry.runId === run.runId) {
309
+ next.set(key, entry)
310
+ }
311
+ }
312
+ run.entries = next
313
+ }
314
+
315
+ const persist = async (run: RunLedger<C>, entry: CumulativeResultEntry): Promise<void> => {
316
+ run.entries.set(keyOf(entry.runId, entry.step), entry)
317
+ try {
318
+ // A plain copy: a store adapter may annotate what it is given (an `_id`), and the held entry
319
+ // is frozen.
320
+ await store.put(JSON.parse(JSON.stringify(entry)) as CumulativeResultEntry)
321
+ } catch (e) {
322
+ warn(`could not store ${entry.label}; a resume will rebuild it`, e)
323
+ }
324
+ }
325
+
326
+ /** The step of THIS run an entry belongs to: its own step, or the step a composed run ran under. */
327
+ const anchorOf = (run: RunLedger<C>, entry: CumulativeResultEntry): string | null => {
328
+ if (entry.runId === run.runId) {
329
+ return entry.step
330
+ }
331
+ if (entry.runId.startsWith(`${run.runId}/`)) {
332
+ return entry.runId.slice(run.runId.length + 1).split('/')[0] ?? null
333
+ }
334
+
335
+ return null
336
+ }
337
+
338
+ const modeFor = (
339
+ run: RunLedger<C>, entry: CumulativeResultEntry, consumer: string, depth: number,
340
+ ): Pick<Item, 'mode' | 'explicit'> => {
341
+ if (entry.omitFor?.includes(consumer) === true) {
342
+ return { mode: ResultViewMode.Omitted, explicit: false }
343
+ }
344
+ if (entry.fullFor === RESULTS_EVERY_STEP) {
345
+ return { mode: ResultViewMode.Full, explicit: false }
346
+ }
347
+ if (Array.isArray(entry.fullFor) && entry.fullFor.includes(consumer)) {
348
+ return { mode: ResultViewMode.Full, explicit: true }
349
+ }
350
+ const window = entry.window ?? run.results.window ?? DEFAULT_RESULTS_WINDOW
351
+
352
+ return { mode: depth <= window ? ResultViewMode.Full : ResultViewMode.Compact, explicit: false }
353
+ }
354
+
355
+ /**
356
+ * Every entry a step can see, oldest first, as its producer's declaration shows it: what the run
357
+ * was handed by the step it is composed under, the seeds, then every predecessor's entry and the
358
+ * entries of runs composed under a predecessor. Nothing else — a sibling branch has not
359
+ * necessarily run, so what it says cannot be what a step relies on.
360
+ */
361
+ const visibleTo = (run: RunLedger<C>, step: string): Item[] => {
362
+ const depths = new Map(pipelineAncestry(run.spec, step).map(({ step: name, depth }) => [name, depth]))
363
+ const items: Item[] = [
364
+ ...run.upstream.map(visible => ({
365
+ entry: visible.entry, mode: visible.mode, explicit: false, dropped: visible.dropped === true,
366
+ })),
367
+ ...run.seeds.map(entry => ({ entry, mode: ResultViewMode.Full, explicit: false, dropped: false })),
368
+ ]
369
+ for (const entry of run.entries.values()) {
370
+ const anchor = anchorOf(run, entry)
371
+ const depth = anchor != null ? depths.get(anchor) : undefined
372
+ if (depth != null) {
373
+ items.push({ entry, ...modeFor(run, entry, step, depth), dropped: false })
374
+ }
375
+ }
376
+
377
+ return items.sort((a, b) =>
378
+ compareResultOrder(a.entry.order, b.entry.order) || compare(a.entry.label, b.entry.label))
379
+ }
380
+
381
+ const textOf = (item: Item): string =>
382
+ item.mode === ResultViewMode.Full
383
+ ? item.entry.full
384
+ : item.mode === ResultViewMode.Compact ? item.entry.compact : ''
385
+
386
+ /** The step's view, inside the budget. See the plugin's own description for the order of cuts. */
387
+ const cut = (run: RunLedger<C>, step: string): StepResults => {
388
+ const items = visibleTo(run, step)
389
+ const maxChars = run.results.maxChars ?? DEFAULT_RESULTS_MAX_CHARS
390
+ const measure = (): number =>
391
+ items.map(textOf).filter(text => text !== '').join(SEPARATOR).length
392
+
393
+ let total = measure()
394
+ const downgrade = (explicit: boolean, from: ResultViewMode): void => {
395
+ for (const item of items) {
396
+ if (total <= maxChars) {
397
+ return
398
+ }
399
+ if (item.explicit !== explicit || item.mode !== from || textOf(item) === '') {
400
+ continue
401
+ }
402
+ if (from === ResultViewMode.Full) {
403
+ item.mode = ResultViewMode.Compact
404
+ } else {
405
+ item.mode = ResultViewMode.Omitted
406
+ item.dropped = true
407
+ }
408
+ total = measure()
409
+ }
410
+ }
411
+ // Oldest first, and whatever a producer has not named this step for goes before what it has.
412
+ downgrade(false, ResultViewMode.Full)
413
+ downgrade(false, ResultViewMode.Compact)
414
+ downgrade(true, ResultViewMode.Full)
415
+ downgrade(true, ResultViewMode.Compact)
416
+
417
+ const sections = items
418
+ .filter(item => textOf(item) !== '')
419
+ .map(item => Object.freeze({ step: item.entry.label, text: textOf(item) }))
420
+ const omitted = items
421
+ .filter(item => item.mode === ResultViewMode.Omitted && item.dropped && item.entry.full !== '')
422
+ .map(item => item.entry.label)
423
+ const joined = sections.map(section => section.text).join(SEPARATOR)
424
+ const view: CumulativeResults = Object.freeze({
425
+ step: resultLabel(run.ledger, run.runId, step),
426
+ sections: Object.freeze(sections) as CumulativeResults['sections'],
427
+ omitted: Object.freeze(omitted) as string[],
428
+ chars: joined.length,
429
+ digest: prefixHash(`${joined}\u0000${omitted.join('\u0000')}`),
430
+ })
431
+ const visible: readonly VisibleResultEntry[] = Object.freeze(items.map(item => Object.freeze({
432
+ entry: item.entry, mode: item.mode, ...(item.dropped ? { dropped: true } : {}),
433
+ })))
434
+
435
+ say(run, `${step} view · sections=${sections.length} chars=${view.chars}`
436
+ + ` omitted=${omitted.length} digest=${view.digest}`)
437
+
438
+ const results: StepResults = {
439
+ ledger: run.ledger,
440
+ order: Object.freeze(orderOf(run, step)),
441
+ view,
442
+ entries: () => visible,
443
+ facts: (query = {}) => visible.flatMap(({ entry }) =>
444
+ query.step == null || entry.step === query.step || entry.label === query.step
445
+ ? entry.facts
446
+ .filter(fact => (query.kind == null || fact.kind === query.kind)
447
+ && (query.name == null || fact.name === query.name))
448
+ .map(copyFact)
449
+ : []),
450
+ record: input => {
451
+ const buffer = run.recorded.get(step) ?? { files: new Set<string>(), facts: [] }
452
+ for (const file of input.files ?? []) {
453
+ buffer.files.add(normalizePath(file))
454
+ }
455
+ buffer.facts.push(...(input.facts ?? []).map(copyFact))
456
+ run.recorded.set(step, buffer)
457
+ },
458
+ }
459
+
460
+ return Object.freeze(results)
461
+ }
462
+
463
+ const take = (run: RunLedger<C>, step: string): Recorded => {
464
+ const buffer = run.recorded.get(step) ?? { files: new Set<string>(), facts: [] }
465
+ run.recorded.delete(step)
466
+
467
+ return buffer
468
+ }
469
+
470
+ const scopeOf = async (run: RunLedger<C>, step: string, state: Readonly<S>): Promise<string[]> => {
471
+ const files: string[] = []
472
+ for (const name of run.results.steps?.[step]?.extractors ?? []) {
473
+ const extractor = options.extractors?.[name]
474
+ if (extractor == null || typeof extractor === 'function' || extractor.scope == null) {
475
+ continue
476
+ }
477
+ try {
478
+ files.push(...await extractor.scope({
479
+ pipeline: run.spec.alias, runId: run.runId, step, state, deps: run.deps,
480
+ }))
481
+ } catch (e) {
482
+ warn(`scope of extractor ${name} failed for ${step}`, e)
483
+ }
484
+ }
485
+
486
+ return uniqueSorted(files)
487
+ }
488
+
489
+ const extract = async (
490
+ run: RunLedger<C>, step: string, state: Readonly<S>,
491
+ files: readonly string[], changed: readonly string[],
492
+ ): Promise<CumulativeResultFact[]> => {
493
+ const facts: CumulativeResultFact[] = []
494
+ for (const name of run.results.steps?.[step]?.extractors ?? []) {
495
+ const extractor = options.extractors?.[name]
496
+ if (extractor == null) {
497
+ warn(`step ${step} names an extractor nobody registered: ${name}`)
498
+ continue
499
+ }
500
+ try {
501
+ facts.push(...(await extractFn(extractor)({
502
+ pipeline: run.spec.alias, runId: run.runId, step, state, deps: run.deps, files, changed,
503
+ }) ?? []))
504
+ } catch (e) {
505
+ warn(`extractor ${name} failed for ${step}`, e)
506
+ }
507
+ }
508
+
509
+ return facts
510
+ }
511
+
512
+ /**
513
+ * Facts no earlier entry already owns. "Earlier" is what the step can see, plus the runs composed
514
+ * under the step itself, whose entries are written before the step's own.
515
+ */
516
+ const unowned = (
517
+ run: RunLedger<C>, step: string, facts: readonly CumulativeResultFact[],
518
+ ): CumulativeResultFact[] => {
519
+ const owned = new Set<string>()
520
+ for (const item of visibleTo(run, step)) {
521
+ for (const fact of item.entry.facts) {
522
+ owned.add(factKey(fact))
523
+ }
524
+ }
525
+ for (const entry of run.entries.values()) {
526
+ if (entry.runId.startsWith(`${run.runId}/${step}`) && anchorOf(run, entry) === step) {
527
+ for (const fact of entry.facts) {
528
+ owned.add(factKey(fact))
529
+ }
530
+ }
531
+ }
532
+
533
+ return facts.filter(fact => !owned.has(factKey(fact)))
534
+ }
535
+
536
+ /** A step's facts and files, from its durable scope, what it recorded, and its extractors. */
537
+ const collect = async (
538
+ run: RunLedger<C>, step: string, state: Readonly<S>,
539
+ changed: readonly string[], recorded: readonly CumulativeResultFact[],
540
+ ): Promise<{ facts: CumulativeResultFact[], files: string[] }> => {
541
+ const files = uniqueSorted([...await scopeOf(run, step, state), ...changed])
542
+ const facts = unowned(run, step, dedupe([...recorded, ...await extract(run, step, state, files, changed)]))
543
+
544
+ return { facts, files: uniqueSorted([...files, ...pathsOf(facts)]) }
545
+ }
546
+
547
+ const compose = (run: RunLedger<C>, step: string, parts: EntryParts): CumulativeResultEntry => {
548
+ const previous = run.entries.get(keyOf(run.runId, step))
549
+ const label = resultLabel(run.ledger, run.runId, step)
550
+ const source = parts.facts.length === 0 && parts.summary != null
551
+ ? CumulativeResultSource.Summarized
552
+ : parts.source
553
+ const facts = sortFacts(parts.facts)
554
+ const rendered = renderResultEntry(
555
+ { label, source, facts, ...(parts.partial === true ? { partial: true } : {}), ...(parts.summary != null ? { summary: parts.summary } : {}) },
556
+ {
557
+ maxChars: parts.spec?.maxChars ?? run.results.maxEntryChars,
558
+ maxCompactChars: parts.spec?.maxCompactChars ?? run.results.maxCompactChars,
559
+ },
560
+ )
561
+
562
+ return freezeEntry({
563
+ ledger: run.ledger,
564
+ pipeline: run.spec.alias,
565
+ runId: run.runId,
566
+ step,
567
+ label,
568
+ order: parts.order,
569
+ revision: (previous?.revision ?? 0) + 1,
570
+ source,
571
+ ...(parts.partial === true ? { partial: true } : {}),
572
+ facts,
573
+ files: parts.files,
574
+ ...(parts.summary != null ? { summary: parts.summary } : {}),
575
+ full: rendered.full,
576
+ compact: rendered.compact,
577
+ ...(parts.spec?.full != null ? { fullFor: parts.spec.full } : {}),
578
+ ...(parts.spec?.omit != null ? { omitFor: parts.spec.omit } : {}),
579
+ ...(parts.spec?.window != null ? { window: parts.spec.window } : {}),
580
+ updatedAt: new Date().toISOString(),
581
+ })
582
+ }
583
+
584
+ /**
585
+ * Re-read every earlier entry of this run whose files the step just rewrote — a repair of an
586
+ * earlier step's output would otherwise leave that step's entry describing a file that no longer
587
+ * says it. The refreshed entry keeps what its extractors find now, plus its earlier facts about
588
+ * files the step did not touch.
589
+ */
590
+ const refreshEarlier = async (
591
+ run: RunLedger<C>, step: string, changed: readonly string[], state: Readonly<S>,
592
+ ): Promise<void> => {
593
+ if (changed.length === 0) {
594
+ return
595
+ }
596
+ const mine = orderOf(run, step)
597
+ const earlier = [...run.entries.values()]
598
+ .filter(entry => entry.runId === run.runId && entry.step !== step
599
+ && compareResultOrder(entry.order, mine) < 0)
600
+ .sort((a, b) => compareResultOrder(a.order, b.order))
601
+
602
+ for (const entry of earlier) {
603
+ const spec = run.results.steps?.[entry.step]
604
+ if (!hasExtractors(spec) || (spec?.refresh ?? run.results.refresh ?? true) === false) {
605
+ continue
606
+ }
607
+ const overlap = entry.files.filter(file => changed.includes(file))
608
+ if (overlap.length === 0) {
609
+ continue
610
+ }
611
+ const fresh = await extract(run, entry.step, state, entry.files, overlap)
612
+ const freshKeys = new Set(fresh.map(factKey))
613
+ const kept = entry.facts.filter(fact => !freshKeys.has(factKey(fact))
614
+ && (fact.path == null || !overlap.includes(normalizePath(fact.path))))
615
+ const facts = unowned(run, entry.step, dedupe([...fresh, ...kept]))
616
+ const refreshed = compose(run, entry.step, {
617
+ facts,
618
+ files: uniqueSorted([...entry.files, ...pathsOf(facts)]),
619
+ source: entry.source === CumulativeResultSource.Summarized
620
+ ? CumulativeResultSource.Extracted
621
+ : entry.source,
622
+ order: [...entry.order],
623
+ ...(spec != null ? { spec } : {}),
624
+ ...(entry.partial === true ? { partial: true } : {}),
625
+ ...(entry.summary != null ? { summary: entry.summary } : {}),
626
+ })
627
+ await persist(run, refreshed)
628
+ say(run, `${step} rewrote ${overlap.length} file(s) of ${entry.label} · revision=${refreshed.revision}`)
629
+ }
630
+ }
631
+
632
+ const summaryOf = async (
633
+ run: RunLedger<C>, step: string, ctx: PipelineRunContext<S, C>, state: Readonly<S>,
634
+ facts: readonly CumulativeResultFact[], files: readonly string[],
635
+ ): Promise<string | undefined> => {
636
+ const spec = run.results.steps?.[step]?.summary
637
+ if (spec == null || options.summarize == null) {
638
+ return undefined
639
+ }
640
+ const maxChars = spec.maxChars ?? DEFAULT_RESULT_SUMMARY_CHARS
641
+ const label = resultLabel(run.ledger, run.runId, step)
642
+ try {
643
+ const answer = await options.summarize({
644
+ ...spec,
645
+ schema: spec.schema ?? DEFAULT_RESULT_SUMMARY_SCHEMA,
646
+ maxChars,
647
+ pipeline: run.spec.alias,
648
+ runId: run.runId,
649
+ step,
650
+ label,
651
+ facts,
652
+ files,
653
+ state,
654
+ deps: run.deps,
655
+ signal: ctx.signal,
656
+ prompt: summaryPrompt(spec, label, facts, files, maxChars),
657
+ })
658
+ const text = renderResultSummary(answer, maxChars)
659
+
660
+ return text !== '' ? text : undefined
661
+ } catch (e) {
662
+ // A summary is the unverified extra. Without it the step's facts still stand.
663
+ warn(`summary of ${step} failed; the entry goes without one`, e)
664
+ return undefined
665
+ }
666
+ }
667
+
668
+ return {
669
+ alias,
670
+ ...(options.order != null ? { order: options.order } : {}),
671
+
672
+ enter: async event => {
673
+ runs.delete(event.runId)
674
+ const info: ResultsRunInfo<S, C> = {
675
+ pipeline: event.spec.alias,
676
+ spec: event.spec,
677
+ runId: event.runId,
678
+ scope: event.scope,
679
+ ...(event.entityId != null ? { entityId: event.entityId } : {}),
680
+ deps: event.deps,
681
+ state: event.state,
682
+ mode: event.mode,
683
+ ...(event.parent != null ? { parent: event.parent } : {}),
684
+ }
685
+ const declared = typeof options.spec === 'function' ? await options.spec(info) : options.spec
686
+ if (declared == null || declared === false) {
687
+ return
688
+ }
689
+
690
+ const parent = event.parent?.results
691
+ const ledger = options.ledger?.(info) ?? parent?.ledger ?? rootRunOf(event.runId)
692
+ const run: RunLedger<C> = {
693
+ results: declared,
694
+ spec: event.spec,
695
+ runId: event.runId,
696
+ ledger,
697
+ trail: parent != null ? [...parent.order] : [],
698
+ index: new Map(orderPipelineSteps(event.spec).map((step, at) => [step, at + 1])),
699
+ entries: new Map(),
700
+ upstream: parent != null ? parent.entries() : [],
701
+ seeds: [],
702
+ views: new Map(),
703
+ recorded: new Map(),
704
+ deps: event.deps,
705
+ }
706
+
707
+ if (event.mode !== 'continue') {
708
+ try {
709
+ await store.clear(ledger, event.runId)
710
+ } catch (e) {
711
+ warn(`could not clear the earlier entries of ${event.runId}`, e)
712
+ }
713
+ }
714
+ await reload(run)
715
+
716
+ for (const name of Object.keys(options.seeds ?? {}).sort(compare)) {
717
+ try {
718
+ const facts = dedupe(await options.seeds![name]!(info))
719
+ run.seeds.push(compose(run, name, {
720
+ facts, files: uniqueSorted(pathsOf(facts)), source: CumulativeResultSource.Seeded,
721
+ order: [...run.trail, 0],
722
+ }))
723
+ } catch (e) {
724
+ warn(`seed ${name} failed`, e)
725
+ }
726
+ }
727
+ runs.set(event.runId, run)
728
+
729
+ // A continuation's finished steps must have their entries, whether or not the ledger kept
730
+ // them — a store outage, an expiry, a crash between a fact and its `put`. They are read again
731
+ // from the steps' DURABLE inputs; never from what a step once recorded, which is gone.
732
+ let rebuilt = 0
733
+ for (const step of event.inherited) {
734
+ if (run.entries.has(keyOf(run.runId, step)) || !hasExtractors(run.results.steps?.[step])) {
735
+ continue
736
+ }
737
+ const collected = await collect(run, step, event.state, [], [])
738
+ await persist(run, compose(run, step, {
739
+ ...collected, source: CumulativeResultSource.Rebuilt, order: orderOf(run, step),
740
+ ...(run.results.steps?.[step] != null ? { spec: run.results.steps[step] } : {}),
741
+ }))
742
+ rebuilt += 1
743
+ }
744
+ say(run, `enter ${event.mode} · ledger=${ledger} entries=${run.entries.size} rebuilt=${rebuilt}`)
745
+ },
746
+
747
+ beforeStep: async ({ step, ctx }) => {
748
+ const run = runs.get(ctx.runId)
749
+ if (run == null) {
750
+ return
751
+ }
752
+ const memo = run.views.get(step)
753
+ if (memo != null) {
754
+ return { results: memo }
755
+ }
756
+ // A run composed under an earlier step wrote its entries through its own plugin instance.
757
+ await reload(run)
758
+ const results = cut(run, step)
759
+ run.views.set(step, results)
760
+
761
+ return { results }
762
+ },
763
+
764
+ afterStep: async ({ step, ctx, state }) => {
765
+ const run = runs.get(ctx.runId)
766
+ if (run == null) {
767
+ return
768
+ }
769
+ const buffer = take(run, step)
770
+ const spec = run.results.steps?.[step]
771
+ if (spec == null && buffer.files.size === 0 && buffer.facts.length === 0
772
+ && !run.entries.has(keyOf(run.runId, step))) {
773
+ return
774
+ }
775
+ await reload(run)
776
+ const changed = uniqueSorted(buffer.files)
777
+ await refreshEarlier(run, step, changed, state)
778
+ const collected = await collect(run, step, state, changed, buffer.facts)
779
+ const summary = await summaryOf(run, step, ctx, state, collected.facts, collected.files)
780
+ const entry = compose(run, step, {
781
+ ...collected, source: CumulativeResultSource.Extracted, order: orderOf(run, step),
782
+ ...(spec != null ? { spec } : {}), ...(summary != null ? { summary } : {}),
783
+ })
784
+ await persist(run, entry)
785
+ say(run, `${step} entry · facts=${entry.facts.length} files=${entry.files.length} revision=${entry.revision}`)
786
+ },
787
+
788
+ passStep: async ({ step, ctx, state, reason }) => {
789
+ const run = runs.get(ctx.runId)
790
+ if (run == null) {
791
+ return
792
+ }
793
+ const buffer = take(run, step)
794
+ const spec = run.results.steps?.[step]
795
+ await reload(run)
796
+
797
+ if (reason === 'skipped') {
798
+ // A guard that skips is saying the work is already there. What its files support is what
799
+ // the step's entry is; what a real earlier run stored stands as it is.
800
+ if (run.entries.has(keyOf(run.runId, step)) || !hasExtractors(spec)) {
801
+ return
802
+ }
803
+ const collected = await collect(run, step, state, [], [])
804
+ await persist(run, compose(run, step, {
805
+ ...collected, source: CumulativeResultSource.Rebuilt, order: orderOf(run, step),
806
+ ...(spec != null ? { spec } : {}),
807
+ }))
808
+ return
809
+ }
810
+
811
+ if (spec == null && buffer.files.size === 0 && buffer.facts.length === 0) {
812
+ return
813
+ }
814
+ const changed = uniqueSorted(buffer.files)
815
+ await refreshEarlier(run, step, changed, state)
816
+ const collected = await collect(run, step, state, changed, buffer.facts)
817
+ await persist(run, compose(run, step, {
818
+ ...collected, source: CumulativeResultSource.Extracted, order: orderOf(run, step),
819
+ partial: true, ...(spec != null ? { spec } : {}),
820
+ }))
821
+ },
822
+
823
+ exit: async ({ runId }) => {
824
+ runs.delete(runId)
825
+ },
826
+ }
827
+ }