@owlmeans/agent-common 0.1.18-rc.47 → 0.1.18-rc.49

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.
Files changed (62) hide show
  1. package/README.md +174 -13
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/agent-common/SKILL.md +1 -1
  4. package/build/consts.d.ts +20 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +103 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/errors.d.ts +1 -1
  9. package/build/errors.js +1 -1
  10. package/build/helpers/conversation.d.ts +0 -9
  11. package/build/helpers/conversation.d.ts.map +1 -1
  12. package/build/helpers/conversation.js +0 -28
  13. package/build/helpers/conversation.js.map +1 -1
  14. package/build/helpers/truncate.d.ts +10 -0
  15. package/build/helpers/truncate.d.ts.map +1 -0
  16. package/build/helpers/truncate.js +29 -0
  17. package/build/helpers/truncate.js.map +1 -0
  18. package/build/index.d.ts +4 -1
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js +2 -1
  21. package/build/index.js.map +1 -1
  22. package/build/models/pipeline-spec/types.d.ts +57 -0
  23. package/build/models/pipeline-spec/types.d.ts.map +1 -0
  24. package/build/models/pipeline-spec/types.js +2 -0
  25. package/build/models/pipeline-spec/types.js.map +1 -0
  26. package/build/models/pipeline-spec.d.ts +4 -0
  27. package/build/models/pipeline-spec.d.ts.map +1 -0
  28. package/build/models/pipeline-spec.js +171 -0
  29. package/build/models/pipeline-spec.js.map +1 -0
  30. package/build/pipeline.d.ts +4 -43
  31. package/build/pipeline.d.ts.map +1 -1
  32. package/build/pipeline.js +9 -205
  33. package/build/pipeline.js.map +1 -1
  34. package/build/results/types.d.ts +211 -0
  35. package/build/results/types.d.ts.map +1 -0
  36. package/build/results/types.js +2 -0
  37. package/build/results/types.js.map +1 -0
  38. package/build/results.d.ts +3 -55
  39. package/build/results.d.ts.map +1 -1
  40. package/build/results.js +202 -250
  41. package/build/results.js.map +1 -1
  42. package/build/types.d.ts +1 -137
  43. package/build/types.d.ts.map +1 -1
  44. package/package.json +5 -5
  45. package/src/consts.ts +113 -0
  46. package/src/errors.ts +1 -1
  47. package/src/helpers/conversation.ts +0 -32
  48. package/src/helpers/truncate.ts +31 -0
  49. package/src/index.ts +4 -1
  50. package/src/models/pipeline-spec/types.ts +58 -0
  51. package/src/models/pipeline-spec.ts +202 -0
  52. package/src/pipeline.ts +10 -231
  53. package/src/results/types.ts +221 -0
  54. package/src/results.ts +229 -274
  55. package/src/types.ts +2 -147
  56. package/tests/pipeline.spec.ts +26 -29
  57. package/tests/results.spec.ts +24 -26
  58. package/build/flows.d.ts +0 -21
  59. package/build/flows.d.ts.map +0 -1
  60. package/build/flows.js +0 -105
  61. package/build/flows.js.map +0 -1
  62. package/src/flows.ts +0 -113
package/src/results.ts CHANGED
@@ -1,315 +1,270 @@
1
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/conversation.js'
2
+ import { truncateAt } from './helpers/truncate.js'
3
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 { CumulativeResultFact, CumulativeResultsSpec, RenderableResultEntry, ResultRenderCaps, StepResultsSpec } from './types.js'
5
-
6
- /**
7
- * CUMULATIVE PIPELINE RESULTS — the contracts.
8
- *
9
- * After a step of a pipeline finishes, code reads what it produced — a type's shape, a file, an
10
- * endpoint's method, path and parameters, a resource's table — into a small list of FACTS, and the
11
- * later steps are told those facts instead of re-deriving them from the files. A later step that is
12
- * handed the authoritative names does not invent a second spelling of them, and that is the whole
13
- * point: it removes the class of errors that live BETWEEN steps.
14
- *
15
- * Everything here is data and pure functions. The runtime that extracts, stores and cuts the views
16
- * is `@owlmeans/agent`'s `cumulativeResultsPlugin`; the prompt block it lands in is
17
- * `@owlmeans/llm`'s. A backend or a browser bundle reads an entry without either.
18
- */
19
-
20
- const compare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0
21
-
22
- const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
23
-
24
- /**
25
- * The identity of a fact inside a ledger: what it is, what it is called and where it comes from.
26
- * Two facts with one key are one fact, owned by whichever entry produced it first.
27
- */
28
- export const factKey = (fact: CumulativeResultFact): string =>
29
- `${fact.kind}\u0000${fact.name}\u0000${fact.specifier ?? fact.path ?? ''}`
30
-
31
- /** File facts last — a location is the first thing a budget can spare, a shape the last. */
32
- const kindRank = (kind: string): number => kind === CumulativeFactKind.File ? 1 : 0
33
-
34
- /**
35
- * Facts in the one order they are ever rendered in: by kind (file facts last), name and location,
36
- * with a code-unit comparison so every host sorts them alike.
37
- */
38
- export const sortFacts = (facts: readonly CumulativeResultFact[]): CumulativeResultFact[] =>
39
- [...facts].sort((a, b) =>
40
- kindRank(a.kind) - kindRank(b.kind)
41
- || compare(a.kind, b.kind)
42
- || compare(a.name, b.name)
43
- || compare(a.specifier ?? a.path ?? '', b.specifier ?? b.path ?? ''))
44
-
45
- /** One fact as one prompt line: kind and name, shape, scalars, pointer, and where it comes from. */
46
- export const renderResultFact = (fact: CumulativeResultFact): string => {
47
- let line = `- ${fact.kind} \`${oneLine(fact.name)}\``
48
- const shape = fact.signature != null && fact.signature.trim() !== ''
49
- ? oneLine(fact.signature)
50
- : fact.members != null && fact.members.length > 0
51
- ? `{ ${fact.members.map(oneLine).join(', ')} }`
52
- : ''
53
- if (shape !== '') {
54
- line += `: ${shape}`
55
- }
56
- const attrs = Object.keys(fact.attrs ?? {}).sort(compare)
57
- .map(key => `${key}: ${oneLine(fact.attrs![key]!)}`)
58
- if (attrs.length > 0) {
59
- line += ` (${attrs.join(', ')})`
60
- }
61
- if (fact.ref != null && fact.ref !== '') {
62
- line += ` → \`${oneLine(fact.ref)}\``
63
- }
64
- if (fact.specifier != null && fact.specifier !== '') {
65
- line += ` — import from \`${fact.specifier}\``
66
- }
67
- if (fact.path != null && fact.path !== '' && fact.path !== fact.name) {
68
- line += fact.specifier != null && fact.specifier !== ''
69
- ? ` (file \`${fact.path}\`)`
70
- : ` — file \`${fact.path}\``
71
- }
72
-
73
- return line
74
- }
75
-
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
+ }
76
53
 
77
- const moreFacts = (count: number): string =>
78
- `- … and ${count} more not listed here for space. They exist: check the files before creating anything similar.`
54
+ return line
55
+ }
79
56
 
80
- const moreNames = (count: number): string => `- … and ${count} more`
57
+ const moreFacts = (count: number): string =>
58
+ `- … and ${count} more not listed here for space. They exist: check the files before creating anything similar.`
81
59
 
82
- const heading = (entry: RenderableResultEntry, compact: boolean): string => {
83
- const notes: string[] = []
84
- const hasFacts = entry.facts.length > 0
85
- if (entry.partial === true) {
86
- notes.push(hasFacts ? PARTIAL_WITH_FACTS : PARTIAL_EMPTY)
87
- }
88
- if (entry.source === CumulativeResultSource.Seeded) {
89
- notes.push(SEEDED)
90
- }
91
- if (!hasFacts && entry.summary != null) {
92
- notes.push(NOT_VERIFIED)
93
- }
94
- if (compact && (hasFacts || entry.summary != null)) {
95
- notes.push(hasFacts ? NAMES_ONLY : SUMMARY_LEFT_OUT)
96
- }
60
+ const moreNames = (count: number): string => `- … and ${count} more`
97
61
 
98
- return `### ${entry.label}${notes.length > 0 ? ` (${notes.join('; ')})` : ''}`
99
- }
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
+ }
100
77
 
101
- /** Lines under `head`, as many as fit in `max`, with `more(n)` saying how many did not. */
102
- const fit = (
103
- head: string[], lines: string[], tail: string[], max: number, more: (count: number) => string,
104
- ): string => {
105
- const total = (parts: string[]): number => parts.join('\n').length
106
- if (total([...head, ...lines, ...tail]) <= max) {
107
- return [...head, ...lines, ...tail].join('\n')
78
+ return `### ${entry.label}${notes.length > 0 ? ` (${notes.join('; ')})` : ''}`
108
79
  }
109
80
 
110
- // Room for the "more" line is reserved at its widest, so adding it can never break the cap.
111
- const reserve = more(lines.length).length + 1
112
- const kept: string[] = []
113
- for (const line of lines) {
114
- if (total([...head, ...kept, line, ...tail]) + reserve > max) {
115
- break
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')
116
88
  }
117
- kept.push(line)
118
- }
119
89
 
120
- return [...head, ...kept, more(lines.length - kept.length), ...tail].join('\n')
121
- }
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
+ }
122
99
 
123
- /**
124
- * An entry's two renderings — in full, and as names only — or two empty strings when it has
125
- * nothing to say.
126
- *
127
- * Deterministic and free of anything that varies between two renderings of the same facts: no
128
- * timestamp, no run id, no revision. Over its cap an entry DROPS whole facts, in render order, and
129
- * says how many it dropped; it never cuts one fact's line, because half a signature is a wrong
130
- * signature. An empty entry that is not partial renders to nothing, so it costs a view nothing.
131
- */
132
- export const renderResultEntry = (
133
- entry: RenderableResultEntry, caps: ResultRenderCaps = {},
134
- ): { full: string, compact: string } => {
135
- const maxChars = caps.maxChars ?? DEFAULT_RESULT_ENTRY_CHARS
136
- const maxCompactChars = caps.maxCompactChars ?? DEFAULT_RESULT_COMPACT_CHARS
137
- const facts = sortFacts(entry.facts)
138
- if (facts.length === 0 && entry.summary == null && entry.partial !== true) {
139
- return { full: '', compact: '' }
100
+ return [...head, ...kept, more(lines.length - kept.length), ...tail].join('\n')
140
101
  }
141
102
 
142
- const summary = entry.summary != null && entry.summary.trim() !== ''
143
- ? [`${facts.length > 0 ? SUMMARY_WITH_FACTS : SUMMARY_ALONE} ${oneLine(entry.summary)}`]
144
- : []
145
- const full = fit([heading(entry, false)], facts.map(renderResultFact), summary, maxChars, moreFacts)
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)
146
117
 
147
- const byKind = new Map<string, string[]>()
148
- for (const fact of facts) {
149
- const names = byKind.get(fact.kind) ?? []
150
- if (!names.includes(fact.name)) {
151
- names.push(fact.name)
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)
152
125
  }
153
- byKind.set(fact.kind, names)
154
- }
155
- // One line per kind while the names fit; the rest are counted, never cut.
156
- const compactHead = heading(entry, true)
157
- const namesWithin = (reserve: number): { lines: string[], left: number } => {
158
- const lines: string[] = []
159
- let left = 0
160
- for (const [kind, names] of byKind) {
161
- let line = `- ${kind}:`
162
- let taken = 0
163
- for (const name of names) {
164
- const next = `${line}${taken > 0 ? ',' : ''} \`${oneLine(name)}\``
165
- if (left > 0 || [compactHead, ...lines, next].join('\n').length + reserve > maxCompactChars) {
166
- left += 1
167
- continue
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)
168
145
  }
169
- line = next
170
- taken += 1
171
- }
172
- if (taken > 0) {
173
- lines.push(line)
174
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)
175
154
  }
155
+ const compact = [
156
+ compactHead, ...names.lines, ...(names.left > 0 ? [moreNames(names.left)] : []),
157
+ ].join('\n')
176
158
 
177
- return { lines, left }
159
+ return { full, compact }
178
160
  }
179
- let names = namesWithin(0)
180
- if (names.left > 0) {
181
- // Room for the "more" line is reserved at its widest, so adding it can never break the cap.
182
- names = namesWithin(moreNames(facts.length).length + 1)
183
- }
184
- const compact = [
185
- compactHead, ...names.lines, ...(names.left > 0 ? [moreNames(names.left)] : []),
186
- ].join('\n')
187
161
 
188
- return { full, compact }
189
- }
190
-
191
- /**
192
- * Ledger order: element by element, and where one key is a prefix of the other, the LONGER one
193
- * first — an entry of a run composed under a step sorts ahead of that step's own entry, which is
194
- * written only once the composed run has finished.
195
- */
196
- export const compareResultOrder = (a: readonly number[], b: readonly number[]): number => {
197
- for (let i = 0; i < Math.min(a.length, b.length); ++i) {
198
- if (a[i] !== b[i]) {
199
- return a[i]! - b[i]!
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
+ }
200
167
  }
201
- }
202
168
 
203
- return b.length - a.length
204
- }
169
+ return b.length - a.length
170
+ }
205
171
 
206
- /** The root run of a composition — what a composed run's id (`<parent>/<step>`) starts with. */
207
- export const rootRunOf = (runId: string): string => {
208
- const at = runId.indexOf('/')
172
+ const rootRunOf = (runId: string): string => {
173
+ const at = runId.indexOf('/')
209
174
 
210
- return at < 0 ? runId : runId.slice(0, at)
211
- }
175
+ return at < 0 ? runId : runId.slice(0, at)
176
+ }
212
177
 
213
- /**
214
- * How an entry is headed: the step, behind the path of composing steps between the ledger's root
215
- * run and the run that produced it. A root-run step is headed by its bare name.
216
- */
217
- export const resultLabel = (ledger: string, runId: string, step: string): string => {
218
- const path = runId === ledger
219
- ? ''
220
- : runId.startsWith(`${ledger}/`)
221
- ? runId.slice(ledger.length + 1)
222
- : runId.slice(rootRunOf(runId).length + 1)
223
-
224
- return path !== '' ? `${path}/${step}` : step
225
- }
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)
226
184
 
227
- const flatten = (value: unknown): string => {
228
- if (value == null) {
229
- return ''
230
- }
231
- if (typeof value === 'string') {
232
- return oneLine(value)
233
- }
234
- if (typeof value === 'number' || typeof value === 'boolean') {
235
- return String(value)
185
+ return path !== '' ? `${path}/${step}` : step
236
186
  }
237
- if (Array.isArray(value)) {
238
- return value.map(flatten).filter(part => part !== '').join(', ')
239
- }
240
- if (typeof value === 'object') {
241
- return Object.keys(value).sort(compare)
242
- .map(key => [key, flatten((value as Record<string, unknown>)[key])] as const)
243
- .filter(([, text]) => text !== '')
244
- .map(([key, text]) => `${key}: ${text}`)
245
- .join('; ')
246
- }
247
-
248
- return ''
249
- }
250
187
 
251
- /**
252
- * A model's schema-shaped summary as one capped line, the same bytes for the same answer.
253
- *
254
- * An object renders as `key: value` pairs in key order; one with a single key renders as that
255
- * value alone, so the default `{ summary }` answer reads as prose. The cap is applied here, after
256
- * the model answered — a cap in a prompt is a request.
257
- */
258
- export const renderResultSummary = (
259
- answer: unknown, maxChars: number = DEFAULT_RESULT_SUMMARY_CHARS,
260
- ): string => {
261
- const single = typeof answer === 'object' && answer != null && !Array.isArray(answer)
262
- && Object.keys(answer).length === 1
263
- ? (answer as Record<string, unknown>)[Object.keys(answer)[0]!]
264
- : answer
265
-
266
- return truncateAt(flatten(single), maxChars)
267
- }
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
+ }
268
208
 
269
- const union = (left: readonly string[] | undefined, right: readonly string[] | undefined): string[] | undefined =>
270
- left == null && right == null ? undefined : [...new Set([...(left ?? []), ...(right ?? [])])]
209
+ return ''
210
+ }
271
211
 
272
- const mergeStep = (base: StepResultsSpec | undefined, patch: StepResultsSpec): StepResultsSpec => {
273
- const extractors = union(base?.extractors, patch.extractors)
274
- const omit = union(base?.omit, patch.omit)
275
- const full = base?.full === RESULTS_EVERY_STEP || patch.full === RESULTS_EVERY_STEP
276
- ? RESULTS_EVERY_STEP
277
- : union(base?.full as string[] | undefined, patch.full as string[] | undefined)
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
278
219
 
279
- return {
280
- ...base,
281
- ...patch,
282
- ...(extractors != null ? { extractors } : {}),
283
- ...(omit != null ? { omit } : {}),
284
- ...(full != null ? { full } : {}),
220
+ return truncateAt(flatten(single), maxChars)
285
221
  }
286
- }
287
222
 
288
- /**
289
- * Several declarations as one — a pipeline's own, then any overlay (a variant, a deployment).
290
- *
291
- * Later declarations win field by field, except the lists: extractors, `full` consumers and `omit`
292
- * consumers ACCUMULATE, the way skills do down an execution chain, because an overlay that adds an
293
- * extractor is not asking to lose the ones the pipeline declared. `'*'` in `full` absorbs names.
294
- */
295
- export const mergeResultsSpecs = (
296
- ...specs: Array<CumulativeResultsSpec | null | undefined>
297
- ): CumulativeResultsSpec => {
298
- const merged: CumulativeResultsSpec = {}
299
- for (const spec of specs) {
300
- if (spec == null) {
301
- continue
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 } : {}),
302
239
  }
303
- const { steps, ...scalars } = spec
304
- Object.assign(merged, scalars)
305
- if (steps != null) {
306
- const into: Record<string, StepResultsSpec> = { ...merged.steps }
307
- for (const name of Object.keys(steps)) {
308
- into[name] = mergeStep(into[name], steps[name]!)
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
309
258
  }
310
- merged.steps = into
311
259
  }
260
+
261
+ return merged
312
262
  }
313
263
 
314
- return merged
264
+ return {
265
+ factKey, sortFacts, renderResultFact, renderResultEntry, compareResultOrder, rootRunOf, resultLabel,
266
+ renderResultSummary, mergeResultsSpecs,
267
+ }
315
268
  }
269
+
270
+ export const cumulativeResultsHelper = createCumulativeResultsHelper()