@inflexa-ai/harness 0.20.0 → 0.21.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.
Files changed (133) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +31 -6
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/loop/run-agent.js +58 -23
  27. package/dist/loop/tool-detail.d.ts +19 -2
  28. package/dist/loop/tool-detail.js +35 -2
  29. package/dist/memory/content-to-cortex.d.ts +3 -2
  30. package/dist/memory/content-to-cortex.js +21 -12
  31. package/dist/memory/conversation-display-replay.js +14 -7
  32. package/dist/memory/thread-history.d.ts +60 -3
  33. package/dist/memory/thread-history.js +55 -16
  34. package/dist/memory/thread-store.d.ts +11 -1
  35. package/dist/memory/thread-store.js +12 -2
  36. package/dist/prompts/conversation.d.ts +1 -1
  37. package/dist/prompts/conversation.js +23 -6
  38. package/dist/prompts/report-session.d.ts +1 -1
  39. package/dist/prompts/report-session.js +183 -9
  40. package/dist/report-model/authoring-grammar.d.ts +463 -0
  41. package/dist/report-model/authoring-grammar.js +77 -0
  42. package/dist/report-model/block-walk.d.ts +57 -14
  43. package/dist/report-model/block-walk.js +116 -11
  44. package/dist/report-model/draft-finish.d.ts +22 -4
  45. package/dist/report-model/draft-finish.js +39 -7
  46. package/dist/report-model/draft-operations.d.ts +3 -0
  47. package/dist/report-model/draft-operations.js +19 -5
  48. package/dist/report-model/draft-read.d.ts +8 -5
  49. package/dist/report-model/draft-read.js +21 -5
  50. package/dist/report-model/fixture-resolver.js +15 -4
  51. package/dist/report-model/pin-snapshot.d.ts +24 -6
  52. package/dist/report-model/pin-snapshot.js +204 -6
  53. package/dist/report-model/production-resolver.js +15 -4
  54. package/dist/report-model/reference-resolver.d.ts +50 -1
  55. package/dist/report-model/reference-resolver.js +94 -1
  56. package/dist/report-model/reference-stamp.d.ts +45 -0
  57. package/dist/report-model/reference-stamp.js +173 -0
  58. package/dist/report-model/structural-validation.d.ts +4 -1
  59. package/dist/report-model/structural-validation.js +48 -3
  60. package/dist/report-model/validate.d.ts +1 -1
  61. package/dist/report-model/validate.js +1 -1
  62. package/dist/report-render/asset-lookup.d.ts +19 -0
  63. package/dist/report-render/asset-lookup.js +37 -0
  64. package/dist/report-render/assets.d.ts +45 -1
  65. package/dist/report-render/assets.js +65 -6
  66. package/dist/report-render/chart-presets.d.ts +102 -0
  67. package/dist/report-render/chart-presets.js +205 -0
  68. package/dist/report-render/chart.d.ts +112 -10
  69. package/dist/report-render/chart.js +1164 -41
  70. package/dist/report-render/design.d.ts +99 -0
  71. package/dist/report-render/design.js +289 -107
  72. package/dist/report-render/number-format.d.ts +117 -0
  73. package/dist/report-render/number-format.js +479 -0
  74. package/dist/report-render/page.d.ts +121 -5
  75. package/dist/report-render/page.js +689 -7
  76. package/dist/report-render/references.d.ts +70 -16
  77. package/dist/report-render/references.js +54 -22
  78. package/dist/report-render/render.d.ts +23 -5
  79. package/dist/report-render/render.js +81 -22
  80. package/dist/report-render/table-data.d.ts +92 -0
  81. package/dist/report-render/table-data.js +129 -0
  82. package/dist/report-render/types.d.ts +15 -0
  83. package/dist/report-render/value-bridge.js +6 -2
  84. package/dist/report-render/views/chart-view.d.ts +12 -5
  85. package/dist/report-render/views/chart-view.js +14 -8
  86. package/dist/report-render/views/page-view.d.ts +36 -15
  87. package/dist/report-render/views/page-view.js +45 -23
  88. package/dist/report-render/views/prose.d.ts +12 -2
  89. package/dist/report-render/views/prose.js +29 -7
  90. package/dist/report-render/views/references-view.d.ts +22 -8
  91. package/dist/report-render/views/references-view.js +101 -13
  92. package/dist/report-render/views/values.d.ts +128 -13
  93. package/dist/report-render/views/values.js +199 -18
  94. package/dist/runtime/assemble.d.ts +39 -5
  95. package/dist/runtime/assemble.js +47 -6
  96. package/dist/sandbox/client.d.ts +10 -1
  97. package/dist/sandbox/create-sandbox.d.ts +9 -4
  98. package/dist/sandbox/create-sandbox.js +22 -16
  99. package/dist/sandbox/docker-client.js +10 -7
  100. package/dist/sandbox/mount-plan.d.ts +45 -7
  101. package/dist/sandbox/mount-plan.js +43 -5
  102. package/dist/sandbox/types.d.ts +6 -0
  103. package/dist/state/init.js +38 -9
  104. package/dist/state/report-session-state.d.ts +42 -1
  105. package/dist/state/report-session-state.js +70 -2
  106. package/dist/state/report-versions.d.ts +22 -17
  107. package/dist/state/report-versions.js +48 -35
  108. package/dist/state/snapshot-parse.js +16 -1
  109. package/dist/tools/define-tool.d.ts +32 -0
  110. package/dist/tools/define-tool.js +4 -0
  111. package/dist/tools/detail-resolver.d.ts +7 -2
  112. package/dist/tools/detail-resolver.js +7 -2
  113. package/dist/tools/iterate-report.d.ts +1 -1
  114. package/dist/tools/report/preview-snapshot.js +1 -0
  115. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  116. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  117. package/dist/tools/report-session/derive-table.d.ts +162 -0
  118. package/dist/tools/report-session/derive-table.js +459 -0
  119. package/dist/tools/report-session/examine-page.d.ts +48 -18
  120. package/dist/tools/report-session/examine-page.js +204 -24
  121. package/dist/tools/report-session/index.d.ts +2 -0
  122. package/dist/tools/report-session/index.js +2 -0
  123. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  124. package/dist/tools/report-session/list-artifacts.js +197 -0
  125. package/dist/tools/report-session/preview-report.d.ts +6 -0
  126. package/dist/tools/report-session/preview-report.js +180 -28
  127. package/dist/tools/report-session/record-version.d.ts +22 -10
  128. package/dist/tools/report-session/record-version.js +99 -14
  129. package/dist/tools/start-report-session.d.ts +115 -0
  130. package/dist/tools/start-report-session.js +179 -0
  131. package/dist/workspace/paths.d.ts +50 -0
  132. package/dist/workspace/paths.js +63 -0
  133. package/package.json +2 -1
@@ -2,12 +2,14 @@
2
2
  * Report-session spawn — the host-agnostic operation that makes a `report`
3
3
  * child thread from a conversation.
4
4
  *
5
- * The operation owns no table. It composes four reads and writes the store and
6
- * the thread history already give: `getThread` for the parent, `latestSeq` for
7
- * the anchor, `listThreads` for the count of report children, and
8
- * `createThread` for the insert. The one storage read this capability adds,
9
- * `latestSeq`, lives on the thread history, because that module owns the
10
- * `messages` table.
5
+ * The operation owns no table. It composes the reads and the writes that the
6
+ * store, the thread history, and the working memory already give. `getThread`
7
+ * finds the parent, and `latestSeq` gives the anchor. `listThreads` counts the
8
+ * report children, and `createThread` writes the insert. `render` and
9
+ * `appendTurn` make the seed, and `purgeThread` removes a child that holds no
10
+ * seed. The two storage reads that this capability adds, `latestSeq` and
11
+ * `countUserTurnsAfter`, live on the thread history, because that module owns
12
+ * the `messages` table.
11
13
  *
12
14
  * The reads and the insert take no lock and no transaction. A concurrent turn
13
15
  * can append between the anchor read and the insert, and a retract can cut the
@@ -21,17 +23,50 @@
21
23
  *
22
24
  * The spawn also gates on the eyes of the composition. A report session records
23
25
  * a version only after a look at the rendered page, and only a browser can look.
24
- * Thus a composition with no browser and no injected capture seam can author a
25
- * draft that no gate ever accepts. The spawn refuses that session before it
26
- * writes a row, because a dead-end thread is worse than a refusal that names the
27
- * absent capability.
26
+ * Three routes give a look: the eyes seam, the capture seam, and a chrome config
27
+ * that names a browser. A composition with none of the three can author a draft
28
+ * that no gate ever accepts. The spawn refuses that session before it writes a
29
+ * row, because a dead-end thread is worse than a refusal that names the absent
30
+ * capability.
31
+ *
32
+ * The spawn seeds the context of the child at the anchor. It writes one message
33
+ * that holds the brief and a copy of the working-memory render. The transcript
34
+ * is append-only, thus the copy stays frozen at the anchor by construction, and
35
+ * a later change to the working memory leaves it as it is. When the seed write
36
+ * fails, the spawn purges the child, because a report thread with no context is
37
+ * a dead end.
38
+ *
39
+ * The seed takes no redaction pass, and that is deliberate. The two parts are
40
+ * agent-authored, the same trust tier as the working-memory render of a
41
+ * conversation turn. The harness redacts the new input of a user one time, at
42
+ * the assembly of that turn. A second pass here would scrub text that the
43
+ * assembly already admitted, and it would report a false clean on the rest.
44
+ *
45
+ * The spawn mints one moment, and that moment carries two pins. The anchor pins
46
+ * the transcript of the parent, and the injected anchor operation pins the data
47
+ * of the analysis. A pin at the first tool call of a later turn reads a
48
+ * different moment, thus the session can cite an artifact that the anchor never
49
+ * held.
50
+ *
51
+ * The delta read gives the state that a caller reads before a new spawn. It
52
+ * gives the report child with the greatest anchor. It also gives the count of
53
+ * user turns of the parent past that anchor. The listing orders by `updated_at`
54
+ * and not by the anchor, thus the read walks each page of the children.
55
+ *
56
+ * The unit of the delta is a turn, and it is not a raw seq. A turn appends after
57
+ * its own loop runs, thus the anchor of a child sits below the rows of the ask
58
+ * that made it. A raw seq comparison would name each child stale one turn after
59
+ * the spawn.
28
60
  */
29
- import { type ResultAsync } from "neverthrow";
61
+ import { ResultAsync } from "neverthrow";
30
62
  import type { Pool } from "pg";
31
63
  import type { DbError } from "../lib/db-result.js";
32
64
  import { type ChromeConfig } from "../lib/chrome.js";
65
+ import type { AcquireEyes } from "../lib/eyes.js";
66
+ import type { Logger } from "../lib/logger.js";
33
67
  import type { CapturePage } from "../lib/page-capture.js";
34
68
  import { type Thread, type ThreadInputError, type ThreadPage, type ThreadType } from "../memory/thread-store.js";
69
+ import type { EnsureSessionStateResult } from "./report-session-runtime.js";
35
70
  /**
36
71
  * A spawn the operation refuses before it writes a row. Distinct from
37
72
  * `DbError`: nothing failed at the driver — the parent names a session the
@@ -44,7 +79,8 @@ import { type Thread, type ThreadInputError, type ThreadPage, type ThreadType }
44
79
  * report session cannot spawn another. `empty_parent_transcript` refuses a
45
80
  * report on a parent that holds no messages, because such a report reports
46
81
  * nothing. `no_browser` refuses every spawn under a composition that gives no
47
- * eyes, and it carries the line that explains the absent capability.
82
+ * route to a look: no eyes seam, no capture seam, and no configured browser
83
+ * endpoint. It carries the line that explains the absent capability.
48
84
  */
49
85
  export type SpawnRefusal = {
50
86
  readonly type: "no_browser";
@@ -70,6 +106,61 @@ export interface ReportSessionPaging {
70
106
  readonly page?: number;
71
107
  readonly perPage?: number;
72
108
  }
109
+ /**
110
+ * The intent brief of one report session. The caller writes it at the moment of
111
+ * the ask, and each field is short prose.
112
+ *
113
+ * The brief carries intent alone. No field names a path, a dataset, or a format,
114
+ * because the report session reads those from the workspace itself.
115
+ */
116
+ export interface ReportBrief {
117
+ /** The question that the report must answer. */
118
+ readonly objective: string;
119
+ /** The reader of the report. */
120
+ readonly audience: string;
121
+ /** The line that the report takes through the evidence. */
122
+ readonly angle: string;
123
+ /** The material that the report must leave out. */
124
+ readonly exclusions?: string;
125
+ /** The points that the user did not decide yet. */
126
+ readonly openQuestions?: string;
127
+ }
128
+ /**
129
+ * The report child that carries the greatest anchor. A tie on the anchor comes
130
+ * from two concurrent spawns, and the newest `createdAt` wins it.
131
+ */
132
+ export interface NewestReportChild {
133
+ readonly threadId: string;
134
+ readonly title: string | null;
135
+ /** The `parentSeq` of the child — the point of the parent transcript that it was built on. */
136
+ readonly anchor: number;
137
+ readonly createdAt: Date;
138
+ }
139
+ /**
140
+ * The state that a caller reads before a new spawn. `newestChild` is `null` when
141
+ * the parent holds no live report child, and an archived child reads the same
142
+ * way, because a steer into hidden state is not permitted. An absent parent
143
+ * reads the same way.
144
+ */
145
+ export interface ReportSessionDelta {
146
+ readonly newestChild: NewestReportChild | null;
147
+ /**
148
+ * The count of the parent turns that a person opened past the anchor of
149
+ * `newestChild` — the new work of the parent past that spawn point. A
150
+ * synthetic record of the host adds nothing to it.
151
+ *
152
+ * It is `null` when `newestChild` is `null`, because a parent with no child
153
+ * gives no anchor to count from. No count query runs in that case.
154
+ */
155
+ readonly userTurnsSinceAnchor: number | null;
156
+ }
157
+ /**
158
+ * The construction inputs of the report-session operations.
159
+ *
160
+ * Three of the fields name a route to a look at the rendered page: `eyes`,
161
+ * `capture`, and a `chrome` config that names a browser. One present route
162
+ * passes the gate of the spawn.
163
+ */
73
164
  export interface ReportSessionSpawnDeps {
74
165
  readonly pool: Pool;
75
166
  /**
@@ -84,29 +175,95 @@ export interface ReportSessionSpawnDeps {
84
175
  * satisfies the gate on its own.
85
176
  */
86
177
  readonly capture?: CapturePage;
178
+ /**
179
+ * The eyes seam that the same composition gives to the eyes tool. A bound
180
+ * seam gives a browser for one look, thus it satisfies the gate with no
181
+ * configured endpoint. The spawn reads only the presence of the seam, and
182
+ * it acquires no lease.
183
+ */
184
+ readonly eyes?: AcquireEyes;
185
+ /**
186
+ * The anchor operation of the report session runtime. The spawn runs it
187
+ * after the seed of the child lands, thus the transcript anchor and the data
188
+ * snapshot pin at one moment.
189
+ *
190
+ * The dep is optional. A composition that binds none pins no snapshot at the
191
+ * spawn, and the first session tool call pins instead, because the operation
192
+ * is idempotent.
193
+ */
194
+ readonly anchorSession?: (threadId: string) => Promise<EnsureSessionStateResult>;
195
+ /** Operational logging seam. An omitted logger falls back to the no-op. */
196
+ readonly logger?: Logger;
87
197
  }
198
+ /**
199
+ * Whether the composition gives a route to a look at the rendered page. The
200
+ * three routes are the eyes seam, the capture seam, and a chrome config that
201
+ * names a browser. One present route opens the gate.
202
+ *
203
+ * The spawn reads the rule before it writes a row. A caller that runs work
204
+ * before the spawn reads the same rule, thus it skips that work under a closed
205
+ * gate. The refusal itself stays with the spawn, and a caller copies no line of
206
+ * it.
207
+ *
208
+ * The parameter names the route fields alone. Thus a caller passes the deps
209
+ * value that it gives to `createReportSessionSpawn`, and the two answers cannot
210
+ * disagree.
211
+ *
212
+ * No type binds a new route to the parameter. Thus this predicate is the one
213
+ * gate expression of the module, and a new route belongs here and nowhere else.
214
+ */
215
+ export declare function compositionHasEyes(deps: Pick<ReportSessionSpawnDeps, "chrome" | "capture" | "eyes">): boolean;
88
216
  export interface ReportSessionSpawn {
89
217
  /**
90
218
  * Make a `report` child of the parent conversation and return the full row.
91
219
  * The child takes the analysis of the parent, the parent thread id, and the
92
- * anchor — the parent's latest `messages.seq` at this moment.
220
+ * anchor — the parent's latest `messages.seq` at this moment. It also takes
221
+ * one seed message that holds the brief and the working-memory render.
222
+ *
223
+ * The seed lands first, and the injected anchor operation then pins the data
224
+ * snapshot of the child. A failed pin keeps the child and it keeps this
225
+ * result on the ok channel, thus the outcome vocabulary holds no pin arm.
93
226
  *
94
227
  * Refused with a `SpawnRefusal`, no row written: a composition with no
95
228
  * eyes, an absent or archived parent, a parent that is not a conversation,
96
229
  * and a parent with no messages. A store refusal (`DbError`,
97
- * `ThreadInputError`) passes through unchanged.
230
+ * `ThreadInputError`) passes through unchanged. A failed seed write purges
231
+ * the child and returns the fault, thus no context-less thread survives.
98
232
  */
99
- spawnReportSession(parentThreadId: string): ResultAsync<Thread, SpawnRefusal | DbError | ThreadInputError>;
233
+ spawnReportSession(parentThreadId: string, brief: ReportBrief): ResultAsync<Thread, SpawnRefusal | DbError | ThreadInputError>;
100
234
  /**
101
235
  * The report sessions of one analysis, through the thread listing narrowed
102
236
  * to the type `report`. It adds no predicate of its own, so its answer and
103
237
  * the thread listing cannot disagree.
104
238
  */
105
239
  listReportSessions(analysisId: string, paging?: ReportSessionPaging): ResultAsync<ThreadPage, DbError>;
240
+ /**
241
+ * The state that a caller reads before a new spawn. It gives the live report
242
+ * child of the parent with the greatest anchor. It also gives the count of
243
+ * the parent turns that a person opened past that anchor.
244
+ *
245
+ * The count admits one turn of new work by construction. A turn appends
246
+ * after its own loop runs, thus the anchor of a child sits below the rows of
247
+ * the ask that made it, and that ask counts as one.
248
+ *
249
+ * The read picks the child first, thus one anchor bounds the count. A parent
250
+ * with no child needs no anchor, and no count query runs. The read walks each
251
+ * page of the children listing, because the listing orders by `updated_at`
252
+ * and not by the anchor. It reads no model judgment, and it writes nothing.
253
+ */
254
+ reportSessionDelta(parentThreadId: string): ResultAsync<ReportSessionDelta, DbError>;
106
255
  }
256
+ /**
257
+ * The page size of the walk over the report children of one parent. The
258
+ * one-version policy keeps the count of children small, thus the walk reads one
259
+ * page on each real parent. It is exported because a test seeds one row more
260
+ * than one page, and a hard-coded count in the test would not follow a change
261
+ * here.
262
+ */
263
+ export declare const REPORT_CHILD_PAGE_SIZE = 100;
107
264
  /**
108
265
  * Build the report-session operations bound to a Postgres pool. The factory
109
- * closure captures `pool` and constructs the store and the thread history from
110
- * it, the same way the chat-turn preparation does.
266
+ * closure captures `pool` and constructs the store, the thread history, and the
267
+ * working memory from it, the same way the chat-turn preparation does.
111
268
  */
112
269
  export declare function createReportSessionSpawn(deps: ReportSessionSpawnDeps): ReportSessionSpawn;
@@ -2,12 +2,14 @@
2
2
  * Report-session spawn — the host-agnostic operation that makes a `report`
3
3
  * child thread from a conversation.
4
4
  *
5
- * The operation owns no table. It composes four reads and writes the store and
6
- * the thread history already give: `getThread` for the parent, `latestSeq` for
7
- * the anchor, `listThreads` for the count of report children, and
8
- * `createThread` for the insert. The one storage read this capability adds,
9
- * `latestSeq`, lives on the thread history, because that module owns the
10
- * `messages` table.
5
+ * The operation owns no table. It composes the reads and the writes that the
6
+ * store, the thread history, and the working memory already give. `getThread`
7
+ * finds the parent, and `latestSeq` gives the anchor. `listThreads` counts the
8
+ * report children, and `createThread` writes the insert. `render` and
9
+ * `appendTurn` make the seed, and `purgeThread` removes a child that holds no
10
+ * seed. The two storage reads that this capability adds, `latestSeq` and
11
+ * `countUserTurnsAfter`, live on the thread history, because that module owns
12
+ * the `messages` table.
11
13
  *
12
14
  * The reads and the insert take no lock and no transaction. A concurrent turn
13
15
  * can append between the anchor read and the insert, and a retract can cut the
@@ -21,20 +23,80 @@
21
23
  *
22
24
  * The spawn also gates on the eyes of the composition. A report session records
23
25
  * a version only after a look at the rendered page, and only a browser can look.
24
- * Thus a composition with no browser and no injected capture seam can author a
25
- * draft that no gate ever accepts. The spawn refuses that session before it
26
- * writes a row, because a dead-end thread is worse than a refusal that names the
27
- * absent capability.
26
+ * Three routes give a look: the eyes seam, the capture seam, and a chrome config
27
+ * that names a browser. A composition with none of the three can author a draft
28
+ * that no gate ever accepts. The spawn refuses that session before it writes a
29
+ * row, because a dead-end thread is worse than a refusal that names the absent
30
+ * capability.
31
+ *
32
+ * The spawn seeds the context of the child at the anchor. It writes one message
33
+ * that holds the brief and a copy of the working-memory render. The transcript
34
+ * is append-only, thus the copy stays frozen at the anchor by construction, and
35
+ * a later change to the working memory leaves it as it is. When the seed write
36
+ * fails, the spawn purges the child, because a report thread with no context is
37
+ * a dead end.
38
+ *
39
+ * The seed takes no redaction pass, and that is deliberate. The two parts are
40
+ * agent-authored, the same trust tier as the working-memory render of a
41
+ * conversation turn. The harness redacts the new input of a user one time, at
42
+ * the assembly of that turn. A second pass here would scrub text that the
43
+ * assembly already admitted, and it would report a false clean on the rest.
44
+ *
45
+ * The spawn mints one moment, and that moment carries two pins. The anchor pins
46
+ * the transcript of the parent, and the injected anchor operation pins the data
47
+ * of the analysis. A pin at the first tool call of a later turn reads a
48
+ * different moment, thus the session can cite an artifact that the anchor never
49
+ * held.
50
+ *
51
+ * The delta read gives the state that a caller reads before a new spawn. It
52
+ * gives the report child with the greatest anchor. It also gives the count of
53
+ * user turns of the parent past that anchor. The listing orders by `updated_at`
54
+ * and not by the anchor, thus the read walks each page of the children.
55
+ *
56
+ * The unit of the delta is a turn, and it is not a raw seq. A turn appends after
57
+ * its own loop runs, thus the anchor of a child sits below the rows of the ask
58
+ * that made it. A raw seq comparison would name each child stale one turn after
59
+ * the spawn.
28
60
  */
29
61
  import { randomUUID } from "node:crypto";
30
- import { errAsync } from "neverthrow";
62
+ import { ResultAsync, errAsync, okAsync } from "neverthrow";
31
63
  import { hasBrowserUrl } from "../lib/chrome.js";
32
- import { createThreadHistory } from "../memory/thread-history.js";
64
+ import { createNoopLogger } from "../lib/console-logger.js";
65
+ import { conversationRecordTurn, createThreadHistory } from "../memory/thread-history.js";
33
66
  import { createThreadStore } from "../memory/thread-store.js";
67
+ import { createWorkingMemory } from "../memory/working-memory.js";
34
68
  const _assertDomainError = true;
69
+ /**
70
+ * Whether the composition gives a route to a look at the rendered page. The
71
+ * three routes are the eyes seam, the capture seam, and a chrome config that
72
+ * names a browser. One present route opens the gate.
73
+ *
74
+ * The spawn reads the rule before it writes a row. A caller that runs work
75
+ * before the spawn reads the same rule, thus it skips that work under a closed
76
+ * gate. The refusal itself stays with the spawn, and a caller copies no line of
77
+ * it.
78
+ *
79
+ * The parameter names the route fields alone. Thus a caller passes the deps
80
+ * value that it gives to `createReportSessionSpawn`, and the two answers cannot
81
+ * disagree.
82
+ *
83
+ * No type binds a new route to the parameter. Thus this predicate is the one
84
+ * gate expression of the module, and a new route belongs here and nowhere else.
85
+ */
86
+ export function compositionHasEyes(deps) {
87
+ return deps.eyes !== undefined || deps.capture !== undefined || hasBrowserUrl(deps.chrome);
88
+ }
35
89
  const OP = "spawn-report-session";
36
90
  /** The line that the caller reads when the composition gives no eyes. */
37
91
  const NO_BROWSER_DETAIL = "the composition gives no browser, thus a report session can never record a version";
92
+ /**
93
+ * The page size of the walk over the report children of one parent. The
94
+ * one-version policy keeps the count of children small, thus the walk reads one
95
+ * page on each real parent. It is exported because a test seeds one row more
96
+ * than one page, and a hard-coded count in the test would not follow a change
97
+ * here.
98
+ */
99
+ export const REPORT_CHILD_PAGE_SIZE = 100;
38
100
  /**
39
101
  * Compose the child title. N counts the existing report children of the parent
40
102
  * plus one. A parent with no title yields `Report N` alone, because the seed of
@@ -44,19 +106,144 @@ function composeTitle(parentTitle, n) {
44
106
  const suffix = `Report ${n}`;
45
107
  return parentTitle && parentTitle.length > 0 ? `${parentTitle} — ${suffix}` : suffix;
46
108
  }
109
+ /** Whether the caller gave text for an optional field of the brief. */
110
+ function hasText(field) {
111
+ return field !== undefined && field.trim().length > 0;
112
+ }
113
+ /**
114
+ * Compose the seed message: the brief as short labeled prose, then the copy of
115
+ * the working-memory render. An empty render adds nothing, because
116
+ * `renderWorkingMemory` gives the empty string for a memory with no entry.
117
+ */
118
+ function composeSeed(brief, workingMemoryRender) {
119
+ const lines = [`Objective: ${brief.objective}`, `Audience: ${brief.audience}`, `Angle: ${brief.angle}`];
120
+ if (hasText(brief.exclusions))
121
+ lines.push(`Exclusions: ${brief.exclusions}`);
122
+ if (hasText(brief.openQuestions))
123
+ lines.push(`Open questions: ${brief.openQuestions}`);
124
+ const briefBlock = `# Report Brief\n\n${lines.join("\n")}\n`;
125
+ const memory = workingMemoryRender.trim();
126
+ return memory.length === 0 ? briefBlock : `${briefBlock}\n${memory}\n`;
127
+ }
128
+ /**
129
+ * The child with the greatest anchor, or `null` for no candidate. Two concurrent
130
+ * spawns can write one anchor, and the newest `createdAt` then wins.
131
+ */
132
+ function pickNewestChild(children) {
133
+ let newest = null;
134
+ for (const child of children) {
135
+ // The store writes `parentThreadId` and `parentSeq` as one pair, thus a
136
+ // child with no anchor names no point of the parent transcript and it
137
+ // cannot advise.
138
+ if (child.parentSeq === null)
139
+ continue;
140
+ const candidate = {
141
+ threadId: child.threadId,
142
+ title: child.title,
143
+ anchor: child.parentSeq,
144
+ createdAt: child.createdAt,
145
+ };
146
+ const wins = newest === null ||
147
+ candidate.anchor > newest.anchor ||
148
+ (candidate.anchor === newest.anchor && candidate.createdAt.getTime() > newest.createdAt.getTime());
149
+ if (wins)
150
+ newest = candidate;
151
+ }
152
+ return newest;
153
+ }
47
154
  /**
48
155
  * Build the report-session operations bound to a Postgres pool. The factory
49
- * closure captures `pool` and constructs the store and the thread history from
50
- * it, the same way the chat-turn preparation does.
156
+ * closure captures `pool` and constructs the store, the thread history, and the
157
+ * working memory from it, the same way the chat-turn preparation does.
51
158
  */
52
159
  export function createReportSessionSpawn(deps) {
53
160
  const { pool } = deps;
161
+ const log = (deps.logger ?? createNoopLogger()).named("spawn-report-session");
54
162
  const store = createThreadStore(pool);
55
163
  const history = createThreadHistory(pool);
56
- // The eyes of the composition are fixed at construction, thus the gate reads
57
- // one boolean and never a live probe of the sidecar.
58
- const eyesAvailable = deps.capture !== undefined || hasBrowserUrl(deps.chrome);
59
- function spawnReportSession(parentThreadId) {
164
+ const workingMemory = createWorkingMemory(pool);
165
+ // The routes are fixed at construction, thus the gate reads one boolean and
166
+ // never a live probe of the sidecar.
167
+ const eyesAvailable = compositionHasEyes(deps);
168
+ /**
169
+ * Write the one seed message of the child and give the child back. The
170
+ * message rides as a record, not as user input: nobody typed it in the child
171
+ * thread, and a record is displayed but it never opens a turn.
172
+ */
173
+ function seedChildContext(child, brief) {
174
+ return workingMemory
175
+ .render(child.analysisId)
176
+ .andThen((memory) => history.appendTurn(child.threadId, conversationRecordTurn(composeSeed(brief, memory))))
177
+ .map(() => child)
178
+ .orElse((fault) =>
179
+ // A report thread with no context is a dead end, thus the purge
180
+ // removes the child. The seed fault is the cause, thus it returns
181
+ // from each route. A failed purge leaves the child, and the caller
182
+ // still reads why the seed stopped.
183
+ store
184
+ .purgeThread(child.threadId)
185
+ .andThen(() => errAsync(fault))
186
+ .orElse(() => errAsync(fault)));
187
+ }
188
+ /**
189
+ * Pin the data snapshot of the child, through the injected anchor operation.
190
+ * The spawn mints one moment, and the transcript anchor and this snapshot are
191
+ * the two pins of that moment.
192
+ *
193
+ * The pin runs after the seed. A failed seed purges the child, and
194
+ * `cortex_report_session_state` carries no foreign key to the thread table,
195
+ * thus a pin before a purged seed would leave an orphan row.
196
+ *
197
+ * A failed pin keeps the child. The operation is idempotent, thus the first
198
+ * session tool call pins again, and a purge on a transient store fault would
199
+ * cost the user the whole session. Thus the failure rides the logger alone.
200
+ */
201
+ async function pinSessionSnapshot(child) {
202
+ const anchor = deps.anchorSession;
203
+ if (anchor === undefined)
204
+ return child;
205
+ try {
206
+ const pinned = await anchor(child.threadId);
207
+ if (pinned.outcome === "failed") {
208
+ log.warn("the snapshot pin of the new session failed", {
209
+ threadId: child.threadId,
210
+ analysisId: child.analysisId,
211
+ kind: pinned.kind,
212
+ detail: pinned.detail,
213
+ });
214
+ }
215
+ }
216
+ catch (cause) {
217
+ // The dep speaks a promise, thus a foreign realization can reject.
218
+ // The spawn keeps the child on that route as well.
219
+ log.warn("the snapshot pin of the new session did not complete", {
220
+ threadId: child.threadId,
221
+ analysisId: child.analysisId,
222
+ ...log.errorFields(cause),
223
+ });
224
+ }
225
+ return child;
226
+ }
227
+ /**
228
+ * Each live report child of the parent, across every page of the listing. The
229
+ * listing orders by `updated_at`, thus one page can hide the child with the
230
+ * greatest anchor. The default listing excludes an archived child, and this
231
+ * walk keeps that scope.
232
+ */
233
+ function collectReportChildren(analysisId, parentThreadId, page, found) {
234
+ return store
235
+ .listThreads({ analysisId, type: "report", parentThreadId, page, perPage: REPORT_CHILD_PAGE_SIZE })
236
+ .andThen((result) => {
237
+ const all = [...found, ...result.threads];
238
+ // An empty page also stops the walk. `hasMore` compares the offset
239
+ // against a count from a second statement, thus a concurrent purge
240
+ // can hold the flag true after the last row.
241
+ if (!result.hasMore || result.threads.length === 0)
242
+ return okAsync(all);
243
+ return collectReportChildren(analysisId, parentThreadId, page + 1, all);
244
+ });
245
+ }
246
+ function spawnReportSession(parentThreadId, brief) {
60
247
  // The gate runs before the parent read. A session with no route to a look
61
248
  // reaches the record gate and refuses there forever, thus the spawn is the
62
249
  // one honest place to say so.
@@ -84,19 +271,44 @@ export function createReportSessionSpawn(deps) {
84
271
  // The result is two titles a user renames, and no identifier collides.
85
272
  return store
86
273
  .listThreads({ analysisId: parent.analysisId, type: "report", parentThreadId })
87
- .andThen((children) => store.createThread({
274
+ .andThen((children) => store
275
+ .createThread({
88
276
  threadId: randomUUID(),
89
277
  analysisId: parent.analysisId,
90
278
  title: composeTitle(parent.title, children.total + 1),
91
279
  type: "report",
92
280
  parentThreadId,
93
281
  parentSeq: anchor,
94
- }));
282
+ })
283
+ // The seed follows the insert, thus the copy of the working
284
+ // memory is the state at the anchor.
285
+ .andThen((child) => seedChildContext(child, brief))
286
+ // The pin follows the seed. `pinSessionSnapshot` never
287
+ // rejects, thus the safe bridge adds no error variant.
288
+ .andThen((child) => ResultAsync.fromSafePromise(pinSessionSnapshot(child))));
95
289
  });
96
290
  });
97
291
  }
98
292
  function listReportSessions(analysisId, paging = {}) {
99
293
  return store.listThreads({ analysisId, type: "report", ...paging });
100
294
  }
101
- return { spawnReportSession, listReportSessions };
295
+ function reportSessionDelta(parentThreadId) {
296
+ // The children listing takes the analysis of the parent, thus the parent
297
+ // read comes first. An absent or archived parent gives no advice, and the
298
+ // spawn refuses it later with `parent_not_found`.
299
+ return store.getThread(parentThreadId).andThen((parent) => {
300
+ if (parent === null)
301
+ return okAsync({ newestChild: null, userTurnsSinceAnchor: null });
302
+ // The child comes first, because the anchor of that child bounds the
303
+ // count. A parent with no child gives no anchor, thus the read stops
304
+ // here and the count query never runs.
305
+ return collectReportChildren(parent.analysisId, parentThreadId, 0, []).andThen((children) => {
306
+ const newestChild = pickNewestChild(children);
307
+ if (newestChild === null)
308
+ return okAsync({ newestChild: null, userTurnsSinceAnchor: null });
309
+ return history.countUserTurnsAfter(parentThreadId, newestChild.anchor).map((userTurnsSinceAnchor) => ({ newestChild, userTurnsSinceAnchor }));
310
+ });
311
+ });
312
+ }
313
+ return { spawnReportSession, listReportSessions, reportSessionDelta };
102
314
  }
@@ -55,7 +55,7 @@ export type ToolOutcome = "ok" | "error" | "denied";
55
55
  export type ToolCallOutcome = ToolOutcome | "incomplete";
56
56
  /**
57
57
  * One line naming what a tool call is doing, produced by the tool's own
58
- * `describeCall` hook and normalized at the emit site.
58
+ * `describeCall` or `describeResult` hook and normalized at the emit site.
59
59
  *
60
60
  * Opaque display text. A consumer renders it and derives nothing from it — no
61
61
  * splitting, no keying on separators. The contract is harness-owned so it can
@@ -82,7 +82,12 @@ export interface ToolFinishedEvent {
82
82
  name: string;
83
83
  /** How the call ended. See {@link ToolOutcome}. */
84
84
  outcome: ToolOutcome;
85
- /** The same detail the matching `tool-started` carried. */
85
+ /**
86
+ * See {@link ToolCallDetail}. It can differ from the detail the matching
87
+ * `tool-started` carried: a tool that describes its own result names the
88
+ * outcome here, and a call that produced none carries the started detail
89
+ * again.
90
+ */
86
91
  detail?: ToolCallDetail;
87
92
  /**
88
93
  * The time in milliseconds around this call's own dispatch. Absent — never
@@ -64,4 +64,14 @@ export interface CortexMessage {
64
64
  * nothing" stay distinguishable.
65
65
  */
66
66
  usage?: TokenUsageRollup;
67
+ /**
68
+ * How long the TURN this message completed took, in milliseconds, as the live
69
+ * header measured it. It rides beside {@link usage} and it carries the same
70
+ * rule: only the assistant message that ended a turn holds one.
71
+ *
72
+ * Absent means that nobody measured the turn — a message stored before the
73
+ * duration was persisted, or any message that did not end a turn. A measured
74
+ * zero is a figure, thus it stays a value and it never reads as an absence.
75
+ */
76
+ durationMs?: number;
67
77
  }