@sigrun/sigrun 0.1.6 → 0.2.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 (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +352 -36
  3. package/dist/blocks/schema.js +9 -2
  4. package/dist/blocks/validate.js +57 -8
  5. package/dist/cli.js +26 -2
  6. package/dist/config.js +67 -1
  7. package/dist/extension.d.ts +189 -15
  8. package/dist/harness/ambient.js +39 -185
  9. package/dist/harness/blocks/entries.js +47 -0
  10. package/dist/harness/blocks/state.js +58 -0
  11. package/dist/harness/blocks/tools.js +274 -0
  12. package/dist/harness/boot.js +253 -20
  13. package/dist/harness/builtins.js +15 -11
  14. package/dist/harness/event-bus.js +105 -10
  15. package/dist/harness/identity/accounts.js +186 -0
  16. package/dist/harness/identity/groups.js +14 -0
  17. package/dist/harness/identity/index.js +13 -6
  18. package/dist/harness/identity/json-store.js +15 -4
  19. package/dist/harness/identity/setup-code-check.js +9 -0
  20. package/dist/harness/identity/tokens.js +28 -5
  21. package/dist/harness/identity/users.js +111 -12
  22. package/dist/harness/interactions.js +292 -136
  23. package/dist/harness/loader.js +271 -17
  24. package/dist/harness/packages.js +1 -1
  25. package/dist/harness/reserved-sections.js +21 -0
  26. package/dist/harness/seam-host.js +80 -6
  27. package/dist/harness/settings.js +63 -2
  28. package/dist/harness/stores.js +241 -4
  29. package/dist/harness/user-services.js +391 -0
  30. package/dist/harness/user-tools.js +187 -0
  31. package/dist/harness/workspace.js +61 -57
  32. package/dist/jobs/fire.js +10 -16
  33. package/dist/sandbox/availability.js +162 -0
  34. package/dist/sandbox/command-observer.js +59 -0
  35. package/dist/sandbox/grants.js +159 -0
  36. package/dist/sandbox/helper.js +29 -0
  37. package/dist/sandbox/isolation.js +29 -0
  38. package/dist/sandbox/launch.js +20 -0
  39. package/dist/sandbox/os-account.js +49 -0
  40. package/dist/sandbox/policy.js +253 -0
  41. package/dist/sandbox/pool.js +548 -0
  42. package/dist/sandbox/protocol.js +167 -0
  43. package/dist/sandbox/sandboxed-env.js +245 -0
  44. package/dist/sandbox/unavailable-env.js +49 -0
  45. package/dist/sandbox/worker.js +281 -0
  46. package/dist/serve.js +82 -7
  47. package/dist/server/auth/access-principal.js +16 -0
  48. package/dist/server/auth/pipeline.js +47 -33
  49. package/dist/server/blocks/answers.js +94 -0
  50. package/dist/server/blocks/chat-surfaces.js +393 -0
  51. package/dist/server/blocks/notes.js +216 -0
  52. package/dist/server/blocks/summaries.js +328 -0
  53. package/dist/server/commands.js +5 -0
  54. package/dist/server/content-parts-lazy.js +13 -0
  55. package/dist/server/conversation-handler.js +2 -5
  56. package/dist/server/conversation-ops.js +138 -37
  57. package/dist/server/helpers.js +18 -6
  58. package/dist/server/host-bridge.js +40 -20
  59. package/dist/server/images/cache.js +205 -0
  60. package/dist/server/images/convert.js +13 -0
  61. package/dist/server/images/fetch.js +302 -0
  62. package/dist/server/images/ranges.js +42 -0
  63. package/dist/server/images/rewrite.js +94 -0
  64. package/dist/server/images/sign.js +84 -0
  65. package/dist/server/images/sniff.js +62 -0
  66. package/dist/server/index.js +144 -4
  67. package/dist/server/login/flows.js +126 -0
  68. package/dist/server/login/http.js +31 -0
  69. package/dist/server/login/resolve.js +133 -0
  70. package/dist/server/login/setup-claim.js +55 -0
  71. package/dist/server/login/usernames.js +15 -0
  72. package/dist/server/ownership.js +13 -0
  73. package/dist/server/routes/admin.js +58 -0
  74. package/dist/server/routes/auth.js +88 -2
  75. package/dist/server/routes/conversations.js +3 -6
  76. package/dist/server/routes/events.js +28 -3
  77. package/dist/server/routes/extensions.js +37 -5
  78. package/dist/server/routes/images.js +267 -0
  79. package/dist/server/routes/interactions.js +57 -68
  80. package/dist/server/routes/jobs.js +2 -8
  81. package/dist/server/routes/login.js +223 -0
  82. package/dist/server/routes/me.js +90 -0
  83. package/dist/server/routes/meta.js +15 -0
  84. package/dist/server/routes/onboarding.js +94 -84
  85. package/dist/server/routes/settings-sections.js +190 -0
  86. package/dist/server/routes/settings.js +16 -4
  87. package/dist/server/routes/submissions.js +1 -1
  88. package/dist/server/routes/surfaces.js +175 -23
  89. package/dist/server/routes/users.js +116 -20
  90. package/dist/server/schema-fixture.js +213 -0
  91. package/dist/server/session-meta.js +3 -0
  92. package/dist/server/shutdown.js +11 -4
  93. package/dist/server/static.js +18 -0
  94. package/dist/server/surfaces/dedup.js +9 -2
  95. package/dist/server/surfaces/service.js +181 -13
  96. package/dist/server/surfaces/settings-surfaces.js +597 -0
  97. package/dist/server/turns/submit-turn.js +26 -0
  98. package/dist/server/views.js +16 -0
  99. package/dist/settings/core-sections.js +532 -0
  100. package/dist/settings/pending.js +181 -0
  101. package/dist/settings/registry.js +453 -0
  102. package/dist/skills/index.js +6 -0
  103. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
  104. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  105. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  106. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  107. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  108. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  109. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  110. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  111. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  112. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  113. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  114. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  115. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  116. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  117. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  118. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  119. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  120. package/node_modules/@sigrun/shared/package.json +1 -1
  121. package/package.json +11 -6
  122. package/webui/dist/assets/index-CxB969qp.js +55 -0
  123. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  124. package/webui/dist/index.html +2 -2
  125. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  126. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The ambient seam (spec: docs/approval-ux-seams.md, "Ambient surfaces"):
3
- * core-owned, channel-neutral records for persistent read-mostly state —
4
- * statusline slots and panel widgets — written by extensions through the
5
- * host context and rendered by channels generically.
2
+ * The ambient seam (spec: docs/approval-ux-seams.md, "Ambient surfaces";
3
+ * the block mapping: UI-blocks design §9.2): core-owned,
4
+ * channel-neutral records for persistent read-mostly state — statusline
5
+ * slots and panel widgets — written by extensions through the host
6
+ * context and rendered by channels generically.
6
7
  *
7
8
  * SPLIT (build-and-webui Task 1): the canonical TYPE declarations live in
8
9
  * the `@sigrun/shared` package (`shared/src/ambient.ts` — the records ride
@@ -16,10 +17,22 @@
16
17
  * not silent composition — the pi-zentui chrome-skin lesson).
17
18
  *
18
19
  * Layering mirrors the interaction seam: strict envelope (key, place,
19
- * budget, refresh, expires) around a body composed only from the closed
20
- * ambient primitives (line, rows, gauge, ledger), with opaque `details`.
20
+ * budget, refresh, expires) around a block-tree body (placement
21
+ * `ambient`; `status`/`footer` allow one line), with opaque `details`.
22
+ * Legacy-shaped bodies FAIL with paths (ruling 16).
21
23
  */
22
24
  import { defineDoc, defineDocFamily } from "@earendil-works/pi-durable";
25
+ import { validateTree } from "../blocks/validate.js";
26
+ /**
27
+ * Narrow a stored record to the public wire record. SAFETY: the two
28
+ * shapes are identical at runtime — a stored body passed `validateTree`
29
+ * (placement `ambient`) at write time, and a validated tree IS the JSON
30
+ * the mirror describes; the cast only restores the type-level naming
31
+ * `JsonObject`-erasure removed.
32
+ */
33
+ export function asAmbientRecord(value) {
34
+ return value;
35
+ }
23
36
  /**
24
37
  * The ambient record family: one record per surface key, per conversation.
25
38
  * (Session-scoped statuslines that outlive one conversation are a v2
@@ -48,9 +61,12 @@ export const AmbientOwnersDoc = defineDoc({
48
61
  });
49
62
  const PLACES = new Set(["status", "footer", "panel"]);
50
63
  const OVERFLOWS = new Set(["drop", "wrap", "summarize"]);
51
- const FORMATS = new Set(["compact", "usd", "percent"]);
52
- const ZONES = new Set(["left", "middle", "right"]);
53
- /** Structural validation of one ambient record. Returns the error text or null. */
64
+ /**
65
+ * Structural validation of one ambient record: the envelope's own checks,
66
+ * then the body tree through `validateTree({placement: "ambient"})` with
67
+ * the one-line rule for `status`/`footer` (§9.2). Returns the error text
68
+ * (with the tree paths) or null.
69
+ */
54
70
  export function validateAmbient(value) {
55
71
  if (typeof value !== "object" || value === null) {
56
72
  return "ambient record must be an object";
@@ -80,182 +96,20 @@ export function validateAmbient(value) {
80
96
  const body = r.body;
81
97
  if (body === undefined)
82
98
  return null; // an empty write clears the surface
83
- if (typeof body !== "object" || body === null) {
84
- return "body must be an object";
85
- }
86
- const b = body;
87
- const known = new Set(["line", "rows", "gauge", "ledger"]);
88
- const unknownKey = Object.keys(b).find((k) => !known.has(k));
89
- if (unknownKey !== undefined) {
90
- return `body.${unknownKey} is outside the closed primitives (move it to details)`;
91
- }
92
- if (b.line !== undefined) {
93
- const problem = validateLine(b.line);
94
- if (problem !== null)
95
- return problem;
96
- }
97
- if (b.rows !== undefined) {
98
- if (!Array.isArray(b.rows))
99
- return "body.rows must be an array";
100
- for (const row of b.rows) {
101
- const problem = validateRow(row);
102
- if (problem !== null)
103
- return problem;
104
- }
105
- }
106
- if (b.gauge !== undefined) {
107
- const problem = validateGauge(b.gauge);
108
- if (problem !== null)
109
- return problem;
110
- }
111
- if (b.ledger !== undefined) {
112
- if (!Array.isArray(b.ledger))
113
- return "body.ledger must be an array";
114
- for (const e of b.ledger) {
115
- const problem = validateLedgerEntry(e);
116
- if (problem !== null)
117
- return problem;
118
- }
119
- }
120
- return null;
121
- }
122
- function validateLine(value) {
123
- if (typeof value !== "object" || value === null)
124
- return "line must be an object";
125
- const l = value;
126
- if (l.text === undefined && l.segments === undefined) {
127
- return "line needs text or segments";
128
- }
129
- if (l.text !== undefined && typeof l.text !== "string") {
130
- return "line.text must be a string";
131
- }
132
- if (l.segments !== undefined) {
133
- if (!Array.isArray(l.segments))
134
- return "line.segments must be an array";
135
- for (const s of l.segments) {
136
- if (typeof s !== "object" || s === null)
137
- return "segment must be an object";
138
- const seg = s;
139
- if (typeof seg.id !== "string" || seg.id.length === 0) {
140
- return "segment.id must be a non-empty string";
141
- }
142
- if (typeof seg.text !== "string")
143
- return "segment.text must be a string";
144
- if (seg.place !== undefined && !ZONES.has(String(seg.place))) {
145
- return `segment.place must be one of left|middle|right (got ${String(seg.place)})`;
146
- }
147
- }
148
- }
149
- return null;
150
- }
151
- function validateRow(value) {
152
- if (typeof value !== "object" || value === null)
153
- return "row must be an object";
154
- const r = value;
155
- if (typeof r.id !== "string" || r.id.length === 0) {
156
- return "row.id must be a non-empty string";
157
- }
158
- if (typeof r.label !== "string")
159
- return "row.label must be a string";
160
- if (r.status !== undefined && typeof r.status !== "string") {
161
- return "row.status must be a string";
162
- }
163
- if (r.severity !== undefined &&
164
- r.severity !== 0 &&
165
- r.severity !== 1 &&
166
- r.severity !== 2) {
167
- return "row.severity must be 0, 1, or 2";
168
- }
169
- if (r.quiet !== undefined && typeof r.quiet !== "boolean") {
170
- return "row.quiet must be a boolean";
171
- }
172
- if (r.detail !== undefined && typeof r.detail !== "string") {
173
- return "row.detail must be a string";
174
- }
175
- if (r.depth !== undefined && typeof r.depth !== "number") {
176
- return "row.depth must be a number";
177
- }
178
- if (r.deps !== undefined && !Array.isArray(r.deps)) {
179
- return "row.deps must be an array of ids";
180
- }
181
- if (Array.isArray(r.deps) && r.deps.some((d) => typeof d !== "string")) {
182
- return "row.deps must be an array of ids";
183
- }
184
- if (r.spinnerLabel !== undefined && typeof r.spinnerLabel !== "string") {
185
- return "row.spinnerLabel must be a string";
186
- }
187
- if (r.metrics !== undefined) {
188
- const problem = validateMetrics(r.metrics);
189
- if (problem !== null)
190
- return problem;
191
- }
192
- return null;
193
- }
194
- function validateMetrics(value) {
195
- if (typeof value !== "object" || value === null)
196
- return "metrics must be an object";
197
- const m = value;
198
- for (const k of ["tokens", "cost", "current", "total", "completed"]) {
199
- if (m[k] !== undefined && typeof m[k] !== "number") {
200
- return `metrics.${k} must be a number`;
201
- }
202
- }
203
- if (m.format !== undefined && !FORMATS.has(String(m.format))) {
204
- return `metrics.format must be one of compact|usd|percent (got ${String(m.format)})`;
205
- }
206
- return null;
207
- }
208
- function validateGauge(value) {
209
- if (typeof value !== "object" || value === null)
210
- return "gauge must be an object";
211
- const g = value;
212
- if (typeof g.label !== "string")
213
- return "gauge.label must be a string";
214
- if (g.percent !== undefined && typeof g.percent !== "number") {
215
- return "gauge.percent must be a number";
216
- }
217
- if (g.phase !== undefined && typeof g.phase !== "string") {
218
- return "gauge.phase must be a string";
219
- }
220
- if (g.elapsedMs !== undefined && typeof g.elapsedMs !== "number") {
221
- return "gauge.elapsedMs must be a number";
222
- }
223
- if (g.recentOutput !== undefined && !Array.isArray(g.recentOutput)) {
224
- return "gauge.recentOutput must be an array of strings";
225
- }
226
- if (Array.isArray(g.recentOutput) && g.recentOutput.some((l) => typeof l !== "string")) {
227
- return "gauge.recentOutput must be an array of strings";
228
- }
229
- if (g.checks !== undefined && !Array.isArray(g.checks)) {
230
- return "gauge.checks must be row-shaped";
231
- }
232
- if (Array.isArray(g.checks)) {
233
- for (const row of g.checks) {
234
- const problem = validateRow(row);
235
- if (problem !== null)
236
- return `gauge.checks: ${problem}`;
237
- }
238
- }
239
- return null;
240
- }
241
- function validateLedgerEntry(value) {
242
- if (typeof value !== "object" || value === null)
243
- return "ledger entry must be an object";
244
- const e = value;
245
- if (typeof e.id !== "string" || e.id.length === 0) {
246
- return "ledger.id must be a non-empty string";
247
- }
248
- if (typeof e.at !== "number")
249
- return "ledger.at must be a number";
250
- if (typeof e.kind !== "string")
251
- return "ledger.kind must be a string";
252
- if (typeof e.summary !== "string")
253
- return "ledger.summary must be a string";
254
- if (e.detail !== undefined && !Array.isArray(e.detail)) {
255
- return "ledger.detail must be an array of strings";
256
- }
257
- if (Array.isArray(e.detail) && e.detail.some((d) => typeof d !== "string")) {
258
- return "ledger.detail must be an array of strings";
99
+ // Ruling 16 (no compatibility layer): legacy-shaped bodies (objects
100
+ // keyed line/rows/gauge/ledger rather than {v, root}) fail HERE, with
101
+ // the path — a tree is {v, root}.
102
+ const result = validateTree(body, {
103
+ placement: "ambient",
104
+ ambientPlace: r.place,
105
+ });
106
+ if (!result.ok) {
107
+ const first = result.errors[0];
108
+ if (first === undefined)
109
+ return "body must be a block tree";
110
+ // The tree-relative path joins the record's field ("body.root…")
111
+ // so the error names where the record is wrong, not just the tree.
112
+ return `body is not a valid ambient tree: body.${first.path} ${first.message}`;
259
113
  }
260
114
  return null;
261
115
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The chat-blocks entry kinds (UI-blocks U5 Task 1, spec §8.2 storage):
3
+ * typed pi-durable entries the transcript carries.
4
+ *
5
+ * - `sigrun.blocks` (`BlocksEntry`): one agent-authored block surface,
6
+ * `{title, tree, data}` EXACTLY as `show_blocks` created it. It never
7
+ * changes — the live state lives in the `sigrun.blocks.state` family
8
+ * (state.ts); the entry is the immutable definition the channels
9
+ * re-render from.
10
+ * - `sigrun.blocks.note` (`BlocksNoteEntry`): the §8.3 change note — a
11
+ * `type: "write"` submission whose entry carries the note as a `model`
12
+ * user message. Declared here so the kind token exists in one place;
13
+ * the note WRITER is Task 3 (src/server/blocks/notes.ts).
14
+ * - `sigrun.blocks.answer` (`BlocksAnswerEntry`, Task 3): a block answer
15
+ * as an input entry kind. Declared alongside for the same
16
+ * one-place-ness; Task 3 owns its writer.
17
+ *
18
+ * Payload typing: `Tree`'s block interfaces carry no implicit index
19
+ * signatures, so the payload cannot satisfy pi-durable's
20
+ * `D extends JsonValue` on its own — `tree` is typed `Tree & JsonValue`
21
+ * (verified: a `Tree` literal satisfies the intersection), and the
22
+ * runtime object pi-durable stores is exactly the validated tree.
23
+ */
24
+ import { defineEntry } from "@earendil-works/pi-durable";
25
+ /** One agent-authored block surface in chat (spec §8.2): never changes. */
26
+ export const BlocksEntry = defineEntry("sigrun.blocks");
27
+ /**
28
+ * The §8.3 change note entry: `model` is `[UserMessage]` carrying the
29
+ * note text (Task 3 writes it before every turn admission). `data`
30
+ * carries the note's COVERAGE ({@link BlocksNoteData}): the durable
31
+ * artifact of which changes the admitted note names, so a deduped
32
+ * retry (pi-durable returns the existing submission, discarding the
33
+ * retried text) advances `notedThrough` only as far as the ADMITTED
34
+ * note covered — never past changes a later compute added but the
35
+ * admitted text never described.
36
+ *
37
+ * NOTE: `advance`'s element array is mutable-typed because pi-durable's
38
+ * `D extends JsonValue` constraint rejects `readonly` arrays; the
39
+ * stored value is still treated as immutable by the writer.
40
+ */
41
+ export const BlocksNoteEntry = defineEntry("sigrun.blocks.note");
42
+ /**
43
+ * A block answer as a user-turn entry (Task 3): `model` is `[UserMessage]`
44
+ * with the server-built answer projection; no payload — the answer's
45
+ * `{entryId, action, payload, state}` ride the submission record.
46
+ */
47
+ export const BlocksAnswerEntry = defineEntry("sigrun.blocks.answer");
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The chat-block state family (UI-blocks U5 Task 1, spec §8.2): the live
3
+ * state of one `sigrun.blocks` entry, a conversation-scoped document
4
+ * family keyed by entry id.
5
+ *
6
+ * Shape `{rev, data, changes, notedThrough}`:
7
+ * - `rev` — the monotonic data revision (both agent `update_blocks` and
8
+ * user edits advance it; every advance publishes `surface.changed`);
9
+ * - `data` — the CURRENT data (the entry's `data` at `show_blocks` time,
10
+ * then whatever ops applied since);
11
+ * - `changes` — the user's `ChangeRecord`s (agent updates record NONE:
12
+ * the agent made the change and already knows);
13
+ * - `notedThrough` — the highest `seq` already sent to the agent in a
14
+ * §8.3 note. Records at or below it are trimmed once more than
15
+ * {@link MAX_CHANGES} have accumulated (the trim runs on append).
16
+ *
17
+ * Fork semantics (ruling 19): `history: "latest", fork: "current"` —
18
+ * pi-durable copies the current document at fork creation, so the child
19
+ * gets the block's current `data`, `rev`, records and `notedThrough`,
20
+ * then diverges. Blocks created after the fork point copy too, but are
21
+ * never addressable in the child (their entry is not in its history).
22
+ */
23
+ import { defineDocFamily } from "@earendil-works/pi-durable";
24
+ /** The trim threshold (spec §8.2): trim at-or-below `notedThrough` once more than 50 accumulate. */
25
+ export const MAX_CHANGES = 50;
26
+ /** The `sigrun.blocks.state` family: one member per (conversation, entry id). */
27
+ export const BlocksStateDoc = defineDocFamily({
28
+ kind: "sigrun.blocks.state",
29
+ version: 1,
30
+ scope: "conversation",
31
+ history: "latest",
32
+ fork: "current",
33
+ family: true,
34
+ // The seed only shapes a first touch; every real member is created by
35
+ // show_blocks, which writes the full state in its append commit.
36
+ initial: () => ({ rev: 0, data: {}, changes: [], notedThrough: 0 }),
37
+ });
38
+ /**
39
+ * Read one block's state (committed). `undefined` when the conversation
40
+ * has no such member — the entry id is unknown here, or the block was
41
+ * never created in this conversation.
42
+ */
43
+ export async function readBlocksState(harness, conversationId, entryId, ctx) {
44
+ return await harness.snapshot(BlocksStateDoc, conversationId, entryId, ctx);
45
+ }
46
+ /**
47
+ * Trim change records at or below `notedThrough` once more than
48
+ * {@link MAX_CHANGES} have accumulated (spec §8.2) — in the caller's
49
+ * commit, on the draft the append already holds.
50
+ */
51
+ export function trimChanges(state) {
52
+ if (state.changes.length <= MAX_CHANGES)
53
+ return;
54
+ const kept = state.changes.filter((record) => record.seq > state.notedThrough);
55
+ // Keep the newest MAX_CHANGES of what remains (the trim is bounded, not
56
+ // a wholesale wipe): a surge of edits above notedThrough keeps its tail.
57
+ state.changes = kept.slice(Math.max(0, kept.length - MAX_CHANGES));
58
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The chat-blocks core tools (UI-blocks U5 Task 1, spec §8.1): the
3
+ * `sigrun.blocks` pi-durable extension installed by `openHarness` beside
4
+ * the coding/search tools, so EVERY agent has them.
5
+ *
6
+ * - `show_blocks {title, tree, data}` validates the tree against the
7
+ * `chat` placement (§5.7: no `page` root, no `secret` fields; §4.5
8
+ * limits 200 blocks / 256 KiB tree / 256 KiB data; handler names are
9
+ * free labels in chat — no `handlers` set). Validation errors go back
10
+ * to the model as the tool error, each naming its JSON path (§14.2).
11
+ * On success it appends ONE immutable `sigrun.blocks` entry and seeds
12
+ * the state family `{rev: 0, data, changes: [], notedThrough: 0}` in
13
+ * the SAME commit; the tool result is the §14.3 text projection of the
14
+ * tree and its initial data plus the `entryId`.
15
+ * - `update_blocks {entryId, ops}` applies `DataOp`s to the surface's
16
+ * CURRENT data (spec §8.1's stale-index honesty: an op written against
17
+ * old indexes applies to current data — the returned projection shows
18
+ * the ACTUAL resulting order). It records NO change record (the agent
19
+ * made the change), increments `rev`, publishes
20
+ * `surface.changed {conversationId, entryId, rev}` through the publish
21
+ * seam, and its result is the NEW projection. A `DataOpError` (a
22
+ * missing path, a non-array where one is required) returns a tool
23
+ * error naming the op index; a failed batch leaves no partial write
24
+ * (the throw unwinds the commit before anything persisted). An entry id
25
+ * from another conversation (or one this conversation cannot see) is
26
+ * "no such block in this conversation".
27
+ *
28
+ * The publish seam (`subscribeChatSurfaceChanged`): the chat
29
+ * `surface.changed` wire shape joins `HostEventWire` in Task 2 (shared/
30
+ * views.ts), and the server binds the hub publish then. Until that
31
+ * bind, the seam carries the events to in-process listeners only — the
32
+ * default subscriber set is empty, so nothing is lost by not wiring it.
33
+ */
34
+ import { Type } from "@earendil-works/pi-ai";
35
+ import { defineExtension, defineTool, } from "@earendil-works/pi-durable";
36
+ import { applyOps, DataOpError, toText } from "@sigrun/shared/blocks";
37
+ import { validateData, validateTree } from "../../blocks/validate.js";
38
+ import { BlocksEntry } from "./entries.js";
39
+ import { BlocksStateDoc } from "./state.js";
40
+ /** The §14.3 projection width for what the model sees of its own blocks (spec §8.1). */
41
+ export const PROJECTION_WIDTH = 100;
42
+ /**
43
+ * The server locale for model-facing projections (spec §14.3: "the model
44
+ * projection always uses the server's locale"). No locale setting exists
45
+ * in config today; `en-CA` is the repo's projection convention (the
46
+ * jobs run-namer and the block-text snapshots both use it).
47
+ */
48
+ export const PROJECTION_LOCALE = "en-CA";
49
+ /**
50
+ * The tool-description sentence (spec §8.1, verbatim): it teaches the
51
+ * action kinds — silent saves, immediate submits, `turn: true` only when
52
+ * a reply is needed.
53
+ */
54
+ const ACTION_SENTENCE = "Interactive elements save the user's edits silently; you'll receive a summary of edits at your next turn. A `submit` sends you the result immediately; set `turn: true` on a `move` or `edit` only if each one needs an immediate reply.";
55
+ /** The publish seam's listener set (see `chatSurfaceChanged`). */
56
+ const chatSurfaceChangedListeners = new Set();
57
+ /**
58
+ * Publish one chat-surface change through the seam (spec §8.2's
59
+ * `surface.changed {conversation, entry, rev}`). The TOOL layer calls
60
+ * this after its state commit; the server (Task 2) subscribes and fans
61
+ * the event out to the watcher registry + hub. In-process subscribers
62
+ * see every event; a listener that throws is logged, never propagated
63
+ * into the tool.
64
+ */
65
+ export function chatSurfaceChanged(event) {
66
+ for (const listener of chatSurfaceChangedListeners) {
67
+ try {
68
+ listener(event);
69
+ }
70
+ catch (error) {
71
+ console.error("blocks: surface.changed listener failed:", error);
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Subscribe to chat-surface changes (the test seam and Task 2's server
77
+ * binding point). Returns the unsubscribe.
78
+ */
79
+ export function subscribeChatSurfaceChanged(listener) {
80
+ chatSurfaceChangedListeners.add(listener);
81
+ return () => {
82
+ chatSurfaceChangedListeners.delete(listener);
83
+ };
84
+ }
85
+ /** A tool error: text content the model can read and recover from. */
86
+ function toolError(message) {
87
+ return { output: [{ type: "text", text: message }], isError: true };
88
+ }
89
+ /** A plain-object check (the `data` arg arrives as `unknown`). */
90
+ function isJsonObject(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ }
93
+ /** The show_blocks tool (spec §8.1). */
94
+ export const showBlocksTool = defineTool({
95
+ name: "show_blocks",
96
+ description: "Show the user an interactive UI (tables, charts, forms, lists) in the conversation. " +
97
+ "The result is the plain-text projection of the surface plus its entryId; keep that entryId to update the surface later. " +
98
+ ACTION_SENTENCE,
99
+ parameters: Type.Object({
100
+ title: Type.String({
101
+ description: "The surface's title, shown in the channel's header.",
102
+ }),
103
+ tree: Type.Unknown({
104
+ description: "The block tree {v: 1, root: Block} — the chat subset (no page root, no secret fields; at most 200 blocks).",
105
+ }),
106
+ data: Type.Unknown({
107
+ description: "The initial data object the tree's bindings point into (a JSON object).",
108
+ }),
109
+ }),
110
+ async execute(args, api, context) {
111
+ // Validate first (§8.1): an invalid tree appends NOTHING. Handler
112
+ // names are free labels in chat — no `handlers` set.
113
+ const validation = validateTree(args.tree, { placement: "chat" });
114
+ if (!validation.ok) {
115
+ const lines = validation.errors.map((error) => `${error.path}: ${error.message}`);
116
+ return toolError(`show_blocks: the tree is invalid:\n${lines.join("\n")}`);
117
+ }
118
+ if (!isJsonObject(args.data)) {
119
+ return toolError("show_blocks: data must be a JSON object");
120
+ }
121
+ const data = args.data;
122
+ // The data limit (§4.5, chat row) — validateTree covers the tree;
123
+ // the data object gets its own check.
124
+ const dataCheck = validateData(data, "chat");
125
+ if (!dataCheck.ok) {
126
+ return toolError(`show_blocks: ${dataCheck.errors
127
+ .map((error) => `${error.path}: ${error.message}`)
128
+ .join("\n")}`);
129
+ }
130
+ const tree = validation.tree;
131
+ // One commit: append the immutable entry and seed the state family
132
+ // (spec §8.2) — a crash between the two cannot strand a block
133
+ // without state.
134
+ const entryId = await api.commit(async (tx) => {
135
+ const entry = await tx.appendEntry(BlocksEntry, api.conversationId, {
136
+ data: {
137
+ title: args.title,
138
+ tree: tree,
139
+ data,
140
+ },
141
+ });
142
+ const state = await tx.doc(BlocksStateDoc, api.conversationId, String(entry.id), String(entry.id));
143
+ state.rev = 0;
144
+ state.data = data;
145
+ state.changes = [];
146
+ state.notedThrough = 0;
147
+ return entry.id;
148
+ }, context);
149
+ return {
150
+ output: [
151
+ {
152
+ type: "text",
153
+ text: `${toText(tree, data, {
154
+ width: PROJECTION_WIDTH,
155
+ locale: PROJECTION_LOCALE,
156
+ })}\nentryId: ${String(entryId)}`,
157
+ },
158
+ ],
159
+ };
160
+ },
161
+ });
162
+ /** The update_blocks tool (spec §8.1). */
163
+ export const updateBlocksTool = defineTool({
164
+ name: "update_blocks",
165
+ description: "Apply data ops (set/remove/insert/move) to one of this conversation's block surfaces (by its entryId) and see the surface's CURRENT text projection. " +
166
+ "Ops apply to current data — if the user has reordered things since you last looked, index-based ops land on the new indexes. " +
167
+ ACTION_SENTENCE,
168
+ parameters: Type.Object({
169
+ entryId: Type.String({
170
+ description: "The entryId show_blocks returned.",
171
+ }),
172
+ ops: Type.Array(Type.Unknown(), {
173
+ description: "The DataOps to apply, in order.",
174
+ }),
175
+ }),
176
+ async execute(args, api, context) {
177
+ const parsed = parseEntryId(args.entryId);
178
+ if (parsed === undefined) {
179
+ return toolError(`update_blocks: entryId ${JSON.stringify(args.entryId)} is not one this conversation has`);
180
+ }
181
+ // Structural op validation (the schema is Unknown per element): each
182
+ // element must carry a known op tag; anything else is a recoverable
183
+ // model error naming the element's index.
184
+ const elements = args.ops;
185
+ // SAFETY: the schema admits unknown elements (typebox cannot express
186
+ // the DataOp union without duplicating it); the loop below validates
187
+ // each element's op tag, and applyOps re-validates every path — an
188
+ // element that is not a DataOp shape is a tool error, never a
189
+ // misapplied write.
190
+ const ops = elements;
191
+ for (let index = 0; index < elements.length; index++) {
192
+ const tag = elements[index]?.op;
193
+ if (tag !== "set" &&
194
+ tag !== "remove" &&
195
+ tag !== "insert" &&
196
+ tag !== "move") {
197
+ return toolError(`update_blocks: op ${index} is not a DataOp (expected {op: "set"|"remove"|"insert"|"move", …})`);
198
+ }
199
+ }
200
+ // The write: one commit reads the CURRENT state, applies the ops,
201
+ // bumps rev. The tree comes from the entry record — returned only
202
+ // when visible through THIS conversation (fork-aware), which is the
203
+ // same check that makes a foreign id "no such block".
204
+ let updated;
205
+ try {
206
+ updated = await api.commit(async (tx) => {
207
+ const page = await tx.scanEntries({
208
+ conversationId: api.conversationId,
209
+ minEntryId: parsed,
210
+ maxEntryId: parsed,
211
+ }, 1, undefined);
212
+ const record = page.items[0];
213
+ if (record === undefined || !BlocksEntry.is(record)) {
214
+ return undefined;
215
+ }
216
+ const state = await tx.doc(BlocksStateDoc, api.conversationId, String(parsed), String(parsed));
217
+ // applyOps throws DataOpError (naming the op index) on a bad
218
+ // path; the throw unwinds the commit BEFORE anything persists
219
+ // — a failed batch leaves no partial write.
220
+ state.data = applyOps(state.data, ops);
221
+ state.rev += 1;
222
+ // Snapshot to plain JSON: chord wraps draft values in tracking
223
+ // proxies that settle when the commit ends — returning the draft
224
+ // value itself would throw "Cannot use a settled overlay" when
225
+ // the projection reads it after commit.
226
+ return {
227
+ tree: record.data.tree,
228
+ data: JSON.parse(JSON.stringify(state.data)),
229
+ rev: state.rev,
230
+ };
231
+ }, context);
232
+ }
233
+ catch (error) {
234
+ if (error instanceof DataOpError) {
235
+ return toolError(`update_blocks: op ${error.opIndex} failed: ${error.message}`);
236
+ }
237
+ throw error;
238
+ }
239
+ if (updated === undefined) {
240
+ return toolError(`update_blocks: no such block in this conversation (entryId ${JSON.stringify(args.entryId)})`);
241
+ }
242
+ // The change announcement (§8.2): surface.changed to watchers of
243
+ // this conversation's block.
244
+ chatSurfaceChanged({
245
+ type: "surface.changed",
246
+ conversationId: api.conversationId,
247
+ entryId: args.entryId,
248
+ rev: updated.rev,
249
+ });
250
+ return {
251
+ output: [
252
+ {
253
+ type: "text",
254
+ text: toText(updated.tree, updated.data, {
255
+ width: PROJECTION_WIDTH,
256
+ locale: PROJECTION_LOCALE,
257
+ }),
258
+ },
259
+ ],
260
+ };
261
+ },
262
+ });
263
+ /** A numeric entry id (`EntryId` is a branded number; tool args are strings). */
264
+ export function parseEntryId(value) {
265
+ if (!/^\d+$/.test(value))
266
+ return undefined;
267
+ const id = Number(value);
268
+ return Number.isSafeInteger(id) && id > 0 ? id : undefined;
269
+ }
270
+ /** The core `sigrun.blocks` extension: the two tools, installed for every conversation. */
271
+ export const blocksToolsExtension = defineExtension({
272
+ name: "sigrun.blocks",
273
+ tools: [showBlocksTool, updateBlocksTool],
274
+ });