@sigrun/shared 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +9 -2
- package/dist/access.d.ts +55 -0
- package/dist/access.js +72 -0
- package/dist/ambient.d.ts +111 -0
- package/dist/ambient.js +23 -0
- package/dist/commands.d.ts +75 -0
- package/dist/commands.js +25 -0
- package/dist/conversations.d.ts +31 -0
- package/dist/conversations.js +94 -0
- package/dist/interactions.d.ts +91 -0
- package/dist/interactions.js +21 -0
- package/dist/jobs.d.ts +100 -0
- package/dist/jobs.js +19 -0
- package/dist/json.d.ts +20 -0
- package/dist/json.js +13 -0
- package/dist/session-meta.d.ts +103 -0
- package/dist/session-meta.js +13 -0
- package/dist/settings.d.ts +401 -0
- package/dist/settings.js +19 -0
- package/dist/themes.d.ts +50 -0
- package/dist/themes.js +156 -0
- package/dist/turns.d.ts +102 -0
- package/dist/turns.js +166 -0
- package/dist/view-delta.d.ts +54 -0
- package/dist/view-delta.js +135 -0
- package/dist/views.d.ts +742 -0
- package/dist/views.js +38 -0
- package/package.json +82 -5
package/dist/settings.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings-file TYPES (Task 8a, pi parity spec: the settings file) —
|
|
3
|
+
* the persisted `SettingsFile` shape and its sections.
|
|
4
|
+
*
|
|
5
|
+
* The CANONICAL type declarations live here (the shared leaf package);
|
|
6
|
+
* `src/harness/settings.ts` imports them (type-only) for the store/
|
|
7
|
+
* validation runtime. The `GET/PATCH /v1/settings` wire serves this
|
|
8
|
+
* shape verbatim (`SettingsResponse` = `SettingsFile` + the
|
|
9
|
+
* server-computed `toolRoster`; see `@sigrun/shared/views`), so both
|
|
10
|
+
* clients read these fields straight off the wire.
|
|
11
|
+
*
|
|
12
|
+
* The file is a JSON document at `<config-dir>/settings.json`. It
|
|
13
|
+
* carries the `HarnessSettings` pass-throughs (`steeringMode`,
|
|
14
|
+
* `followUpMode`, `retry`, `stream`) plus sigrun's own resolved fields
|
|
15
|
+
* (`defaultTools`, `perModelThinking`, `extensions`, `jobs`, `jobHistory`,
|
|
16
|
+
* `defaultModel`, `namingModel`, `defaultProvider`, `server`). Credentials
|
|
17
|
+
* never live here (they live in the sibling `auth.json`).
|
|
18
|
+
*/
|
|
19
|
+
export {};
|
package/dist/themes.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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 palette mockup
|
|
11
|
+
* (docs/superpowers/specs/assets/2026-10-17-ui-refresh/palettes.html),
|
|
12
|
+
* with the mockup ids renamed to the plan ids: palette `iron` →
|
|
13
|
+
* `iron-ember`, `aurora` → `polar-night`, `birch` → `birch-moss`;
|
|
14
|
+
* accent `amber` → `baltic-amber`; token key `surface-2` → `surface2`.
|
|
15
|
+
*/
|
|
16
|
+
export type PaletteName = "fjord" | "iron-ember" | "polar-night" | "birch-moss";
|
|
17
|
+
export type AccentName = "baltic-amber" | "ember" | "lingonberry" | "glacier" | "aurora" | "lichen" | "heather";
|
|
18
|
+
export type ModeName = "dark" | "light";
|
|
19
|
+
/** One mode's tokens: every value a CSS colour literal. */
|
|
20
|
+
export interface PaletteTokens {
|
|
21
|
+
readonly bg: string;
|
|
22
|
+
readonly surface: string;
|
|
23
|
+
readonly surface2: string;
|
|
24
|
+
readonly border: string;
|
|
25
|
+
readonly text: string;
|
|
26
|
+
readonly muted: string;
|
|
27
|
+
readonly faint: string;
|
|
28
|
+
readonly ok: string;
|
|
29
|
+
readonly code: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Palette {
|
|
32
|
+
readonly label: string;
|
|
33
|
+
readonly defaultAccent: AccentName;
|
|
34
|
+
readonly dark: PaletteTokens;
|
|
35
|
+
readonly light: PaletteTokens;
|
|
36
|
+
}
|
|
37
|
+
export interface Accent {
|
|
38
|
+
readonly label: string;
|
|
39
|
+
readonly dark: string;
|
|
40
|
+
readonly light: string;
|
|
41
|
+
}
|
|
42
|
+
export declare const PALETTES: Readonly<Record<PaletteName, Palette>>;
|
|
43
|
+
export declare const ACCENTS: Readonly<Record<AccentName, Accent>>;
|
|
44
|
+
export declare const PALETTE_NAMES: readonly PaletteName[];
|
|
45
|
+
export declare const ACCENT_NAMES: readonly AccentName[];
|
|
46
|
+
/** The shipped default: Polar Night + Baltic Amber. */
|
|
47
|
+
export declare const DEFAULT_PALETTE: PaletteName;
|
|
48
|
+
export declare const DEFAULT_ACCENT: AccentName;
|
|
49
|
+
export declare function isPaletteName(value: unknown): value is PaletteName;
|
|
50
|
+
export declare function isAccentName(value: unknown): value is AccentName;
|
package/dist/themes.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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 palette mockup
|
|
11
|
+
* (docs/superpowers/specs/assets/2026-10-17-ui-refresh/palettes.html),
|
|
12
|
+
* with the mockup ids renamed to the plan ids: palette `iron` →
|
|
13
|
+
* `iron-ember`, `aurora` → `polar-night`, `birch` → `birch-moss`;
|
|
14
|
+
* accent `amber` → `baltic-amber`; token key `surface-2` → `surface2`.
|
|
15
|
+
*/
|
|
16
|
+
export const PALETTES = {
|
|
17
|
+
fjord: {
|
|
18
|
+
label: "Fjord",
|
|
19
|
+
defaultAccent: "baltic-amber",
|
|
20
|
+
dark: {
|
|
21
|
+
bg: "#0b0f13",
|
|
22
|
+
surface: "#11171e",
|
|
23
|
+
surface2: "#18202a",
|
|
24
|
+
border: "#222d39",
|
|
25
|
+
text: "#dde6ee",
|
|
26
|
+
muted: "#8d9cab",
|
|
27
|
+
faint: "#5f6e7d",
|
|
28
|
+
ok: "#7fbf9a",
|
|
29
|
+
code: "rgba(255,255,255,.06)",
|
|
30
|
+
},
|
|
31
|
+
light: {
|
|
32
|
+
bg: "#eef2f5",
|
|
33
|
+
surface: "#ffffff",
|
|
34
|
+
surface2: "#e3e9ef",
|
|
35
|
+
border: "#d3dbe3",
|
|
36
|
+
text: "#18212a",
|
|
37
|
+
muted: "#4d5b69",
|
|
38
|
+
faint: "#8593a1",
|
|
39
|
+
ok: "#2f8a5a",
|
|
40
|
+
code: "rgba(0,0,0,.05)",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"iron-ember": {
|
|
44
|
+
label: "Iron & Ember",
|
|
45
|
+
defaultAccent: "ember",
|
|
46
|
+
dark: {
|
|
47
|
+
bg: "#0e0e0f",
|
|
48
|
+
surface: "#161618",
|
|
49
|
+
surface2: "#1e1e21",
|
|
50
|
+
border: "#2a2a2e",
|
|
51
|
+
text: "#e6e1da",
|
|
52
|
+
muted: "#9a958e",
|
|
53
|
+
faint: "#66625d",
|
|
54
|
+
ok: "#8fb58a",
|
|
55
|
+
code: "rgba(255,255,255,.06)",
|
|
56
|
+
},
|
|
57
|
+
light: {
|
|
58
|
+
bg: "#f3f1ee",
|
|
59
|
+
surface: "#fbfaf8",
|
|
60
|
+
surface2: "#e9e6e1",
|
|
61
|
+
border: "#dbd7d1",
|
|
62
|
+
text: "#1c1b1a",
|
|
63
|
+
muted: "#55524d",
|
|
64
|
+
faint: "#8e8a84",
|
|
65
|
+
ok: "#3e7a3a",
|
|
66
|
+
code: "rgba(0,0,0,.05)",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
"polar-night": {
|
|
70
|
+
label: "Polar Night",
|
|
71
|
+
defaultAccent: "aurora",
|
|
72
|
+
dark: {
|
|
73
|
+
bg: "#090d16",
|
|
74
|
+
surface: "#0f1524",
|
|
75
|
+
surface2: "#161e31",
|
|
76
|
+
border: "#1f2942",
|
|
77
|
+
text: "#e2e8f3",
|
|
78
|
+
muted: "#8e9ab2",
|
|
79
|
+
faint: "#5d6984",
|
|
80
|
+
ok: "#5fd4a8",
|
|
81
|
+
code: "rgba(255,255,255,.06)",
|
|
82
|
+
},
|
|
83
|
+
light: {
|
|
84
|
+
bg: "#f0f3f9",
|
|
85
|
+
surface: "#ffffff",
|
|
86
|
+
surface2: "#e4e9f3",
|
|
87
|
+
border: "#d2d9e6",
|
|
88
|
+
text: "#141b2b",
|
|
89
|
+
muted: "#4b5670",
|
|
90
|
+
faint: "#8691a8",
|
|
91
|
+
ok: "#13866a",
|
|
92
|
+
code: "rgba(0,0,0,.05)",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
"birch-moss": {
|
|
96
|
+
label: "Birch & Moss",
|
|
97
|
+
defaultAccent: "lichen",
|
|
98
|
+
dark: {
|
|
99
|
+
bg: "#0d100d",
|
|
100
|
+
surface: "#141a15",
|
|
101
|
+
surface2: "#1b221c",
|
|
102
|
+
border: "#263027",
|
|
103
|
+
text: "#e8e6dc",
|
|
104
|
+
muted: "#9ba091",
|
|
105
|
+
faint: "#666d60",
|
|
106
|
+
ok: "#9cc48a",
|
|
107
|
+
code: "rgba(255,255,255,.06)",
|
|
108
|
+
},
|
|
109
|
+
light: {
|
|
110
|
+
bg: "#f3f2ea",
|
|
111
|
+
surface: "#fbfbf6",
|
|
112
|
+
surface2: "#e7e7dc",
|
|
113
|
+
border: "#d8d8ca",
|
|
114
|
+
text: "#1b1e18",
|
|
115
|
+
muted: "#525849",
|
|
116
|
+
faint: "#8b9080",
|
|
117
|
+
ok: "#4a7a35",
|
|
118
|
+
code: "rgba(0,0,0,.05)",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
export const ACCENTS = {
|
|
123
|
+
"baltic-amber": { label: "Baltic Amber", dark: "#d6a24a", light: "#a8741f" },
|
|
124
|
+
ember: { label: "Ember", dark: "#d9773a", light: "#b8571c" },
|
|
125
|
+
lingonberry: { label: "Lingonberry", dark: "#d0606a", light: "#a83440" },
|
|
126
|
+
glacier: { label: "Glacier", dark: "#8fb8d0", light: "#2f6f93" },
|
|
127
|
+
aurora: { label: "Aurora", dark: "#5fd4a8", light: "#13866a" },
|
|
128
|
+
lichen: { label: "Lichen", dark: "#b9c46a", light: "#6f7a1f" },
|
|
129
|
+
heather: { label: "Heather", dark: "#a993c9", light: "#6c4f99" },
|
|
130
|
+
};
|
|
131
|
+
export const PALETTE_NAMES = [
|
|
132
|
+
"fjord",
|
|
133
|
+
"iron-ember",
|
|
134
|
+
"polar-night",
|
|
135
|
+
"birch-moss",
|
|
136
|
+
];
|
|
137
|
+
export const ACCENT_NAMES = [
|
|
138
|
+
"baltic-amber",
|
|
139
|
+
"ember",
|
|
140
|
+
"lingonberry",
|
|
141
|
+
"glacier",
|
|
142
|
+
"aurora",
|
|
143
|
+
"lichen",
|
|
144
|
+
"heather",
|
|
145
|
+
];
|
|
146
|
+
/** The shipped default: Polar Night + Baltic Amber. */
|
|
147
|
+
export const DEFAULT_PALETTE = "polar-night";
|
|
148
|
+
export const DEFAULT_ACCENT = "baltic-amber";
|
|
149
|
+
export function isPaletteName(value) {
|
|
150
|
+
return (typeof value === "string" &&
|
|
151
|
+
Object.prototype.hasOwnProperty.call(PALETTES, value));
|
|
152
|
+
}
|
|
153
|
+
export function isAccentName(value) {
|
|
154
|
+
return (typeof value === "string" &&
|
|
155
|
+
Object.prototype.hasOwnProperty.call(ACCENTS, value));
|
|
156
|
+
}
|
package/dist/turns.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
export interface TurnBlock {
|
|
28
|
+
readonly type: string;
|
|
29
|
+
readonly text?: string;
|
|
30
|
+
readonly thinking?: string;
|
|
31
|
+
readonly id?: string;
|
|
32
|
+
readonly name?: string;
|
|
33
|
+
readonly arguments?: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface TurnMessage {
|
|
36
|
+
readonly role: string;
|
|
37
|
+
readonly content: string | readonly TurnBlock[];
|
|
38
|
+
readonly toolCallId?: string;
|
|
39
|
+
readonly toolName?: string;
|
|
40
|
+
readonly isError?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface TurnEntry {
|
|
43
|
+
readonly id: number;
|
|
44
|
+
readonly kind: string;
|
|
45
|
+
readonly model?: readonly TurnMessage[];
|
|
46
|
+
/** The entry's kind-specific JSON payload — `sigrun.user.expanded`'s
|
|
47
|
+
* `{typed, command}` (see {@link expandedUserEntry}). */
|
|
48
|
+
readonly data?: unknown;
|
|
49
|
+
}
|
|
50
|
+
/** The narrowed `data` of a {@link EXPANDED_USER_KIND} entry: what the
|
|
51
|
+
* user TYPED (the bubble's text) and the command that expanded. */
|
|
52
|
+
export interface ExpandedUserEntryData {
|
|
53
|
+
readonly typed: string;
|
|
54
|
+
readonly command?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Narrow one entry to its expanded-user `data` — defined exactly when
|
|
57
|
+
* the kind is {@link EXPANDED_USER_KIND} and `typed` is a non-empty
|
|
58
|
+
* string; undefined for every other shape (a plain `pi.user`, missing
|
|
59
|
+
* or malformed data). */
|
|
60
|
+
export declare function expandedUserEntry(entry: {
|
|
61
|
+
readonly kind: string;
|
|
62
|
+
readonly data?: unknown;
|
|
63
|
+
}): ExpandedUserEntryData | undefined;
|
|
64
|
+
export interface ToolCall {
|
|
65
|
+
readonly callId: string;
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly preview: string;
|
|
68
|
+
readonly resultEntryId?: number;
|
|
69
|
+
readonly isError: boolean;
|
|
70
|
+
}
|
|
71
|
+
export type Turn<E extends TurnEntry> = {
|
|
72
|
+
readonly kind: "user";
|
|
73
|
+
readonly entry: E;
|
|
74
|
+
} | {
|
|
75
|
+
readonly kind: "assistant";
|
|
76
|
+
readonly entries: readonly E[];
|
|
77
|
+
readonly thinking: readonly string[];
|
|
78
|
+
readonly tools: readonly ToolCall[];
|
|
79
|
+
readonly text: string;
|
|
80
|
+
readonly lastEntryId: number;
|
|
81
|
+
} | {
|
|
82
|
+
readonly kind: "other";
|
|
83
|
+
readonly entry: E;
|
|
84
|
+
};
|
|
85
|
+
export declare function groupTurns<E extends TurnEntry>(entries: readonly E[]): Turn<E>[];
|
|
86
|
+
export declare function toolPreview(name: string, args: unknown): string;
|
|
87
|
+
/**
|
|
88
|
+
* The FULL argument a tool's preview shows, uncut — the transcript's
|
|
89
|
+
* copy-command action copies it verbatim: the same name-keyed argument
|
|
90
|
+
* the preview selects, before its first-line/80-char truncation.
|
|
91
|
+
* Undefined when the arguments carry no previewable string.
|
|
92
|
+
*/
|
|
93
|
+
export declare function previewArgument(name: string, args: unknown): string | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* The collapsed activity line under an assistant turn: thinking and
|
|
96
|
+
* tool COUNTS only — the entry wire carries no per-block timing, so
|
|
97
|
+
* durations are never shown (plan deviation, Task D records it).
|
|
98
|
+
*/
|
|
99
|
+
export declare function activityLabel(turn: {
|
|
100
|
+
thinking: readonly string[];
|
|
101
|
+
tools: readonly ToolCall[];
|
|
102
|
+
}): string;
|
package/dist/turns.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
/** Narrow one entry to its expanded-user `data` — defined exactly when
|
|
28
|
+
* the kind is {@link EXPANDED_USER_KIND} and `typed` is a non-empty
|
|
29
|
+
* string; undefined for every other shape (a plain `pi.user`, missing
|
|
30
|
+
* or malformed data). */
|
|
31
|
+
export function expandedUserEntry(entry) {
|
|
32
|
+
if (entry.kind !== EXPANDED_USER_KIND)
|
|
33
|
+
return undefined;
|
|
34
|
+
const data = entry.data;
|
|
35
|
+
if (typeof data !== "object" || data === null)
|
|
36
|
+
return undefined;
|
|
37
|
+
const typed = data.typed;
|
|
38
|
+
if (typeof typed !== "string" || typed === "")
|
|
39
|
+
return undefined;
|
|
40
|
+
const command = data.command;
|
|
41
|
+
return typeof command === "string" && command !== ""
|
|
42
|
+
? { typed, command }
|
|
43
|
+
: { typed };
|
|
44
|
+
}
|
|
45
|
+
/** The assistant text blocks of one message, in order. */
|
|
46
|
+
function blocks(message) {
|
|
47
|
+
return typeof message.content === "string" ? [] : message.content;
|
|
48
|
+
}
|
|
49
|
+
export function groupTurns(entries) {
|
|
50
|
+
const turns = [];
|
|
51
|
+
let pending = null;
|
|
52
|
+
const flush = () => {
|
|
53
|
+
if (pending === null)
|
|
54
|
+
return;
|
|
55
|
+
turns.push({
|
|
56
|
+
kind: "assistant",
|
|
57
|
+
entries: pending.entries,
|
|
58
|
+
thinking: pending.thinking,
|
|
59
|
+
tools: pending.tools,
|
|
60
|
+
text: pending.texts.join("\n\n"),
|
|
61
|
+
lastEntryId: pending.entries[pending.entries.length - 1].id,
|
|
62
|
+
});
|
|
63
|
+
pending = null;
|
|
64
|
+
};
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.kind === "pi.assistant" || entry.kind === "pi.tool-result") {
|
|
67
|
+
pending ??= { entries: [], thinking: [], tools: [], texts: [] };
|
|
68
|
+
pending.entries.push(entry);
|
|
69
|
+
if (entry.kind === "pi.assistant") {
|
|
70
|
+
for (const message of entry.model ?? []) {
|
|
71
|
+
for (const block of blocks(message)) {
|
|
72
|
+
if (block.type === "text" && block.text !== undefined) {
|
|
73
|
+
pending.texts.push(block.text);
|
|
74
|
+
}
|
|
75
|
+
else if (block.type === "thinking" && block.thinking !== undefined) {
|
|
76
|
+
pending.thinking.push(block.thinking);
|
|
77
|
+
}
|
|
78
|
+
else if (block.type === "toolCall") {
|
|
79
|
+
pending.tools.push({
|
|
80
|
+
callId: block.id ?? "",
|
|
81
|
+
name: block.name ?? "",
|
|
82
|
+
preview: toolPreview(block.name ?? "", block.arguments),
|
|
83
|
+
isError: false,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
for (const message of entry.model ?? []) {
|
|
91
|
+
if (message.toolCallId === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
const tool = pending.tools.find((candidate) => candidate.callId === message.toolCallId &&
|
|
94
|
+
candidate.resultEntryId === undefined);
|
|
95
|
+
if (tool !== undefined) {
|
|
96
|
+
tool.resultEntryId = entry.id;
|
|
97
|
+
tool.isError = message.isError === true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
flush();
|
|
104
|
+
if (entry.kind === "pi.user" || entry.kind === EXPANDED_USER_KIND) {
|
|
105
|
+
turns.push({ kind: "user", entry });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
turns.push({ kind: "other", entry });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
flush();
|
|
112
|
+
return turns;
|
|
113
|
+
}
|
|
114
|
+
/** Which argument each known tool previews, by tool name. */
|
|
115
|
+
const PREVIEW_ARGUMENT = {
|
|
116
|
+
bash: "command",
|
|
117
|
+
read: "path",
|
|
118
|
+
write: "path",
|
|
119
|
+
edit: "path",
|
|
120
|
+
ls: "path",
|
|
121
|
+
find: "path",
|
|
122
|
+
grep: "pattern",
|
|
123
|
+
};
|
|
124
|
+
const PREVIEW_MAX = 80;
|
|
125
|
+
export function toolPreview(name, args) {
|
|
126
|
+
const candidate = previewArgument(name, args);
|
|
127
|
+
if (candidate === undefined)
|
|
128
|
+
return "";
|
|
129
|
+
const firstLine = candidate.split("\n", 1)[0];
|
|
130
|
+
return firstLine.length > PREVIEW_MAX
|
|
131
|
+
? `${firstLine.slice(0, PREVIEW_MAX)}…`
|
|
132
|
+
: firstLine;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The FULL argument a tool's preview shows, uncut — the transcript's
|
|
136
|
+
* copy-command action copies it verbatim: the same name-keyed argument
|
|
137
|
+
* the preview selects, before its first-line/80-char truncation.
|
|
138
|
+
* Undefined when the arguments carry no previewable string.
|
|
139
|
+
*/
|
|
140
|
+
export function previewArgument(name, args) {
|
|
141
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const record = args;
|
|
145
|
+
const key = PREVIEW_ARGUMENT[name];
|
|
146
|
+
const candidate = key === undefined
|
|
147
|
+
? Object.values(record).find((value) => typeof value === "string")
|
|
148
|
+
: record[key];
|
|
149
|
+
return typeof candidate === "string" && candidate.length > 0
|
|
150
|
+
? candidate
|
|
151
|
+
: undefined;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The collapsed activity line under an assistant turn: thinking and
|
|
155
|
+
* tool COUNTS only — the entry wire carries no per-block timing, so
|
|
156
|
+
* durations are never shown (plan deviation, Task D records it).
|
|
157
|
+
*/
|
|
158
|
+
export function activityLabel(turn) {
|
|
159
|
+
const parts = [];
|
|
160
|
+
if (turn.thinking.length > 0)
|
|
161
|
+
parts.push("Thinking");
|
|
162
|
+
if (turn.tools.length > 0) {
|
|
163
|
+
parts.push(`${turn.tools.length} ${turn.tools.length === 1 ? "tool" : "tools"}`);
|
|
164
|
+
}
|
|
165
|
+
return parts.join(" · ");
|
|
166
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
/** Structural equality for the JSON-shaped slots (docs/conversation/steer). */
|
|
24
|
+
function jsonEqual(a, b) {
|
|
25
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Diff two consecutive wire views into a delta, or `undefined` when the
|
|
29
|
+
* change is not representable: entries are keyed by `id`, `remove` is the
|
|
30
|
+
* prev ids absent from next, and AFTER removal prev's remaining ids must be
|
|
31
|
+
* a PREFIX of next's (append-only tail). A head insert or reorder fails the
|
|
32
|
+
* prefix rule → `undefined` → the caller sends a fresh snapshot.
|
|
33
|
+
*/
|
|
34
|
+
export function diffViews(prev, next) {
|
|
35
|
+
const delta = {};
|
|
36
|
+
// Entries: removals, then the prefix rule, then the appended suffix.
|
|
37
|
+
const nextIds = new Set(next.entries.map((entry) => entry.id));
|
|
38
|
+
const remove = prev.entries
|
|
39
|
+
.filter((entry) => !nextIds.has(entry.id))
|
|
40
|
+
.map((entry) => entry.id);
|
|
41
|
+
const remaining = prev.entries.filter((entry) => nextIds.has(entry.id));
|
|
42
|
+
const prefix = remaining.every((entry, index) => entry.id === next.entries[index]?.id);
|
|
43
|
+
if (!prefix)
|
|
44
|
+
return undefined;
|
|
45
|
+
const append = next.entries.slice(remaining.length);
|
|
46
|
+
if (remove.length > 0 || append.length > 0) {
|
|
47
|
+
delta.entries = {};
|
|
48
|
+
if (remove.length > 0)
|
|
49
|
+
delta.entries.remove = remove;
|
|
50
|
+
if (append.length > 0)
|
|
51
|
+
delta.entries.append = [...append];
|
|
52
|
+
}
|
|
53
|
+
// Docs: reference-equal first (the common no-op revision), JSON second.
|
|
54
|
+
const docKeys = new Set([
|
|
55
|
+
...Object.keys(prev.docs),
|
|
56
|
+
...Object.keys(next.docs),
|
|
57
|
+
]);
|
|
58
|
+
const set = {};
|
|
59
|
+
const unset = [];
|
|
60
|
+
for (const key of docKeys) {
|
|
61
|
+
const before = prev.docs[key];
|
|
62
|
+
const after = next.docs[key];
|
|
63
|
+
if (after === undefined) {
|
|
64
|
+
if (before !== undefined)
|
|
65
|
+
unset.push(key);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (before === undefined || !jsonEqual(before, after))
|
|
69
|
+
set[key] = after;
|
|
70
|
+
}
|
|
71
|
+
if (unset.length > 0 || Object.keys(set).length > 0) {
|
|
72
|
+
delta.docs = {};
|
|
73
|
+
if (unset.length > 0)
|
|
74
|
+
delta.docs.unset = unset;
|
|
75
|
+
if (Object.keys(set).length > 0)
|
|
76
|
+
delta.docs.set = set;
|
|
77
|
+
}
|
|
78
|
+
if (!jsonEqual(prev.conversation, next.conversation)) {
|
|
79
|
+
delta.conversation = next.conversation;
|
|
80
|
+
}
|
|
81
|
+
if (!jsonEqual(prev.steer, next.steer)) {
|
|
82
|
+
delta.steer = next.steer;
|
|
83
|
+
}
|
|
84
|
+
return delta;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Fold a delta into a view: filter removed entry ids, concat the appended
|
|
88
|
+
* suffix, shallow-merge docs (set over, unset out), replace
|
|
89
|
+
* `conversation`/`steer` when present. NEVER mutates `view` — every changed
|
|
90
|
+
* slot is a fresh object/array (unchanged slots keep their references).
|
|
91
|
+
*/
|
|
92
|
+
export function applyDelta(view, delta) {
|
|
93
|
+
let entries = view.entries;
|
|
94
|
+
if (delta.entries !== undefined) {
|
|
95
|
+
const removed = new Set(delta.entries.remove ?? []);
|
|
96
|
+
entries = [
|
|
97
|
+
...view.entries.filter((entry) => !removed.has(entry.id)),
|
|
98
|
+
...(delta.entries.append ?? []),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
let docs = view.docs;
|
|
102
|
+
if (delta.docs !== undefined) {
|
|
103
|
+
const unset = new Set(delta.docs.unset ?? []);
|
|
104
|
+
const merged = {};
|
|
105
|
+
for (const [key, value] of Object.entries(view.docs)) {
|
|
106
|
+
if (!unset.has(key))
|
|
107
|
+
merged[key] = value;
|
|
108
|
+
}
|
|
109
|
+
for (const [key, value] of Object.entries(delta.docs.set ?? {})) {
|
|
110
|
+
merged[key] = value;
|
|
111
|
+
}
|
|
112
|
+
docs = merged;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...view,
|
|
116
|
+
entries,
|
|
117
|
+
docs,
|
|
118
|
+
conversation: delta.conversation ?? view.conversation,
|
|
119
|
+
steer: delta.steer ?? view.steer,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** True when every slot is absent or empty (the server skips these frames). */
|
|
123
|
+
export function isEmptyDelta(delta) {
|
|
124
|
+
const entries = delta.entries;
|
|
125
|
+
if (entries !== undefined &&
|
|
126
|
+
((entries.append?.length ?? 0) > 0 || (entries.remove?.length ?? 0) > 0)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const docs = delta.docs;
|
|
130
|
+
if (docs !== undefined &&
|
|
131
|
+
(Object.keys(docs.set ?? {}).length > 0 || (docs.unset?.length ?? 0) > 0)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return delta.conversation === undefined && delta.steer === undefined;
|
|
135
|
+
}
|