@owlmeans/agent-common 0.1.18-rc.9 → 0.1.18
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 +174 -13
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/agent-common/SKILL.md +69 -14
- package/build/consts.d.ts +118 -5
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +204 -4
- package/build/consts.js.map +1 -1
- package/build/consts.local.d.ts +9 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +9 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +60 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +84 -0
- package/build/errors.js.map +1 -1
- package/build/helpers/conversation.d.ts +0 -9
- package/build/helpers/conversation.d.ts.map +1 -1
- package/build/helpers/conversation.js +0 -28
- package/build/helpers/conversation.js.map +1 -1
- package/build/helpers/truncate.d.ts +10 -0
- package/build/helpers/truncate.d.ts.map +1 -0
- package/build/helpers/truncate.js +29 -0
- package/build/helpers/truncate.js.map +1 -0
- package/build/index.d.ts +6 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -1
- package/build/index.js.map +1 -1
- package/build/models/pipeline-spec/types.d.ts +57 -0
- package/build/models/pipeline-spec/types.d.ts.map +1 -0
- package/build/models/pipeline-spec/types.js +2 -0
- package/build/models/pipeline-spec/types.js.map +1 -0
- package/build/models/pipeline-spec.d.ts +4 -0
- package/build/models/pipeline-spec.d.ts.map +1 -0
- package/build/models/pipeline-spec.js +171 -0
- package/build/models/pipeline-spec.js.map +1 -0
- package/build/pipeline.d.ts +10 -0
- package/build/pipeline.d.ts.map +1 -0
- package/build/pipeline.js +10 -0
- package/build/pipeline.js.map +1 -0
- package/build/results/types.d.ts +211 -0
- package/build/results/types.d.ts.map +1 -0
- package/build/results/types.js +2 -0
- package/build/results/types.js.map +1 -0
- package/build/results.d.ts +4 -0
- package/build/results.d.ts.map +1 -0
- package/build/results.js +218 -0
- package/build/results.js.map +1 -0
- package/build/types.d.ts +146 -24
- package/build/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/consts.local.ts +15 -0
- package/src/consts.ts +218 -4
- package/src/errors.ts +96 -0
- package/src/helpers/conversation.ts +0 -32
- package/src/helpers/truncate.ts +31 -0
- package/src/index.ts +6 -1
- package/src/models/pipeline-spec/types.ts +58 -0
- package/src/models/pipeline-spec.ts +202 -0
- package/src/pipeline.ts +16 -0
- package/src/results/types.ts +221 -0
- package/src/results.ts +270 -0
- package/src/types.ts +153 -25
- package/tests/pipeline.spec.ts +164 -0
- package/tests/results.spec.ts +148 -0
- package/src/flows.ts +0 -118
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { makePipelineSpecModel } from './models/pipeline-spec.js'
|
|
2
|
+
import type { PipelineSpec, PipelineStepSpec } from './types.js'
|
|
3
|
+
|
|
4
|
+
/** @deprecated compat:factory-refactor — use `makePipelineSpecModel(spec).validate()` */
|
|
5
|
+
export const validatePipelineSpec = (spec: PipelineSpec): void => makePipelineSpecModel(spec).validate()
|
|
6
|
+
|
|
7
|
+
/** @deprecated compat:factory-refactor — use `makePipelineSpecModel(spec).orderSteps()` */
|
|
8
|
+
export const orderPipelineSteps = (spec: PipelineSpec): string[] => makePipelineSpecModel(spec).orderSteps()
|
|
9
|
+
|
|
10
|
+
/** @deprecated compat:factory-refactor — use `makePipelineSpecModel(spec).descendants(…)` */
|
|
11
|
+
export const pipelineDescendants = (spec: PipelineSpec, step: string): string[] =>
|
|
12
|
+
makePipelineSpecModel(spec).descendants(step)
|
|
13
|
+
|
|
14
|
+
/** @deprecated compat:factory-refactor — use `makePipelineSpecModel(spec).stepOf(…)` */
|
|
15
|
+
export const pipelineStep = (spec: PipelineSpec, step: string): PipelineStepSpec | null =>
|
|
16
|
+
makePipelineSpecModel(spec).stepOf(step)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
2
|
+
import { RESULTS_EVERY_STEP, CumulativeResultSource } from '../consts.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ONE authoritative fact about what a step produced, small enough to be one line of a prompt.
|
|
6
|
+
*
|
|
7
|
+
* Every field is optional past `kind` and `name`, because facts differ in what matters about them:
|
|
8
|
+
* a type has a shape and an import specifier, an endpoint an alias, a method, a path and a guard, a
|
|
9
|
+
* resource a table and an accessor, a file only its path. Put scalars that have no field of their
|
|
10
|
+
* own into `attrs`.
|
|
11
|
+
*/
|
|
12
|
+
export interface CumulativeResultFact {
|
|
13
|
+
/** What the fact is about — see {@link CumulativeFactKind}. */
|
|
14
|
+
kind: string
|
|
15
|
+
/** The name to use VERBATIM. */
|
|
16
|
+
name: string
|
|
17
|
+
/** The file it lives in, relative to the project root. */
|
|
18
|
+
path?: string
|
|
19
|
+
/** The module specifier it is imported from. */
|
|
20
|
+
specifier?: string
|
|
21
|
+
/** Its shape or signature, on one line. */
|
|
22
|
+
signature?: string
|
|
23
|
+
/** Fields, parameters or columns, when there is no signature to say it. */
|
|
24
|
+
members?: string[]
|
|
25
|
+
/** Another fact's name this one points at — a returned type, a mapped table. */
|
|
26
|
+
ref?: string
|
|
27
|
+
/** Scalars without a field of their own — `method`, `path`, `guard`, `table`. */
|
|
28
|
+
attrs?: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One step's contribution to a ledger.
|
|
33
|
+
*
|
|
34
|
+
* Keyed by `(ledger, runId, step)`. `order` places it in the ledger: the topological index of the
|
|
35
|
+
* step in its own pipeline, preceded by the index path of every step it was composed under, so a
|
|
36
|
+
* run composed as a step of another sorts inside that step, ahead of the composing step's own
|
|
37
|
+
* entry. A single number cannot say that: two pipelines' indexes are not comparable.
|
|
38
|
+
*
|
|
39
|
+
* `full` and `compact` are rendered once, when the entry is written, and never contain a timestamp,
|
|
40
|
+
* a run id or anything else a model would copy without meaning to.
|
|
41
|
+
*/
|
|
42
|
+
export interface CumulativeResultEntry extends ResourceRecord {
|
|
43
|
+
/** What entries can see each other — by default the root run of the composition. */
|
|
44
|
+
ledger: string
|
|
45
|
+
pipeline: string
|
|
46
|
+
runId: string
|
|
47
|
+
step: string
|
|
48
|
+
/** How the entry is headed in a prompt: the step, behind the path of the steps it runs under. */
|
|
49
|
+
label: string
|
|
50
|
+
order: number[]
|
|
51
|
+
/** Bumped every time the entry is re-extracted after a later step rewrote one of its files. */
|
|
52
|
+
revision: number
|
|
53
|
+
source: CumulativeResultSource
|
|
54
|
+
/** An `optional` step that failed: whatever it left behind, and a heading that says so. */
|
|
55
|
+
partial?: boolean
|
|
56
|
+
facts: CumulativeResultFact[]
|
|
57
|
+
/** The files the facts were read from — what a later rewrite is matched against. */
|
|
58
|
+
files: string[]
|
|
59
|
+
/** A model's summary, rendered and capped. NOT verified, and always labelled so. */
|
|
60
|
+
summary?: string
|
|
61
|
+
full: string
|
|
62
|
+
compact: string
|
|
63
|
+
/** Consumers that always get the full entry, by step name, or every later step. */
|
|
64
|
+
fullFor?: string[] | typeof RESULTS_EVERY_STEP
|
|
65
|
+
/** Consumers that never see the entry in their prompt. */
|
|
66
|
+
omitFor?: string[]
|
|
67
|
+
/** How many edges back the entry is still rendered in full by default. */
|
|
68
|
+
window?: number
|
|
69
|
+
updatedAt: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The optional model-written summary of a step. OFF unless a step declares one.
|
|
74
|
+
*
|
|
75
|
+
* Facts are the product; a summary is for what code cannot extract — an intent, a decision taken.
|
|
76
|
+
* It is bounded by `maxChars` in code after the model answers, rendered deterministically from the
|
|
77
|
+
* schema-shaped answer, and always labelled as unverified wherever it is shown.
|
|
78
|
+
*/
|
|
79
|
+
export interface ResultSummarySpec {
|
|
80
|
+
/** Which model — interpreted by the caller's summarize callback (a model role, typically). */
|
|
81
|
+
role?: string
|
|
82
|
+
instructions: string
|
|
83
|
+
/** JSON schema of the answer. Absent means `{ summary: string }`. */
|
|
84
|
+
schema?: Record<string, unknown>
|
|
85
|
+
/** Defaults to `DEFAULT_RESULT_SUMMARY_CHARS`. */
|
|
86
|
+
maxChars?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What one step contributes, and to whom. JSON-safe, so a declaration can live in data.
|
|
91
|
+
*
|
|
92
|
+
* Extractors are named, never inlined: a declaration names what runs, and the runtime holds the
|
|
93
|
+
* code. A consumer not named in `full` or `omit` gets the full entry within `window` edges and the
|
|
94
|
+
* names only beyond it.
|
|
95
|
+
*/
|
|
96
|
+
export interface StepResultsSpec {
|
|
97
|
+
/** Named deterministic extractors, run in this order. */
|
|
98
|
+
extractors?: string[]
|
|
99
|
+
/** Later steps that get the FULL entry wherever they sit, or `'*'` for all of them. */
|
|
100
|
+
full?: string[] | typeof RESULTS_EVERY_STEP
|
|
101
|
+
/** Later steps that are never shown the entry. */
|
|
102
|
+
omit?: string[]
|
|
103
|
+
/** Overrides {@link CumulativeResultsSpec.window} for this entry. */
|
|
104
|
+
window?: number
|
|
105
|
+
/** Cap on the full rendering; facts are dropped whole, never cut. */
|
|
106
|
+
maxChars?: number
|
|
107
|
+
/** Cap on the names-only rendering. */
|
|
108
|
+
maxCompactChars?: number
|
|
109
|
+
/** Re-extract this entry when a later step rewrites one of its files. Defaults to the spec's. */
|
|
110
|
+
refresh?: boolean
|
|
111
|
+
summary?: ResultSummarySpec
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The pipeline-level declaration — merged from a pipeline's own and any overlay with {@link CumulativeResultsHelper.mergeResultsSpecs}. */
|
|
115
|
+
export interface CumulativeResultsSpec {
|
|
116
|
+
/** Per step, by step name. A step with no entry contributes only what it records itself. */
|
|
117
|
+
steps?: Record<string, StepResultsSpec>
|
|
118
|
+
/** Default {@link DEFAULT_RESULTS_WINDOW}. */
|
|
119
|
+
window?: number
|
|
120
|
+
/** Total characters of one view. Default `DEFAULT_RESULTS_MAX_CHARS`. */
|
|
121
|
+
maxChars?: number
|
|
122
|
+
/** Default cap of one full entry. Default {@link DEFAULT_RESULT_ENTRY_CHARS}. */
|
|
123
|
+
maxEntryChars?: number
|
|
124
|
+
/** Default cap of one names-only entry. Default {@link DEFAULT_RESULT_COMPACT_CHARS}. */
|
|
125
|
+
maxCompactChars?: number
|
|
126
|
+
/** Default for {@link StepResultsSpec.refresh}. Defaults to `true`. */
|
|
127
|
+
refresh?: boolean
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What a facts query may narrow by. Every field is an exact match. */
|
|
131
|
+
export interface ResultFactQuery {
|
|
132
|
+
kind?: string
|
|
133
|
+
name?: string
|
|
134
|
+
/** A producing step, by its name or by its label. */
|
|
135
|
+
step?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What {@link CumulativeResultsHelper.renderResultEntry} needs of an entry. */
|
|
139
|
+
export interface RenderableResultEntry extends Pick<
|
|
140
|
+
CumulativeResultEntry, 'label' | 'source' | 'partial' | 'facts' | 'summary'
|
|
141
|
+
> {}
|
|
142
|
+
|
|
143
|
+
export interface ResultRenderCaps {
|
|
144
|
+
/** Cap on the full rendering. Default {@link DEFAULT_RESULT_ENTRY_CHARS}. */
|
|
145
|
+
maxChars?: number
|
|
146
|
+
/** Cap on the names-only rendering. Default {@link DEFAULT_RESULT_COMPACT_CHARS}. */
|
|
147
|
+
maxCompactChars?: number
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** An entry's two renderings. */
|
|
151
|
+
export interface RenderedResultEntry {
|
|
152
|
+
full: string
|
|
153
|
+
compact: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* CUMULATIVE PIPELINE RESULTS — the contracts.
|
|
158
|
+
*
|
|
159
|
+
* After a step of a pipeline finishes, code reads what it produced — a type's shape, a file, an
|
|
160
|
+
* endpoint's method, path and parameters, a resource's table — into a small list of FACTS, and the
|
|
161
|
+
* later steps are told those facts instead of re-deriving them from the files. A later step that is
|
|
162
|
+
* handed the authoritative names does not invent a second spelling of them, and that is the whole
|
|
163
|
+
* point: it removes the class of errors that live BETWEEN steps.
|
|
164
|
+
*
|
|
165
|
+
* Everything here is data and pure functions. The runtime that extracts, stores and cuts the views
|
|
166
|
+
* is `@owlmeans/agent`'s `cumulativeResultsPlugin`; the prompt block it lands in is
|
|
167
|
+
* `@owlmeans/llm`'s. A backend or a browser bundle reads an entry without either.
|
|
168
|
+
*/
|
|
169
|
+
export interface CumulativeResultsHelper {
|
|
170
|
+
/**
|
|
171
|
+
* The identity of a fact inside a ledger: what it is, what it is called and where it comes from.
|
|
172
|
+
* Two facts with one key are one fact, owned by whichever entry produced it first.
|
|
173
|
+
*/
|
|
174
|
+
factKey: (fact: CumulativeResultFact) => string
|
|
175
|
+
/**
|
|
176
|
+
* Facts in the one order they are ever rendered in: by kind (file facts last), name and location,
|
|
177
|
+
* with a code-unit comparison so every host sorts them alike.
|
|
178
|
+
*/
|
|
179
|
+
sortFacts: (facts: readonly CumulativeResultFact[]) => CumulativeResultFact[]
|
|
180
|
+
/** One fact as one prompt line: kind and name, shape, scalars, pointer, and where it comes from. */
|
|
181
|
+
renderResultFact: (fact: CumulativeResultFact) => string
|
|
182
|
+
/**
|
|
183
|
+
* An entry's two renderings — in full, and as names only — or two empty strings when it has
|
|
184
|
+
* nothing to say.
|
|
185
|
+
*
|
|
186
|
+
* Deterministic and free of anything that varies between two renderings of the same facts: no
|
|
187
|
+
* timestamp, no run id, no revision. Over its cap an entry DROPS whole facts, in render order, and
|
|
188
|
+
* says how many it dropped; it never cuts one fact's line, because half a signature is a wrong
|
|
189
|
+
* signature. An empty entry that is not partial renders to nothing, so it costs a view nothing.
|
|
190
|
+
*/
|
|
191
|
+
renderResultEntry: (entry: RenderableResultEntry, caps?: ResultRenderCaps) => RenderedResultEntry
|
|
192
|
+
/**
|
|
193
|
+
* Ledger order: element by element, and where one key is a prefix of the other, the LONGER one
|
|
194
|
+
* first — an entry of a run composed under a step sorts ahead of that step's own entry, which is
|
|
195
|
+
* written only once the composed run has finished.
|
|
196
|
+
*/
|
|
197
|
+
compareResultOrder: (a: readonly number[], b: readonly number[]) => number
|
|
198
|
+
/** The root run of a composition — what a composed run's id (`<parent>/<step>`) starts with. */
|
|
199
|
+
rootRunOf: (runId: string) => string
|
|
200
|
+
/**
|
|
201
|
+
* How an entry is headed: the step, behind the path of composing steps between the ledger's root
|
|
202
|
+
* run and the run that produced it. A root-run step is headed by its bare name.
|
|
203
|
+
*/
|
|
204
|
+
resultLabel: (ledger: string, runId: string, step: string) => string
|
|
205
|
+
/**
|
|
206
|
+
* A model's schema-shaped summary as one capped line, the same bytes for the same answer.
|
|
207
|
+
*
|
|
208
|
+
* An object renders as `key: value` pairs in key order; one with a single key renders as that
|
|
209
|
+
* value alone, so the default `{ summary }` answer reads as prose. The cap is applied here, after
|
|
210
|
+
* the model answered — a cap in a prompt is a request.
|
|
211
|
+
*/
|
|
212
|
+
renderResultSummary: (answer: unknown, maxChars?: number) => string
|
|
213
|
+
/**
|
|
214
|
+
* Several declarations as one — a pipeline's own, then any overlay (a variant, a deployment).
|
|
215
|
+
*
|
|
216
|
+
* Later declarations win field by field, except the lists: extractors, `full` consumers and `omit`
|
|
217
|
+
* consumers ACCUMULATE, the way skills do down an execution chain, because an overlay that adds an
|
|
218
|
+
* extractor is not asking to lose the ones the pipeline declared. `'*'` in `full` absorbs names.
|
|
219
|
+
*/
|
|
220
|
+
mergeResultsSpecs: (...specs: Array<CumulativeResultsSpec | null | undefined>) => CumulativeResultsSpec
|
|
221
|
+
}
|
package/src/results.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { DEFAULT_RESULT_COMPACT_CHARS, DEFAULT_RESULT_ENTRY_CHARS, DEFAULT_RESULT_SUMMARY_CHARS, RESULTS_EVERY_STEP, CumulativeFactKind, CumulativeResultSource } from './consts.js'
|
|
2
|
+
import { truncateAt } from './helpers/truncate.js'
|
|
3
|
+
import { NAMES_ONLY, NOT_VERIFIED, PARTIAL_EMPTY, PARTIAL_WITH_FACTS, SEEDED, SUMMARY_ALONE, SUMMARY_LEFT_OUT, SUMMARY_WITH_FACTS } from './consts.local.js'
|
|
4
|
+
import type {
|
|
5
|
+
CumulativeResultFact, CumulativeResultsHelper, CumulativeResultsSpec, RenderableResultEntry, RenderedResultEntry,
|
|
6
|
+
ResultRenderCaps, StepResultsSpec,
|
|
7
|
+
} from './results/types.js'
|
|
8
|
+
|
|
9
|
+
export const createCumulativeResultsHelper = (): CumulativeResultsHelper => {
|
|
10
|
+
const compare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0
|
|
11
|
+
|
|
12
|
+
const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
|
|
13
|
+
|
|
14
|
+
const factKey = (fact: CumulativeResultFact): string =>
|
|
15
|
+
`${fact.kind}\u0000${fact.name}\u0000${fact.specifier ?? fact.path ?? ''}`
|
|
16
|
+
|
|
17
|
+
/** File facts last — a location is the first thing a budget can spare, a shape the last. */
|
|
18
|
+
const kindRank = (kind: string): number => kind === CumulativeFactKind.File ? 1 : 0
|
|
19
|
+
|
|
20
|
+
const sortFacts = (facts: readonly CumulativeResultFact[]): CumulativeResultFact[] =>
|
|
21
|
+
[...facts].sort((a, b) =>
|
|
22
|
+
kindRank(a.kind) - kindRank(b.kind)
|
|
23
|
+
|| compare(a.kind, b.kind)
|
|
24
|
+
|| compare(a.name, b.name)
|
|
25
|
+
|| compare(a.specifier ?? a.path ?? '', b.specifier ?? b.path ?? ''))
|
|
26
|
+
|
|
27
|
+
const renderResultFact = (fact: CumulativeResultFact): string => {
|
|
28
|
+
let line = `- ${fact.kind} \`${oneLine(fact.name)}\``
|
|
29
|
+
const shape = fact.signature != null && fact.signature.trim() !== ''
|
|
30
|
+
? oneLine(fact.signature)
|
|
31
|
+
: fact.members != null && fact.members.length > 0
|
|
32
|
+
? `{ ${fact.members.map(oneLine).join(', ')} }`
|
|
33
|
+
: ''
|
|
34
|
+
if (shape !== '') {
|
|
35
|
+
line += `: ${shape}`
|
|
36
|
+
}
|
|
37
|
+
const attrs = Object.keys(fact.attrs ?? {}).sort(compare)
|
|
38
|
+
.map(key => `${key}: ${oneLine(fact.attrs![key]!)}`)
|
|
39
|
+
if (attrs.length > 0) {
|
|
40
|
+
line += ` (${attrs.join(', ')})`
|
|
41
|
+
}
|
|
42
|
+
if (fact.ref != null && fact.ref !== '') {
|
|
43
|
+
line += ` → \`${oneLine(fact.ref)}\``
|
|
44
|
+
}
|
|
45
|
+
if (fact.specifier != null && fact.specifier !== '') {
|
|
46
|
+
line += ` — import from \`${fact.specifier}\``
|
|
47
|
+
}
|
|
48
|
+
if (fact.path != null && fact.path !== '' && fact.path !== fact.name) {
|
|
49
|
+
line += fact.specifier != null && fact.specifier !== ''
|
|
50
|
+
? ` (file \`${fact.path}\`)`
|
|
51
|
+
: ` — file \`${fact.path}\``
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return line
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const moreFacts = (count: number): string =>
|
|
58
|
+
`- … and ${count} more not listed here for space. They exist: check the files before creating anything similar.`
|
|
59
|
+
|
|
60
|
+
const moreNames = (count: number): string => `- … and ${count} more`
|
|
61
|
+
|
|
62
|
+
const heading = (entry: RenderableResultEntry, compact: boolean): string => {
|
|
63
|
+
const notes: string[] = []
|
|
64
|
+
const hasFacts = entry.facts.length > 0
|
|
65
|
+
if (entry.partial === true) {
|
|
66
|
+
notes.push(hasFacts ? PARTIAL_WITH_FACTS : PARTIAL_EMPTY)
|
|
67
|
+
}
|
|
68
|
+
if (entry.source === CumulativeResultSource.Seeded) {
|
|
69
|
+
notes.push(SEEDED)
|
|
70
|
+
}
|
|
71
|
+
if (!hasFacts && entry.summary != null) {
|
|
72
|
+
notes.push(NOT_VERIFIED)
|
|
73
|
+
}
|
|
74
|
+
if (compact && (hasFacts || entry.summary != null)) {
|
|
75
|
+
notes.push(hasFacts ? NAMES_ONLY : SUMMARY_LEFT_OUT)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return `### ${entry.label}${notes.length > 0 ? ` (${notes.join('; ')})` : ''}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Lines under `head`, as many as fit in `max`, with `more(n)` saying how many did not. */
|
|
82
|
+
const fit = (
|
|
83
|
+
head: string[], lines: string[], tail: string[], max: number, more: (count: number) => string,
|
|
84
|
+
): string => {
|
|
85
|
+
const total = (parts: string[]): number => parts.join('\n').length
|
|
86
|
+
if (total([...head, ...lines, ...tail]) <= max) {
|
|
87
|
+
return [...head, ...lines, ...tail].join('\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Room for the "more" line is reserved at its widest, so adding it can never break the cap.
|
|
91
|
+
const reserve = more(lines.length).length + 1
|
|
92
|
+
const kept: string[] = []
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (total([...head, ...kept, line, ...tail]) + reserve > max) {
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
kept.push(line)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [...head, ...kept, more(lines.length - kept.length), ...tail].join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const renderResultEntry = (
|
|
104
|
+
entry: RenderableResultEntry, caps: ResultRenderCaps = {},
|
|
105
|
+
): RenderedResultEntry => {
|
|
106
|
+
const maxChars = caps.maxChars ?? DEFAULT_RESULT_ENTRY_CHARS
|
|
107
|
+
const maxCompactChars = caps.maxCompactChars ?? DEFAULT_RESULT_COMPACT_CHARS
|
|
108
|
+
const facts = sortFacts(entry.facts)
|
|
109
|
+
if (facts.length === 0 && entry.summary == null && entry.partial !== true) {
|
|
110
|
+
return { full: '', compact: '' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const summary = entry.summary != null && entry.summary.trim() !== ''
|
|
114
|
+
? [`${facts.length > 0 ? SUMMARY_WITH_FACTS : SUMMARY_ALONE} ${oneLine(entry.summary)}`]
|
|
115
|
+
: []
|
|
116
|
+
const full = fit([heading(entry, false)], facts.map(renderResultFact), summary, maxChars, moreFacts)
|
|
117
|
+
|
|
118
|
+
const byKind = new Map<string, string[]>()
|
|
119
|
+
for (const fact of facts) {
|
|
120
|
+
const names = byKind.get(fact.kind) ?? []
|
|
121
|
+
if (!names.includes(fact.name)) {
|
|
122
|
+
names.push(fact.name)
|
|
123
|
+
}
|
|
124
|
+
byKind.set(fact.kind, names)
|
|
125
|
+
}
|
|
126
|
+
// One line per kind while the names fit; the rest are counted, never cut.
|
|
127
|
+
const compactHead = heading(entry, true)
|
|
128
|
+
const namesWithin = (reserve: number): { lines: string[], left: number } => {
|
|
129
|
+
const lines: string[] = []
|
|
130
|
+
let left = 0
|
|
131
|
+
for (const [kind, names] of byKind) {
|
|
132
|
+
let line = `- ${kind}:`
|
|
133
|
+
let taken = 0
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
const next = `${line}${taken > 0 ? ',' : ''} \`${oneLine(name)}\``
|
|
136
|
+
if (left > 0 || [compactHead, ...lines, next].join('\n').length + reserve > maxCompactChars) {
|
|
137
|
+
left += 1
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
line = next
|
|
141
|
+
taken += 1
|
|
142
|
+
}
|
|
143
|
+
if (taken > 0) {
|
|
144
|
+
lines.push(line)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { lines, left }
|
|
149
|
+
}
|
|
150
|
+
let names = namesWithin(0)
|
|
151
|
+
if (names.left > 0) {
|
|
152
|
+
// Room for the "more" line is reserved at its widest, so adding it can never break the cap.
|
|
153
|
+
names = namesWithin(moreNames(facts.length).length + 1)
|
|
154
|
+
}
|
|
155
|
+
const compact = [
|
|
156
|
+
compactHead, ...names.lines, ...(names.left > 0 ? [moreNames(names.left)] : []),
|
|
157
|
+
].join('\n')
|
|
158
|
+
|
|
159
|
+
return { full, compact }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const compareResultOrder = (a: readonly number[], b: readonly number[]): number => {
|
|
163
|
+
for (let i = 0; i < Math.min(a.length, b.length); ++i) {
|
|
164
|
+
if (a[i] !== b[i]) {
|
|
165
|
+
return a[i]! - b[i]!
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return b.length - a.length
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rootRunOf = (runId: string): string => {
|
|
173
|
+
const at = runId.indexOf('/')
|
|
174
|
+
|
|
175
|
+
return at < 0 ? runId : runId.slice(0, at)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const resultLabel = (ledger: string, runId: string, step: string): string => {
|
|
179
|
+
const path = runId === ledger
|
|
180
|
+
? ''
|
|
181
|
+
: runId.startsWith(`${ledger}/`)
|
|
182
|
+
? runId.slice(ledger.length + 1)
|
|
183
|
+
: runId.slice(rootRunOf(runId).length + 1)
|
|
184
|
+
|
|
185
|
+
return path !== '' ? `${path}/${step}` : step
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const flatten = (value: unknown): string => {
|
|
189
|
+
if (value == null) {
|
|
190
|
+
return ''
|
|
191
|
+
}
|
|
192
|
+
if (typeof value === 'string') {
|
|
193
|
+
return oneLine(value)
|
|
194
|
+
}
|
|
195
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
196
|
+
return String(value)
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(value)) {
|
|
199
|
+
return value.map(flatten).filter(part => part !== '').join(', ')
|
|
200
|
+
}
|
|
201
|
+
if (typeof value === 'object') {
|
|
202
|
+
return Object.keys(value).sort(compare)
|
|
203
|
+
.map(key => [key, flatten((value as Record<string, unknown>)[key])] as const)
|
|
204
|
+
.filter(([, text]) => text !== '')
|
|
205
|
+
.map(([key, text]) => `${key}: ${text}`)
|
|
206
|
+
.join('; ')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return ''
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const renderResultSummary = (
|
|
213
|
+
answer: unknown, maxChars: number = DEFAULT_RESULT_SUMMARY_CHARS,
|
|
214
|
+
): string => {
|
|
215
|
+
const single = typeof answer === 'object' && answer != null && !Array.isArray(answer)
|
|
216
|
+
&& Object.keys(answer).length === 1
|
|
217
|
+
? (answer as Record<string, unknown>)[Object.keys(answer)[0]!]
|
|
218
|
+
: answer
|
|
219
|
+
|
|
220
|
+
return truncateAt(flatten(single), maxChars)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const union = (left: readonly string[] | undefined, right: readonly string[] | undefined): string[] | undefined =>
|
|
224
|
+
left == null && right == null ? undefined : [...new Set([...(left ?? []), ...(right ?? [])])]
|
|
225
|
+
|
|
226
|
+
const mergeStep = (base: StepResultsSpec | undefined, patch: StepResultsSpec): StepResultsSpec => {
|
|
227
|
+
const extractors = union(base?.extractors, patch.extractors)
|
|
228
|
+
const omit = union(base?.omit, patch.omit)
|
|
229
|
+
const full = base?.full === RESULTS_EVERY_STEP || patch.full === RESULTS_EVERY_STEP
|
|
230
|
+
? RESULTS_EVERY_STEP
|
|
231
|
+
: union(base?.full as string[] | undefined, patch.full as string[] | undefined)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
...patch,
|
|
236
|
+
...(extractors != null ? { extractors } : {}),
|
|
237
|
+
...(omit != null ? { omit } : {}),
|
|
238
|
+
...(full != null ? { full } : {}),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const mergeResultsSpecs = (
|
|
243
|
+
...specs: Array<CumulativeResultsSpec | null | undefined>
|
|
244
|
+
): CumulativeResultsSpec => {
|
|
245
|
+
const merged: CumulativeResultsSpec = {}
|
|
246
|
+
for (const spec of specs) {
|
|
247
|
+
if (spec == null) {
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
const { steps, ...scalars } = spec
|
|
251
|
+
Object.assign(merged, scalars)
|
|
252
|
+
if (steps != null) {
|
|
253
|
+
const into: Record<string, StepResultsSpec> = { ...merged.steps }
|
|
254
|
+
for (const name of Object.keys(steps)) {
|
|
255
|
+
into[name] = mergeStep(into[name], steps[name]!)
|
|
256
|
+
}
|
|
257
|
+
merged.steps = into
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return merged
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
factKey, sortFacts, renderResultFact, renderResultEntry, compareResultOrder, rootRunOf, resultLabel,
|
|
266
|
+
renderResultSummary, mergeResultsSpecs,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const cumulativeResultsHelper = createCumulativeResultsHelper()
|