@sigrun/shared 0.4.1 → 0.5.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 (50) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/dist/access.d.ts +60 -0
  3. package/dist/access.js +87 -0
  4. package/dist/ambient.d.ts +54 -0
  5. package/dist/ambient.js +25 -0
  6. package/dist/blocks/format.d.ts +28 -0
  7. package/dist/blocks/format.js +199 -0
  8. package/dist/blocks/icons.d.ts +12 -0
  9. package/dist/blocks/icons.js +64 -0
  10. package/dist/blocks/index.d.ts +25 -0
  11. package/dist/blocks/index.js +21 -0
  12. package/dist/blocks/instance.d.ts +29 -0
  13. package/dist/blocks/instance.js +51 -0
  14. package/dist/blocks/lttb.d.ts +16 -0
  15. package/dist/blocks/lttb.js +67 -0
  16. package/dist/blocks/ops.d.ts +44 -0
  17. package/dist/blocks/ops.js +294 -0
  18. package/dist/blocks/text.d.ts +43 -0
  19. package/dist/blocks/text.js +804 -0
  20. package/dist/blocks/transport.d.ts +81 -0
  21. package/dist/blocks/transport.js +11 -0
  22. package/dist/blocks/types.d.ts +558 -0
  23. package/dist/blocks/types.js +17 -0
  24. package/dist/commands.d.ts +75 -0
  25. package/dist/commands.js +25 -0
  26. package/dist/conversations.d.ts +31 -0
  27. package/dist/conversations.js +94 -0
  28. package/dist/interactions.d.ts +65 -0
  29. package/dist/interactions.js +23 -0
  30. package/dist/jobs.d.ts +109 -0
  31. package/dist/jobs.js +19 -0
  32. package/dist/json.d.ts +20 -0
  33. package/dist/json.js +13 -0
  34. package/dist/live.d.ts +71 -0
  35. package/dist/live.js +82 -0
  36. package/dist/session-meta.d.ts +130 -0
  37. package/dist/session-meta.js +13 -0
  38. package/dist/settings.d.ts +449 -0
  39. package/dist/settings.js +19 -0
  40. package/dist/themes.d.ts +49 -0
  41. package/dist/themes.js +155 -0
  42. package/dist/turns.d.ts +133 -0
  43. package/dist/turns.js +247 -0
  44. package/dist/usernames.d.ts +8 -0
  45. package/dist/usernames.js +39 -0
  46. package/dist/view-delta.d.ts +54 -0
  47. package/dist/view-delta.js +135 -0
  48. package/dist/views.d.ts +1021 -0
  49. package/dist/views.js +54 -0
  50. package/package.json +3 -2
package/dist/themes.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The shared theme data (Task B1, UI refresh): the four palettes and
3
+ * seven accents of the UI refresh, plus their name guards.
4
+ *
5
+ * PURE module: no imports at all (test/views-purity.test.ts enforces
6
+ * this). Both clients — webui (bundler, DOM) and tui (NodeNext) —
7
+ * consume this file from a single source; any import would drag a
8
+ * dependency only one client can load or resolve.
9
+ *
10
+ * Colour values are copied verbatim from the UI-refresh palette mockup
11
+ * (palettes.html), with the mockup ids renamed to the plan ids: palette `iron` →
12
+ * `iron-ember`, `aurora` → `polar-night`, `birch` → `birch-moss`;
13
+ * accent `amber` → `baltic-amber`; token key `surface-2` → `surface2`.
14
+ */
15
+ export const PALETTES = {
16
+ fjord: {
17
+ label: "Fjord",
18
+ defaultAccent: "baltic-amber",
19
+ dark: {
20
+ bg: "#0b0f13",
21
+ surface: "#11171e",
22
+ surface2: "#18202a",
23
+ border: "#222d39",
24
+ text: "#dde6ee",
25
+ muted: "#8d9cab",
26
+ faint: "#5f6e7d",
27
+ ok: "#7fbf9a",
28
+ code: "rgba(255,255,255,.06)",
29
+ },
30
+ light: {
31
+ bg: "#eef2f5",
32
+ surface: "#ffffff",
33
+ surface2: "#e3e9ef",
34
+ border: "#d3dbe3",
35
+ text: "#18212a",
36
+ muted: "#4d5b69",
37
+ faint: "#8593a1",
38
+ ok: "#2f8a5a",
39
+ code: "rgba(0,0,0,.05)",
40
+ },
41
+ },
42
+ "iron-ember": {
43
+ label: "Iron & Ember",
44
+ defaultAccent: "ember",
45
+ dark: {
46
+ bg: "#0e0e0f",
47
+ surface: "#161618",
48
+ surface2: "#1e1e21",
49
+ border: "#2a2a2e",
50
+ text: "#e6e1da",
51
+ muted: "#9a958e",
52
+ faint: "#66625d",
53
+ ok: "#8fb58a",
54
+ code: "rgba(255,255,255,.06)",
55
+ },
56
+ light: {
57
+ bg: "#f3f1ee",
58
+ surface: "#fbfaf8",
59
+ surface2: "#e9e6e1",
60
+ border: "#dbd7d1",
61
+ text: "#1c1b1a",
62
+ muted: "#55524d",
63
+ faint: "#8e8a84",
64
+ ok: "#3e7a3a",
65
+ code: "rgba(0,0,0,.05)",
66
+ },
67
+ },
68
+ "polar-night": {
69
+ label: "Polar Night",
70
+ defaultAccent: "aurora",
71
+ dark: {
72
+ bg: "#090d16",
73
+ surface: "#0f1524",
74
+ surface2: "#161e31",
75
+ border: "#1f2942",
76
+ text: "#e2e8f3",
77
+ muted: "#8e9ab2",
78
+ faint: "#5d6984",
79
+ ok: "#5fd4a8",
80
+ code: "rgba(255,255,255,.06)",
81
+ },
82
+ light: {
83
+ bg: "#f0f3f9",
84
+ surface: "#ffffff",
85
+ surface2: "#e4e9f3",
86
+ border: "#d2d9e6",
87
+ text: "#141b2b",
88
+ muted: "#4b5670",
89
+ faint: "#8691a8",
90
+ ok: "#13866a",
91
+ code: "rgba(0,0,0,.05)",
92
+ },
93
+ },
94
+ "birch-moss": {
95
+ label: "Birch & Moss",
96
+ defaultAccent: "lichen",
97
+ dark: {
98
+ bg: "#0d100d",
99
+ surface: "#141a15",
100
+ surface2: "#1b221c",
101
+ border: "#263027",
102
+ text: "#e8e6dc",
103
+ muted: "#9ba091",
104
+ faint: "#666d60",
105
+ ok: "#9cc48a",
106
+ code: "rgba(255,255,255,.06)",
107
+ },
108
+ light: {
109
+ bg: "#f3f2ea",
110
+ surface: "#fbfbf6",
111
+ surface2: "#e7e7dc",
112
+ border: "#d8d8ca",
113
+ text: "#1b1e18",
114
+ muted: "#525849",
115
+ faint: "#8b9080",
116
+ ok: "#4a7a35",
117
+ code: "rgba(0,0,0,.05)",
118
+ },
119
+ },
120
+ };
121
+ export const ACCENTS = {
122
+ "baltic-amber": { label: "Baltic Amber", dark: "#d6a24a", light: "#a8741f" },
123
+ ember: { label: "Ember", dark: "#d9773a", light: "#b8571c" },
124
+ lingonberry: { label: "Lingonberry", dark: "#d0606a", light: "#a83440" },
125
+ glacier: { label: "Glacier", dark: "#8fb8d0", light: "#2f6f93" },
126
+ aurora: { label: "Aurora", dark: "#5fd4a8", light: "#13866a" },
127
+ lichen: { label: "Lichen", dark: "#b9c46a", light: "#6f7a1f" },
128
+ heather: { label: "Heather", dark: "#a993c9", light: "#6c4f99" },
129
+ };
130
+ export const PALETTE_NAMES = [
131
+ "fjord",
132
+ "iron-ember",
133
+ "polar-night",
134
+ "birch-moss",
135
+ ];
136
+ export const ACCENT_NAMES = [
137
+ "baltic-amber",
138
+ "ember",
139
+ "lingonberry",
140
+ "glacier",
141
+ "aurora",
142
+ "lichen",
143
+ "heather",
144
+ ];
145
+ /** The shipped default: Polar Night + Baltic Amber. */
146
+ export const DEFAULT_PALETTE = "polar-night";
147
+ export const DEFAULT_ACCENT = "baltic-amber";
148
+ export function isPaletteName(value) {
149
+ return (typeof value === "string" &&
150
+ Object.prototype.hasOwnProperty.call(PALETTES, value));
151
+ }
152
+ export function isAccentName(value) {
153
+ return (typeof value === "string" &&
154
+ Object.prototype.hasOwnProperty.call(ACCENTS, value));
155
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Turn grouping (Task B1, UI refresh): fold a flat pi-durable entry
3
+ * list into the renderable turns both clients draw.
4
+ *
5
+ * PURE module: no imports at all (test/views-purity.test.ts enforces
6
+ * this). The entry/message/block types below are deliberately LOOSE
7
+ * (`role: string`, `content: string | readonly TurnBlock[]`) so both
8
+ * clients' wire types fit structurally — a shared import would defeat
9
+ * the single-source rule.
10
+ *
11
+ * Grouping: consecutive `pi.assistant` and `pi.tool-result` entries
12
+ * form ONE assistant turn; a `pi.user` entry is its own turn; every
13
+ * other kind (compaction records, summaries) is an `other` turn. A
14
+ * user or other entry also ENDS the assistant run.
15
+ *
16
+ * Task 2c: an EXPANDED input (`/skill:name`, a prompt template) is
17
+ * placed as ONE entry whose kind REPLACES `pi.user` —
18
+ * {@link EXPANDED_USER_KIND}, with `data {typed, command}` and the
19
+ * EXPANDED text on `model` (pi-durable's `SubmissionDraft.entry`).
20
+ * The grouping classifies it as a user turn (the renderers show the
21
+ * TYPED text with a chip); the server-side twin of the kind constant
22
+ * is `EXPANDED_ENTRY_KIND` in `src/commands/expand.ts`.
23
+ */
24
+ /** The user-entry kind an expanded `/skill:` or template input is placed
25
+ * as (see the module doc); grouping treats it exactly like `pi.user`. */
26
+ export declare const EXPANDED_USER_KIND = "sigrun.user.expanded";
27
+ /** The chat-blocks entry kinds (UI-blocks U5, spec §8.2/§8.3/§8.4) the
28
+ * grouping recognises. The server-side twins live in
29
+ * `src/harness/blocks/entries.ts` (this module imports nothing, so the
30
+ * tokens duplicate by design — one spelling per kind, pinned by the
31
+ * grouping tests). */
32
+ export declare const BLOCKS_KIND = "sigrun.blocks";
33
+ export declare const BLOCKS_NOTE_KIND = "sigrun.blocks.note";
34
+ export declare const BLOCKS_ANSWER_KIND = "sigrun.blocks.answer";
35
+ export interface TurnBlock {
36
+ readonly type: string;
37
+ readonly text?: string;
38
+ readonly thinking?: string;
39
+ readonly id?: string;
40
+ readonly name?: string;
41
+ readonly arguments?: unknown;
42
+ }
43
+ export interface TurnMessage {
44
+ readonly role: string;
45
+ readonly content: string | readonly TurnBlock[];
46
+ readonly toolCallId?: string;
47
+ readonly toolName?: string;
48
+ readonly isError?: boolean;
49
+ }
50
+ export interface TurnEntry {
51
+ readonly id: number;
52
+ readonly kind: string;
53
+ readonly model?: readonly TurnMessage[];
54
+ /** The entry's kind-specific JSON payload — `sigrun.user.expanded`'s
55
+ * `{typed, command}` (see {@link expandedUserEntry}). */
56
+ readonly data?: unknown;
57
+ }
58
+ /** The narrowed `data` of a {@link EXPANDED_USER_KIND} entry: what the
59
+ * user TYPED (the bubble's text) and the command that expanded. */
60
+ export interface ExpandedUserEntryData {
61
+ readonly typed: string;
62
+ readonly command?: string;
63
+ }
64
+ /** Narrow one entry to its expanded-user `data` — defined exactly when
65
+ * the kind is {@link EXPANDED_USER_KIND} and `typed` is a non-empty
66
+ * string; undefined for every other shape (a plain `pi.user`, missing
67
+ * or malformed data). */
68
+ export declare function expandedUserEntry(entry: {
69
+ readonly kind: string;
70
+ readonly data?: unknown;
71
+ }): ExpandedUserEntryData | undefined;
72
+ export interface ToolCall {
73
+ readonly callId: string;
74
+ readonly name: string;
75
+ readonly preview: string;
76
+ readonly resultEntryId?: number;
77
+ readonly isError: boolean;
78
+ }
79
+ export type Turn<E extends TurnEntry> = {
80
+ readonly kind: "user";
81
+ readonly entry: E;
82
+ } | {
83
+ readonly kind: "assistant";
84
+ readonly entries: readonly E[];
85
+ readonly thinking: readonly string[];
86
+ readonly tools: readonly ToolCall[];
87
+ readonly text: string;
88
+ readonly lastEntryId: number;
89
+ /** The turn's blocks in OCCURRENCE order — the chronological
90
+ * view interleaves thinking / tool calls / text exactly as they
91
+ * happened, instead of folding everything under the final text
92
+ * (the grouped view's shape). Text blocks CONSECUTIVE in the
93
+ * stream merge into one segment (multi-block answers read as one). */
94
+ readonly timeline: readonly TimelineEvent[];
95
+ } | {
96
+ readonly kind: "other";
97
+ readonly entry: E;
98
+ };
99
+ /** One chronological segment of an assistant turn. */
100
+ export type TimelineEvent = {
101
+ readonly kind: "thinking";
102
+ readonly text: string;
103
+ } | {
104
+ readonly kind: "tool";
105
+ readonly tool: ToolCall;
106
+ } | {
107
+ readonly kind: "text";
108
+ readonly text: string;
109
+ } | {
110
+ readonly kind: "blocks";
111
+ readonly entryId: number;
112
+ } | {
113
+ readonly kind: "note";
114
+ readonly entryId: number;
115
+ };
116
+ export declare function groupTurns<E extends TurnEntry>(entries: readonly E[]): Turn<E>[];
117
+ export declare function toolPreview(name: string, args: unknown): string;
118
+ /**
119
+ * The FULL argument a tool's preview shows, uncut — the transcript's
120
+ * copy-command action copies it verbatim: the same name-keyed argument
121
+ * the preview selects, before its first-line/80-char truncation.
122
+ * Undefined when the arguments carry no previewable string.
123
+ */
124
+ export declare function previewArgument(name: string, args: unknown): string | undefined;
125
+ /**
126
+ * The collapsed activity line under an assistant turn: thinking and
127
+ * tool COUNTS only — the entry wire carries no per-block timing, so
128
+ * durations are never shown (plan deviation, Task D records it).
129
+ */
130
+ export declare function activityLabel(turn: {
131
+ thinking: readonly string[];
132
+ tools: readonly ToolCall[];
133
+ }): string;
package/dist/turns.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Turn grouping (Task B1, UI refresh): fold a flat pi-durable entry
3
+ * list into the renderable turns both clients draw.
4
+ *
5
+ * PURE module: no imports at all (test/views-purity.test.ts enforces
6
+ * this). The entry/message/block types below are deliberately LOOSE
7
+ * (`role: string`, `content: string | readonly TurnBlock[]`) so both
8
+ * clients' wire types fit structurally — a shared import would defeat
9
+ * the single-source rule.
10
+ *
11
+ * Grouping: consecutive `pi.assistant` and `pi.tool-result` entries
12
+ * form ONE assistant turn; a `pi.user` entry is its own turn; every
13
+ * other kind (compaction records, summaries) is an `other` turn. A
14
+ * user or other entry also ENDS the assistant run.
15
+ *
16
+ * Task 2c: an EXPANDED input (`/skill:name`, a prompt template) is
17
+ * placed as ONE entry whose kind REPLACES `pi.user` —
18
+ * {@link EXPANDED_USER_KIND}, with `data {typed, command}` and the
19
+ * EXPANDED text on `model` (pi-durable's `SubmissionDraft.entry`).
20
+ * The grouping classifies it as a user turn (the renderers show the
21
+ * TYPED text with a chip); the server-side twin of the kind constant
22
+ * is `EXPANDED_ENTRY_KIND` in `src/commands/expand.ts`.
23
+ */
24
+ /** The user-entry kind an expanded `/skill:` or template input is placed
25
+ * as (see the module doc); grouping treats it exactly like `pi.user`. */
26
+ export const EXPANDED_USER_KIND = "sigrun.user.expanded";
27
+ /** The chat-blocks entry kinds (UI-blocks U5, spec §8.2/§8.3/§8.4) the
28
+ * grouping recognises. The server-side twins live in
29
+ * `src/harness/blocks/entries.ts` (this module imports nothing, so the
30
+ * tokens duplicate by design — one spelling per kind, pinned by the
31
+ * grouping tests). */
32
+ export const BLOCKS_KIND = "sigrun.blocks";
33
+ export const BLOCKS_NOTE_KIND = "sigrun.blocks.note";
34
+ export const BLOCKS_ANSWER_KIND = "sigrun.blocks.answer";
35
+ /** The `show_blocks`/`update_blocks` tool names: a call to one of these
36
+ * never joins the turn's `tools` or the timeline — the block surface it
37
+ * created IS the visible artifact (the activity line would otherwise
38
+ * count its own rendering machinery as activity). */
39
+ const BLOCKS_TOOLS = new Set([
40
+ "show_blocks",
41
+ "update_blocks",
42
+ ]);
43
+ /** Narrow one entry to its expanded-user `data` — defined exactly when
44
+ * the kind is {@link EXPANDED_USER_KIND} and `typed` is a non-empty
45
+ * string; undefined for every other shape (a plain `pi.user`, missing
46
+ * or malformed data). */
47
+ export function expandedUserEntry(entry) {
48
+ if (entry.kind !== EXPANDED_USER_KIND)
49
+ return undefined;
50
+ const data = entry.data;
51
+ if (typeof data !== "object" || data === null)
52
+ return undefined;
53
+ const typed = data.typed;
54
+ if (typeof typed !== "string" || typed === "")
55
+ return undefined;
56
+ const command = data.command;
57
+ return typeof command === "string" && command !== ""
58
+ ? { typed, command }
59
+ : { typed };
60
+ }
61
+ /** The assistant text blocks of one message, in order. */
62
+ function blocks(message) {
63
+ return typeof message.content === "string" ? [] : message.content;
64
+ }
65
+ export function groupTurns(entries) {
66
+ const turns = [];
67
+ let pending = null;
68
+ const flush = () => {
69
+ if (pending === null)
70
+ return;
71
+ // Merge consecutive text segments for the timeline (a multi-block
72
+ // answer reads as one run of prose, not N chopped paragraphs).
73
+ const timeline = [];
74
+ for (const event of pending.timeline) {
75
+ const last = timeline[timeline.length - 1];
76
+ if (event.kind === "text" &&
77
+ last !== undefined &&
78
+ last.kind === "text") {
79
+ timeline[timeline.length - 1] = {
80
+ kind: "text",
81
+ text: `${last.text}\n\n${event.text}`,
82
+ };
83
+ continue;
84
+ }
85
+ timeline.push(event);
86
+ }
87
+ turns.push({
88
+ kind: "assistant",
89
+ entries: pending.entries,
90
+ thinking: pending.thinking,
91
+ tools: pending.tools,
92
+ text: pending.texts.join("\n\n"),
93
+ lastEntryId: pending.entries[pending.entries.length - 1].id,
94
+ timeline,
95
+ });
96
+ pending = null;
97
+ };
98
+ for (const entry of entries) {
99
+ if (entry.kind === "pi.assistant" || entry.kind === "pi.tool-result") {
100
+ pending ??= {
101
+ entries: [],
102
+ thinking: [],
103
+ tools: [],
104
+ texts: [],
105
+ timeline: [],
106
+ };
107
+ pending.entries.push(entry);
108
+ if (entry.kind === "pi.assistant") {
109
+ for (const message of entry.model ?? []) {
110
+ for (const block of blocks(message)) {
111
+ if (block.type === "text" && block.text !== undefined) {
112
+ pending.texts.push(block.text);
113
+ pending.timeline.push({ kind: "text", text: block.text });
114
+ }
115
+ else if (block.type === "thinking" &&
116
+ block.thinking !== undefined) {
117
+ pending.thinking.push(block.thinking);
118
+ pending.timeline.push({
119
+ kind: "thinking",
120
+ text: block.thinking,
121
+ });
122
+ }
123
+ else if (block.type === "toolCall") {
124
+ const name = block.name ?? "";
125
+ // A blocks tool's call is invisible in the transcript: the
126
+ // `sigrun.blocks` entry it appends is the artifact. The
127
+ // RESULT entry still joins by callId below (matching only
128
+ // pending tools, so its join is a harmless no-op).
129
+ if (BLOCKS_TOOLS.has(name))
130
+ continue;
131
+ const call = {
132
+ callId: block.id ?? "",
133
+ name,
134
+ preview: toolPreview(name, block.arguments),
135
+ isError: false,
136
+ };
137
+ pending.tools.push(call);
138
+ pending.timeline.push({ kind: "tool", tool: call });
139
+ }
140
+ }
141
+ }
142
+ }
143
+ else {
144
+ for (const message of entry.model ?? []) {
145
+ if (message.toolCallId === undefined)
146
+ continue;
147
+ const tool = pending.tools.find((candidate) => candidate.callId === message.toolCallId &&
148
+ candidate.resultEntryId === undefined);
149
+ if (tool !== undefined) {
150
+ tool.resultEntryId = entry.id;
151
+ tool.isError = message.isError === true;
152
+ }
153
+ }
154
+ }
155
+ continue;
156
+ }
157
+ // U5 Task 4: the chat-blocks kinds are RUN ARTIFACTS, never
158
+ // standalone records (spec §8.2/§8.3). With a pending assistant
159
+ // turn they ride it — a boundary note mid-run appends its event
160
+ // and does NOT flush. With NONE (auto-compaction's pi.compaction
161
+ // flushed the run; the trailing note/blocks entry lands after the
162
+ // flush) they START one: an assistant turn with no text whose
163
+ // timeline still renders the block/note rows — falling through to
164
+ // `other` would print "unsupported entry kind".
165
+ // `sigrun.blocks.answer` is a user turn (§8.4: the answer submits
166
+ // through the normal path).
167
+ if (entry.kind === BLOCKS_KIND || entry.kind === BLOCKS_NOTE_KIND) {
168
+ pending ??= {
169
+ entries: [],
170
+ thinking: [],
171
+ tools: [],
172
+ texts: [],
173
+ timeline: [],
174
+ };
175
+ pending.entries.push(entry);
176
+ pending.timeline.push({
177
+ kind: entry.kind === BLOCKS_KIND ? "blocks" : "note",
178
+ entryId: entry.id,
179
+ });
180
+ continue;
181
+ }
182
+ flush();
183
+ if (entry.kind === "pi.user" ||
184
+ entry.kind === EXPANDED_USER_KIND ||
185
+ entry.kind === BLOCKS_ANSWER_KIND) {
186
+ turns.push({ kind: "user", entry });
187
+ }
188
+ else {
189
+ turns.push({ kind: "other", entry });
190
+ }
191
+ }
192
+ flush();
193
+ return turns;
194
+ }
195
+ /** Which argument each known tool previews, by tool name. */
196
+ const PREVIEW_ARGUMENT = {
197
+ bash: "command",
198
+ read: "path",
199
+ write: "path",
200
+ edit: "path",
201
+ ls: "path",
202
+ find: "path",
203
+ grep: "pattern",
204
+ };
205
+ const PREVIEW_MAX = 80;
206
+ export function toolPreview(name, args) {
207
+ const candidate = previewArgument(name, args);
208
+ if (candidate === undefined)
209
+ return "";
210
+ const firstLine = candidate.split("\n", 1)[0];
211
+ return firstLine.length > PREVIEW_MAX
212
+ ? `${firstLine.slice(0, PREVIEW_MAX)}…`
213
+ : firstLine;
214
+ }
215
+ /**
216
+ * The FULL argument a tool's preview shows, uncut — the transcript's
217
+ * copy-command action copies it verbatim: the same name-keyed argument
218
+ * the preview selects, before its first-line/80-char truncation.
219
+ * Undefined when the arguments carry no previewable string.
220
+ */
221
+ export function previewArgument(name, args) {
222
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
223
+ return undefined;
224
+ }
225
+ const record = args;
226
+ const key = PREVIEW_ARGUMENT[name];
227
+ const candidate = key === undefined
228
+ ? Object.values(record).find((value) => typeof value === "string")
229
+ : record[key];
230
+ return typeof candidate === "string" && candidate.length > 0
231
+ ? candidate
232
+ : undefined;
233
+ }
234
+ /**
235
+ * The collapsed activity line under an assistant turn: thinking and
236
+ * tool COUNTS only — the entry wire carries no per-block timing, so
237
+ * durations are never shown (plan deviation, Task D records it).
238
+ */
239
+ export function activityLabel(turn) {
240
+ const parts = [];
241
+ if (turn.thinking.length > 0)
242
+ parts.push("Thinking");
243
+ if (turn.tools.length > 0) {
244
+ parts.push(`${turn.tools.length} ${turn.tools.length === 1 ? "tool" : "tools"}`);
245
+ }
246
+ return parts.join(" · ");
247
+ }
@@ -0,0 +1,8 @@
1
+ export declare const MAX_USERNAME_LENGTH = 128;
2
+ export declare const MAX_SUFFIX_ATTEMPTS = 10000;
3
+ export declare class UsernameExhaustedError extends Error {
4
+ }
5
+ export declare function deriveUsername(input: string): string | undefined;
6
+ export declare function isUsernameKey(s: string): boolean;
7
+ export declare function foldSubject(s: string): string;
8
+ export declare function withSuffix(base: string, taken: (key: string) => boolean): string;
@@ -0,0 +1,39 @@
1
+ export const MAX_USERNAME_LENGTH = 128;
2
+ export const MAX_SUFFIX_ATTEMPTS = 10_000;
3
+ const COMBINING_MARKS = /\p{M}/gu;
4
+ const AT_SIGNS = /@/g;
5
+ const INVALID_CHARACTERS = /[^a-z0-9._-]/g;
6
+ const LEADING_NON_ALPHANUMERIC = /^[^a-z0-9]+/;
7
+ const USERNAME_KEY = /^[a-z0-9][a-z0-9._-]*$/;
8
+ export class UsernameExhaustedError extends Error {
9
+ }
10
+ export function deriveUsername(input) {
11
+ const derived = input
12
+ .normalize("NFKD")
13
+ .replace(COMBINING_MARKS, "")
14
+ .toLowerCase()
15
+ .replace(AT_SIGNS, "_")
16
+ .replace(INVALID_CHARACTERS, "-")
17
+ .replace(LEADING_NON_ALPHANUMERIC, "")
18
+ .slice(0, MAX_USERNAME_LENGTH);
19
+ return derived === "" ? undefined : derived;
20
+ }
21
+ export function isUsernameKey(s) {
22
+ return s.length <= MAX_USERNAME_LENGTH && USERNAME_KEY.test(s);
23
+ }
24
+ export function foldSubject(s) {
25
+ return s.normalize("NFC").toLowerCase();
26
+ }
27
+ export function withSuffix(base, taken) {
28
+ if (!taken(base)) {
29
+ return base;
30
+ }
31
+ for (let suffix = 2; suffix <= MAX_SUFFIX_ATTEMPTS; suffix += 1) {
32
+ const suffixText = `-${suffix}`;
33
+ const candidate = `${base.slice(0, MAX_USERNAME_LENGTH - suffixText.length)}${suffixText}`;
34
+ if (!taken(candidate)) {
35
+ return candidate;
36
+ }
37
+ }
38
+ throw new UsernameExhaustedError(`No available username for "${base}"`);
39
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Delta view frames (core hardening W3, spec §7.3 ruling 11): the SSE view
3
+ * stream's incremental frame shape and its pure diff/fold functions, shared
4
+ * by the server (which diffs) and both clients (which fold).
5
+ *
6
+ * The stream keeps `event: snapshot` (the full wire view) on connect and as
7
+ * the fallback; each later revision is a `event: delta` frame carrying only
8
+ * what changed:
9
+ *
10
+ * - `entries.append` / `entries.remove` — the entry list keyed by `id`;
11
+ * removals plus a pure suffix append are representable, anything else
12
+ * (a head insert, a reorder — e.g. a compaction's new head marker) makes
13
+ * {@link diffViews} return `undefined` and the server sends a fresh
14
+ * snapshot instead;
15
+ * - `docs.set` / `docs.unset` — the changed/removed named documents;
16
+ * - `conversation` / `steer` — present only when they differ.
17
+ *
18
+ * Purity: a shared wire module — `import type` sibling-only (the purity
19
+ * guard in test/views-purity.test.ts); the runtime functions below are
20
+ * dependency-free so both clients (DOM-only webui, NodeNext tui) and the
21
+ * server fold identically.
22
+ */
23
+ import type { JsonObject } from "./json.js";
24
+ import type { ConversationViewWire, SteerField, ViewConversation, ViewEntry } from "./views.js";
25
+ /** One `event: delta` frame's payload. Absent slots mean "no change". */
26
+ export interface ViewDelta {
27
+ readonly entries?: {
28
+ readonly append?: readonly ViewEntry[];
29
+ readonly remove?: readonly number[];
30
+ };
31
+ readonly docs?: {
32
+ readonly set?: Readonly<Record<string, JsonObject>>;
33
+ readonly unset?: readonly string[];
34
+ };
35
+ readonly conversation?: ViewConversation;
36
+ readonly steer?: SteerField;
37
+ }
38
+ /**
39
+ * Diff two consecutive wire views into a delta, or `undefined` when the
40
+ * change is not representable: entries are keyed by `id`, `remove` is the
41
+ * prev ids absent from next, and AFTER removal prev's remaining ids must be
42
+ * a PREFIX of next's (append-only tail). A head insert or reorder fails the
43
+ * prefix rule → `undefined` → the caller sends a fresh snapshot.
44
+ */
45
+ export declare function diffViews(prev: ConversationViewWire, next: ConversationViewWire): ViewDelta | undefined;
46
+ /**
47
+ * Fold a delta into a view: filter removed entry ids, concat the appended
48
+ * suffix, shallow-merge docs (set over, unset out), replace
49
+ * `conversation`/`steer` when present. NEVER mutates `view` — every changed
50
+ * slot is a fresh object/array (unchanged slots keep their references).
51
+ */
52
+ export declare function applyDelta(view: ConversationViewWire, delta: ViewDelta): ConversationViewWire;
53
+ /** True when every slot is absent or empty (the server skips these frames). */
54
+ export declare function isEmptyDelta(delta: ViewDelta): boolean;