pi-roundtable 0.22.5 → 0.23.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 (129) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -0,0 +1,68 @@
1
+ /* Generated from src/characters/css.ts by scripts/build-character-css.ts; do not edit. */
2
+ /* State motion for composed characters. Put data-state on the <svg class="av"> or on an .av-host around it. */
3
+ .av{overflow:visible}
4
+ .av .ov{opacity:0}
5
+ .av .glow{opacity:.28}
6
+ .av .ch,.av .prop,.av .eyes,.av .ov{transform-box:view-box}
7
+ .av .ch{transform-origin:50px 94px}
8
+ .av .eyes{transform-origin:50px 52px;animation:av-blink 5.2s infinite}
9
+ .av .o-wait,.av .o-done,.av .o-err{transform-origin:17px 19px}
10
+ .av .o-think{transform-origin:50px 50px}
11
+ .av [data-prop=staff]{transform-origin:73px 82px}
12
+ .av [data-prop=shield]{transform-origin:27px 82px}
13
+ .av [data-prop=scroll]{transform-origin:76px 76px}
14
+ .av [data-prop=hammer]{transform-origin:79px 84px}
15
+ .av [data-prop=bow]{transform-origin:78px 70px}
16
+ .av [data-prop=lute]{transform-origin:75px 70px}
17
+ .av [data-prop=flask]{transform-origin:73px 66px}
18
+ .av [data-prop=quill]{transform-origin:72px 82px}
19
+ .av [data-prop=tome]{transform-origin:78px 76px}
20
+ .av [data-prop=bell]{transform-origin:76px 62px}
21
+ .av [data-prop=keys]{transform-origin:78px 62px}
22
+ .av [data-prop=map]{transform-origin:77px 58px}
23
+ .av [data-prop=telescope]{transform-origin:76px 74px}
24
+ .av [data-prop=wrench]{transform-origin:78px 86px}
25
+ .av [data-prop=spade]{transform-origin:76px 60px}
26
+ .av [data-prop=ladle]{transform-origin:78px 78px}
27
+ .av [data-prop=lantern]{transform-origin:76px 62px}
28
+ .av [data-prop=seal]{transform-origin:76px 66px}
29
+ .av [data-prop=dagger]{transform-origin:78px 86px}
30
+ .av [data-prop=beads]{transform-origin:78px 62px}
31
+ .av [data-prop=bird]{transform-origin:78px 76px}
32
+ .av [data-prop=anchor]{transform-origin:76px 76px}
33
+ .av [data-prop=marotte]{transform-origin:76px 62px}
34
+ .av [data-prop=sword]{transform-origin:76px 82px}
35
+ :is(.av,.av-host)[data-state=idle] .ch{animation:av-breathe 3.8s ease-in-out infinite}
36
+ :is(.av,.av-host)[data-state=thinking] .ch{animation:av-tilt 3.2s ease-in-out infinite}
37
+ :is(.av,.av-host)[data-state=thinking] .o-think{opacity:1;animation:av-orbit 2.6s linear infinite}
38
+ :is(.av,.av-host)[data-state=working] .ch{animation:av-bob .56s ease-in-out infinite}
39
+ :is(.av,.av-host)[data-state=working] .prop{animation:av-sweep .56s ease-in-out infinite alternate}
40
+ :is(.av,.av-host)[data-state=working] .glow{animation:av-glow .56s ease-in-out infinite alternate}
41
+ :is(.av,.av-host)[data-state=waiting] .ch{animation:av-lean 1.6s ease-in-out infinite}
42
+ :is(.av,.av-host)[data-state=waiting] .o-wait{opacity:1;animation:av-badge 1.2s ease-in-out infinite}
43
+ :is(.av,.av-host)[data-state=waiting] .glow{animation:av-glow 1.2s ease-in-out infinite alternate}
44
+ :is(.av,.av-host)[data-state=done] .ch{animation:av-pop .7s cubic-bezier(.3,1.6,.5,1) 1}
45
+ :is(.av,.av-host)[data-state=done] .o-done{opacity:1;animation:av-badge-in .5s ease-out 1}
46
+ :is(.av,.av-host)[data-state=done] .glow{opacity:.7}
47
+ :is(.av,.av-host)[data-state=error] .ch{animation:av-shake .5s ease-in-out 2}
48
+ :is(.av,.av-host)[data-state=error] .o-err{opacity:1;animation:av-badge-in .3s ease-out 1}
49
+ :is(.av,.av-host)[data-state=error] .ring{stroke:#e5484d}
50
+ :is(.av,.av-host)[data-state=error] .glow{opacity:0}
51
+ :is(.av,.av-host)[data-state=sleepy] .ch{animation:av-breathe 6s ease-in-out infinite}
52
+ :is(.av,.av-host)[data-state=sleepy] .o-zz{opacity:1;animation:av-float 3s ease-in-out infinite}
53
+ :is(.av,.av-host)[data-state=sleepy] .glow{opacity:.08}
54
+ @keyframes av-blink{0%,93%,100%{transform:scaleY(1)}96%{transform:scaleY(.08)}}
55
+ @keyframes av-breathe{0%,100%{transform:scale(1,1)}50%{transform:scale(1.012,1.03)}}
56
+ @keyframes av-tilt{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}}
57
+ @keyframes av-orbit{to{transform:rotate(360deg)}}
58
+ @keyframes av-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.4px)}}
59
+ @keyframes av-sweep{from{transform:rotate(-9deg)}to{transform:rotate(9deg)}}
60
+ @keyframes av-glow{from{opacity:.2}to{opacity:.85}}
61
+ @keyframes av-lean{0%,100%{transform:rotate(-1deg) translateY(0)}50%{transform:rotate(-6deg) translateY(1px)}}
62
+ @keyframes av-badge{0%,100%{transform:scale(1)}50%{transform:scale(1.14)}}
63
+ @keyframes av-badge-in{from{transform:scale(.2);opacity:0}to{transform:scale(1);opacity:1}}
64
+ @keyframes av-pop{0%{transform:scale(1)}40%{transform:scale(1.1,.94)}70%{transform:scale(.97,1.05)}100%{transform:scale(1)}}
65
+ @keyframes av-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-2.4px)}40%{transform:translateX(2.4px)}60%{transform:translateX(-1.6px)}80%{transform:translateX(1.6px)}}
66
+ @keyframes av-float{0%,100%{transform:translateY(0);opacity:.35}50%{transform:translateY(-4px);opacity:1}}
67
+ @media (prefers-reduced-motion:reduce){.av *{animation:none!important}.av .o-think{opacity:0!important}}
68
+ .av.static *{animation:none!important}
@@ -0,0 +1,108 @@
1
+ import type { CharacterLook } from "./compose.ts";
2
+ import type { Hex } from "./spec.ts";
3
+
4
+ type Rgb = readonly [number, number, number];
5
+
6
+ const channel = (value: number): string =>
7
+ Math.max(0, Math.min(255, Math.round(value)))
8
+ .toString(16)
9
+ .padStart(2, "0");
10
+
11
+ function rgbOf(color: string): Rgb {
12
+ const part = (at: number) => Number.parseInt(color.slice(at, at + 2), 16);
13
+ return [part(1), part(3), part(5)];
14
+ }
15
+
16
+ function hexOf([r, g, b]: Rgb): Hex {
17
+ return `#${channel(r)}${channel(g)}${channel(b)}`;
18
+ }
19
+
20
+ /** The point `t` of the way from `a` to `b`, channel by channel. */
21
+ export function mix(a: string, b: string, t: number): Hex {
22
+ const from = rgbOf(a);
23
+ const to = rgbOf(b);
24
+ return hexOf([
25
+ from[0] + (to[0] - from[0]) * t,
26
+ from[1] + (to[1] - from[1]) * t,
27
+ from[2] + (to[2] - from[2]) * t,
28
+ ]);
29
+ }
30
+
31
+ export const lighten = (color: string, t: number): Hex =>
32
+ mix(color, "#ffffff", t);
33
+ export const darken = (color: string, t: number): Hex =>
34
+ mix(color, "#1a1330", t);
35
+
36
+ function luminance(color: string): number {
37
+ const [r, g, b] = rgbOf(color).map((value) => {
38
+ const unit = value / 255;
39
+ return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
40
+ }) as [number, number, number];
41
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
42
+ }
43
+
44
+ /** The WCAG contrast ratio of two colors, 1 to 21. */
45
+ export function contrast(a: string, b: string): number {
46
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [
47
+ number,
48
+ number,
49
+ ];
50
+ return (light + 0.05) / (dark + 0.05);
51
+ }
52
+
53
+ /**
54
+ * The surface the accent sits on, per scheme and per look: the app's page background, and for
55
+ * Camelot the hardest of its surfaces (the darkest parchment, the lightest night blue), so a
56
+ * shade that reads on that one reads on the rest.
57
+ */
58
+ const SURFACE = {
59
+ light: "#ffffff",
60
+ dark: "#0d1018",
61
+ "modern-light": "#ffffff",
62
+ "modern-dark": "#0d1018",
63
+ "camelot-light": "#e3d2a9",
64
+ "camelot-dark": "#243557",
65
+ } as const;
66
+
67
+ /**
68
+ * `primary` itself when it reads at 3:1 on the surface, else the first shade toward the opposite
69
+ * end (darker on a light surface, lighter on a dark one) that does.
70
+ */
71
+ export function accentColor(
72
+ primary: Hex,
73
+ on: "light" | "dark" | CharacterLook,
74
+ ): Hex {
75
+ const surface = SURFACE[on];
76
+ const toward = on.endsWith("light") ? "#1a1330" : "#ffffff";
77
+ for (let step = 0; step <= 20; step++) {
78
+ const shade = step === 0 ? primary : mix(primary, toward, step / 20);
79
+ if (contrast(shade, surface) >= 3) return shade;
80
+ }
81
+ return toward as Hex;
82
+ }
83
+
84
+ /** Every color a part may use, as literals: nothing in the output refers to a variable. */
85
+ export interface Tones {
86
+ /** Primary and its shades. */
87
+ p: string;
88
+ pd: string;
89
+ pl: string;
90
+ pll: string;
91
+ /** Secondary tincture. */
92
+ s: string;
93
+ sd: string;
94
+ /** Metal trim. */
95
+ t: string;
96
+ td: string;
97
+ /** Skin. */
98
+ k: string;
99
+ kd: string;
100
+ /** Hair. */
101
+ hr: string;
102
+ hd: string;
103
+ ink: string;
104
+ /** Prop glow. */
105
+ gl: string;
106
+ }
107
+
108
+ export type Part = (tones: Tones) => string;
@@ -0,0 +1,281 @@
1
+ import {
2
+ AVATAR_STATES,
3
+ type AvatarState,
4
+ CATALOG,
5
+ EXPRESSIONS,
6
+ type Expression,
7
+ STATE_EXPRESSION,
8
+ } from "./catalog.ts";
9
+ import { accentColor, darken, lighten, mix, type Tones } from "./colors.ts";
10
+ import { parseCharacter } from "./parse.ts";
11
+ import { faceParts, INK, overlays } from "./parts-face.ts";
12
+ import { ACCESSORIES, BEARDS, HAIR } from "./parts-hair.ts";
13
+ import { HATS } from "./parts-hats.ts";
14
+ import { OUTFITS } from "./parts-outfits.ts";
15
+ import { PROPS } from "./parts-props.ts";
16
+ import {
17
+ CharacterError,
18
+ type CharacterSpec,
19
+ checkOneOf,
20
+ type Hex,
21
+ } from "./spec.ts";
22
+
23
+ export type CharacterLook =
24
+ | "modern-light"
25
+ | "modern-dark"
26
+ | "camelot-light"
27
+ | "camelot-dark";
28
+
29
+ export interface ComposeOptions {
30
+ /** Default `STATE_EXPRESSION[state ?? "idle"]`. */
31
+ expr?: Expression;
32
+ /** With `still`: which overlay is baked in. Live output carries every overlay. */
33
+ state?: AvatarState;
34
+ /** Background and ring only. Default `"modern-light"`. */
35
+ look?: CharacterLook;
36
+ /** Default `"disc"`. */
37
+ frame?: "disc" | "shield";
38
+ /** No frame and no background. */
39
+ bare?: boolean;
40
+ /** For files, PNG and 3D: only the active state's overlay, and no motion needed. */
41
+ still?: boolean;
42
+ /** `/^[a-z0-9]{1,16}$/`, the suffix of the clip path ids. Default `"a"`. */
43
+ uid?: string;
44
+ /** Adds `width` and `height` (the PNG rasterizer needs them). */
45
+ size?: number;
46
+ /** The accessible name, escaped into `aria-label`; without it the figure is `aria-hidden`. */
47
+ label?: string;
48
+ }
49
+
50
+ const LOOKS = ["modern-light", "modern-dark", "camelot-light", "camelot-dark"];
51
+ const FRAMES = ["disc", "shield"] as const;
52
+ const UID = /^[a-z0-9]{1,16}$/;
53
+ const MAX_LABEL = 200;
54
+ const MAX_SIZE = 4096;
55
+
56
+ interface LookStyle {
57
+ bg: (primary: string) => string;
58
+ ring: (primary: string) => string;
59
+ width: number;
60
+ /** The thin inner line of the Camelot frame. */
61
+ line: string | null;
62
+ }
63
+
64
+ const STYLES: Record<CharacterLook, LookStyle> = {
65
+ "modern-light": {
66
+ bg: (p) => lighten(p, 0.8),
67
+ ring: (p) => p,
68
+ width: 3,
69
+ line: null,
70
+ },
71
+ "modern-dark": {
72
+ bg: (p) => mix(p, "#0d1018", 0.68),
73
+ ring: (p) => lighten(p, 0.15),
74
+ width: 3,
75
+ line: null,
76
+ },
77
+ "camelot-light": {
78
+ bg: (p) => mix("#f2e6c6", p, 0.1),
79
+ ring: () => "#b98a2c",
80
+ width: 4.2,
81
+ line: "#8a5f12",
82
+ },
83
+ "camelot-dark": {
84
+ bg: (p) => mix("#16224c", p, 0.16),
85
+ ring: () => "#e6b94c",
86
+ width: 4.2,
87
+ line: "#ffe08a",
88
+ },
89
+ };
90
+
91
+ const SHIELD = "M12 14H88V50Q88 78 50 97Q12 78 12 50Z";
92
+ const ERROR_RED = "#e5484d";
93
+
94
+ /**
95
+ * Text safe inside a double-quoted attribute and inert as markup or script: the characters that
96
+ * open a tag, end a value or start a call become entities, which also leaves no `on…=`, `style=`
97
+ * or `var(` in the output, and `href` is broken by one more entity.
98
+ */
99
+ function escapeAttribute(text: string): string {
100
+ return text
101
+ .replace(/[&<>"'=()]/g, (char) => `&#${char.charCodeAt(0)};`)
102
+ .replace(
103
+ /href/gi,
104
+ (word) => `${word[0]}&#${word.charCodeAt(1)};${word.slice(2)}`,
105
+ );
106
+ }
107
+
108
+ function tonesOf(spec: CharacterSpec, look: LookStyle, error: boolean) {
109
+ const { primary, secondary, trim } = spec.colors;
110
+ const skin = CATALOG.skins[spec.skin] as Hex;
111
+ const tones: Tones = {
112
+ p: primary,
113
+ pd: darken(primary, 0.3),
114
+ pl: lighten(primary, 0.35),
115
+ pll: lighten(primary, 0.65),
116
+ s: secondary,
117
+ sd: darken(secondary, 0.16),
118
+ t: trim,
119
+ td: darken(trim, 0.3),
120
+ k: skin,
121
+ kd: darken(skin, 0.12),
122
+ hr: spec.hairColor,
123
+ hd: darken(spec.hairColor, 0.2),
124
+ ink: INK,
125
+ gl: lighten(primary, 0.5),
126
+ };
127
+ return {
128
+ tones,
129
+ bg: look.bg(primary),
130
+ ring: error ? ERROR_RED : look.ring(primary),
131
+ };
132
+ }
133
+
134
+ function group(name: string, inner: string, extra = ""): string {
135
+ return inner
136
+ ? `<g class="${name}" data-part="${name}"${extra}>${inner}</g>`
137
+ : "";
138
+ }
139
+
140
+ /**
141
+ * The figure as an SVG string with literal colors: no style attribute, variable, script or
142
+ * link, so it is safe to inject into a page that forbids inline styles and renders the same
143
+ * through canvas or a 3D texture. The spec is checked again here, so a spec cast from untrusted
144
+ * data cannot reach the markup. State motion is in `characters.css`.
145
+ */
146
+ export function composeSvg(
147
+ spec: CharacterSpec,
148
+ options: ComposeOptions = {},
149
+ ): string {
150
+ const s = parseCharacter(spec);
151
+ const state =
152
+ options.state === undefined
153
+ ? undefined
154
+ : checkOneOf(AVATAR_STATES, options.state, "state");
155
+ const expr = checkOneOf(
156
+ EXPRESSIONS,
157
+ options.expr ?? STATE_EXPRESSION[state ?? "idle"],
158
+ "expr",
159
+ );
160
+ const lookName = checkOneOf(
161
+ LOOKS,
162
+ options.look ?? "modern-light",
163
+ "look",
164
+ ) as CharacterLook;
165
+ const frame = checkOneOf(FRAMES, options.frame ?? "disc", "frame");
166
+ const uid = options.uid ?? "a";
167
+ if (typeof uid !== "string" || !UID.test(uid))
168
+ throw new CharacterError("uid", "must match /^[a-z0-9]{1,16}$/");
169
+ const { size, label } = options;
170
+ if (
171
+ size !== undefined &&
172
+ !(Number.isInteger(size) && size >= 1 && size <= MAX_SIZE)
173
+ )
174
+ throw new CharacterError("size", `must be an integer up to ${MAX_SIZE}`);
175
+ if (
176
+ label !== undefined &&
177
+ (typeof label !== "string" || label.length > MAX_LABEL)
178
+ )
179
+ throw new CharacterError(
180
+ "label",
181
+ `must be text of at most ${MAX_LABEL} characters`,
182
+ );
183
+ const bare = options.bare === true;
184
+ const still = options.still === true;
185
+
186
+ const style = STYLES[lookName];
187
+ const { tones, bg, ring } = tonesOf(s, style, state === "error");
188
+ const face = faceParts(
189
+ { ink: INK, brow: mix(s.hairColor, INK, 0.5), dot: mix(tones.p, INK, 0.1) },
190
+ expr,
191
+ );
192
+
193
+ const hair = HAIR[s.hair](tones);
194
+ const hood =
195
+ s.hat === "hood"
196
+ ? `<path d="M17 62C12 22 88 22 83 62C81 72 72 77 66 78H34C28 77 19 72 17 62Z" fill="${tones.pd}"/>`
197
+ : "";
198
+ const outfit = OUTFITS[s.outfit](tones);
199
+ const factor = { slim: 0.8, regular: 1, stout: 1.3 }[s.build];
200
+ const dressed =
201
+ factor === 1
202
+ ? outfit
203
+ : `<g transform="translate(${+(50 - 50 * factor).toFixed(2)} 0) scale(${factor} 1)">${outfit}</g>`;
204
+ let body = dressed;
205
+ if (!bare)
206
+ body =
207
+ frame === "shield"
208
+ ? `<g clip-path="url(#as${uid})">${dressed}</g>`
209
+ : `<clipPath id="ad${uid}"><circle cx="50" cy="50" r="44.6"/></clipPath><g clip-path="url(#ad${uid})">${dressed}</g>`;
210
+
211
+ const line = style.line;
212
+ const frameMarkup =
213
+ frame === "shield"
214
+ ? `<clipPath id="as${uid}"><path d="${SHIELD}"/></clipPath><path d="${SHIELD}" fill="${bg}" stroke="${ring}" stroke-width="${style.width}" stroke-linejoin="round" class="ring"/>${
215
+ line
216
+ ? `<path d="M16.5 18.5H83.5V50Q83.5 74 50 91.6Q16.5 74 16.5 50Z" fill="none" stroke="${line}" stroke-width=".9" opacity=".55"/>`
217
+ : ""
218
+ }`
219
+ : `<circle cx="50" cy="50" r="46.4" fill="${bg}" stroke="${ring}" stroke-width="${style.width}" class="ring"/>${
220
+ line
221
+ ? `<circle cx="50" cy="50" r="40.6" fill="none" stroke="${line}" stroke-width=".9" opacity=".55"/>`
222
+ : ""
223
+ }<ellipse cx="50" cy="93" rx="19" ry="2.6" fill="#000" opacity=".12"/>`;
224
+
225
+ const head =
226
+ `<circle cx="22.6" cy="54" r="4.2" fill="${tones.k}"/><circle cx="77.4" cy="54" r="4.2" fill="${tones.k}"/>` +
227
+ `<ellipse cx="50" cy="51" rx="28" ry="26" fill="${tones.k}"/><path d="M23 58Q50 88 77 58Q77 76 50 77Q23 76 23 58Z" fill="${tones.kd}" opacity=".3"/>` +
228
+ `<ellipse cx="50" cy="58.4" rx="1.7" ry="1.2" fill="${tones.kd}"/>` +
229
+ hair.front +
230
+ (s.beard === "none" ? "" : BEARDS[s.beard](tones));
231
+
232
+ const inner =
233
+ (bare ? "" : group("frame", frameMarkup)) +
234
+ `<g class="ch">` +
235
+ group("back", hair.back + hood) +
236
+ group("body", body) +
237
+ group("head", head) +
238
+ group("hat", HATS[s.hat](tones)) +
239
+ group("eyes-brows", face.eyes + face.brows) +
240
+ group("mouth", face.mouth) +
241
+ group("fx", face.fx) +
242
+ group(
243
+ "acc",
244
+ s.accessory === "none" ? "" : ACCESSORIES[s.accessory](tones),
245
+ ) +
246
+ group("prop", PROPS[s.prop](tones), ` data-prop="${s.prop}"`) +
247
+ `</g>` +
248
+ group(
249
+ "state",
250
+ overlays(tones.p, darken(tones.p, 0.1), still ? (state ?? "idle") : null),
251
+ );
252
+
253
+ const attributes = [
254
+ `xmlns="http://www.w3.org/2000/svg"`,
255
+ `viewBox="0 0 100 100"`,
256
+ size === undefined ? "" : `width="${size}" height="${size}"`,
257
+ `class="${still ? "av static" : "av"}"`,
258
+ state === undefined && !still ? "" : `data-state="${state ?? "idle"}"`,
259
+ `data-expr="${expr}"`,
260
+ label === undefined
261
+ ? `aria-hidden="true"`
262
+ : `role="img" aria-label="${escapeAttribute(label)}"`,
263
+ ]
264
+ .filter(Boolean)
265
+ .join(" ");
266
+ return `<svg ${attributes}>${inner}</svg>`.replace(
267
+ /class="glow"/g,
268
+ `class="glow" opacity=".3"`,
269
+ );
270
+ }
271
+
272
+ /**
273
+ * The agent's accent as a UI color: `colors.primary`, shaded until it reads at 3:1 on the page. A
274
+ * scheme means the modern page; a look (`camelot-light`) means that look's hardest surface.
275
+ */
276
+ export function accentOf(
277
+ spec: CharacterSpec,
278
+ on: "light" | "dark" | CharacterLook,
279
+ ): Hex {
280
+ return accentColor(spec.colors.primary, on);
281
+ }
@@ -0,0 +1,63 @@
1
+ import { PIVOTS } from "./parts-face.ts";
2
+ import { PROP_PIVOTS } from "./parts-props.ts";
3
+
4
+ /** A state, named on the figure itself or on the `.av-host` around it. */
5
+ const on = (state: string) => `:is(.av,.av-host)[data-state=${state}]`;
6
+
7
+ /**
8
+ * The stylesheet that moves composed characters: state motion on transform and opacity only,
9
+ * with the pivots the composer leaves out of the markup, because the page forbids inline
10
+ * styles. The static file `characters.css` is this text, generated by
11
+ * `scripts/build-character-css.ts`.
12
+ */
13
+ export function characterCss(): string {
14
+ const pivots = Object.entries(PROP_PIVOTS)
15
+ .map(([id, origin]) => `.av [data-prop=${id}]{transform-origin:${origin}}`)
16
+ .join("\n");
17
+ return `/* Generated from src/characters/css.ts by scripts/build-character-css.ts; do not edit. */
18
+ /* State motion for composed characters. Put data-state on the <svg class="av"> or on an .av-host around it. */
19
+ .av{overflow:visible}
20
+ .av .ov{opacity:0}
21
+ .av .glow{opacity:.28}
22
+ .av .ch,.av .prop,.av .eyes,.av .ov{transform-box:view-box}
23
+ .av .ch{transform-origin:${PIVOTS.character}}
24
+ .av .eyes{transform-origin:${PIVOTS.eyes};animation:av-blink 5.2s infinite}
25
+ .av .o-wait,.av .o-done,.av .o-err{transform-origin:${PIVOTS.badge}}
26
+ .av .o-think{transform-origin:${PIVOTS.orbit}}
27
+ ${pivots}
28
+ ${on("idle")} .ch{animation:av-breathe 3.8s ease-in-out infinite}
29
+ ${on("thinking")} .ch{animation:av-tilt 3.2s ease-in-out infinite}
30
+ ${on("thinking")} .o-think{opacity:1;animation:av-orbit 2.6s linear infinite}
31
+ ${on("working")} .ch{animation:av-bob .56s ease-in-out infinite}
32
+ ${on("working")} .prop{animation:av-sweep .56s ease-in-out infinite alternate}
33
+ ${on("working")} .glow{animation:av-glow .56s ease-in-out infinite alternate}
34
+ ${on("waiting")} .ch{animation:av-lean 1.6s ease-in-out infinite}
35
+ ${on("waiting")} .o-wait{opacity:1;animation:av-badge 1.2s ease-in-out infinite}
36
+ ${on("waiting")} .glow{animation:av-glow 1.2s ease-in-out infinite alternate}
37
+ ${on("done")} .ch{animation:av-pop .7s cubic-bezier(.3,1.6,.5,1) 1}
38
+ ${on("done")} .o-done{opacity:1;animation:av-badge-in .5s ease-out 1}
39
+ ${on("done")} .glow{opacity:.7}
40
+ ${on("error")} .ch{animation:av-shake .5s ease-in-out 2}
41
+ ${on("error")} .o-err{opacity:1;animation:av-badge-in .3s ease-out 1}
42
+ ${on("error")} .ring{stroke:#e5484d}
43
+ ${on("error")} .glow{opacity:0}
44
+ ${on("sleepy")} .ch{animation:av-breathe 6s ease-in-out infinite}
45
+ ${on("sleepy")} .o-zz{opacity:1;animation:av-float 3s ease-in-out infinite}
46
+ ${on("sleepy")} .glow{opacity:.08}
47
+ @keyframes av-blink{0%,93%,100%{transform:scaleY(1)}96%{transform:scaleY(.08)}}
48
+ @keyframes av-breathe{0%,100%{transform:scale(1,1)}50%{transform:scale(1.012,1.03)}}
49
+ @keyframes av-tilt{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}}
50
+ @keyframes av-orbit{to{transform:rotate(360deg)}}
51
+ @keyframes av-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.4px)}}
52
+ @keyframes av-sweep{from{transform:rotate(-9deg)}to{transform:rotate(9deg)}}
53
+ @keyframes av-glow{from{opacity:.2}to{opacity:.85}}
54
+ @keyframes av-lean{0%,100%{transform:rotate(-1deg) translateY(0)}50%{transform:rotate(-6deg) translateY(1px)}}
55
+ @keyframes av-badge{0%,100%{transform:scale(1)}50%{transform:scale(1.14)}}
56
+ @keyframes av-badge-in{from{transform:scale(.2);opacity:0}to{transform:scale(1);opacity:1}}
57
+ @keyframes av-pop{0%{transform:scale(1)}40%{transform:scale(1.1,.94)}70%{transform:scale(.97,1.05)}100%{transform:scale(1)}}
58
+ @keyframes av-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-2.4px)}40%{transform:translateX(2.4px)}60%{transform:translateX(-1.6px)}80%{transform:translateX(1.6px)}}
59
+ @keyframes av-float{0%,100%{transform:translateY(0);opacity:.35}50%{transform:translateY(-4px);opacity:1}}
60
+ @media (prefers-reduced-motion:reduce){.av *{animation:none!important}.av .o-think{opacity:0!important}}
61
+ .av.static *{animation:none!important}
62
+ `;
63
+ }
@@ -0,0 +1,33 @@
1
+ // Composed characters: a part library and an SVG composer that run in the browser and on the server.
2
+
3
+ export type {
4
+ AccessoryId,
5
+ AvatarState,
6
+ BeardId,
7
+ ClassId,
8
+ Expression,
9
+ HairId,
10
+ HatId,
11
+ OutfitId,
12
+ PropId,
13
+ } from "./catalog.ts";
14
+ export {
15
+ AVATAR_STATES,
16
+ CATALOG,
17
+ CLASS_IDS,
18
+ EXPRESSIONS,
19
+ STATE_EXPRESSION,
20
+ } from "./catalog.ts";
21
+ export type { CharacterLook, ComposeOptions } from "./compose.ts";
22
+ export { accentOf, composeSvg } from "./compose.ts";
23
+ export { characterCss } from "./css.ts";
24
+ export { parseCharacter, readCharacter } from "./parse.ts";
25
+ export type {
26
+ CharacterColors,
27
+ CharacterInput,
28
+ CharacterSpec,
29
+ Hex,
30
+ PaletteName,
31
+ } from "./spec.ts";
32
+ export { CharacterError, resolveCharacter } from "./spec.ts";
33
+ export { suggestCharacter } from "./suggest.ts";