@vellumai/assistant 0.11.9-staging.2 → 0.11.9-staging.3

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 (34) hide show
  1. package/Dockerfile +9 -0
  2. package/docker-bun-no-autoserve.js +38 -0
  3. package/docker-node-launcher.sh +37 -0
  4. package/node_modules/@vellumai/avatar-catalog/src/colors.ts +8 -0
  5. package/node_modules/@vellumai/avatar-manifest/package.json +2 -1
  6. package/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
  7. package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
  8. package/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
  9. package/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
  10. package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
  11. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  12. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  17. package/openapi.yaml +116 -1
  18. package/package.json +1 -1
  19. package/src/__tests__/bun-no-autoserve.test.ts +178 -0
  20. package/src/avatar/__tests__/avatar-manifest.test.ts +10 -0
  21. package/src/avatar/__tests__/avatar-store.test.ts +130 -8
  22. package/src/avatar/__tests__/ensure-raster.test.ts +15 -0
  23. package/src/avatar/avatar-accent.ts +57 -0
  24. package/src/avatar/avatar-manifest.ts +20 -2
  25. package/src/avatar/avatar-store.ts +111 -4
  26. package/src/config/bundled-skills/media-processing/SKILL.md +3 -3
  27. package/src/config/bundled-skills/media-processing/TOOLS.json +1 -1
  28. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  29. package/src/platform/sync-avatar.test.ts +3 -0
  30. package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +147 -27
  31. package/src/runtime/routes/avatar-routes.ts +84 -28
  32. package/src/runtime/routes/settings-routes.ts +1 -1
  33. package/src/tools/terminal/__tests__/safe-env.test.ts +60 -1
  34. package/src/tools/terminal/safe-env.ts +70 -1
package/Dockerfile CHANGED
@@ -143,6 +143,14 @@ COPY plugins/marketplace.json /app/assistant/src/cli/lib/bundled-marketplace.jso
143
143
  RUN printf '#!/usr/bin/env sh\nexec bun run /app/assistant/src/index.ts "$@"\n' > /usr/local/bin/assistant && \
144
144
  chmod +x /usr/local/bin/assistant
145
145
 
146
+ # The image ships no Node, so `bunx <pkg>` follows a bin's `#!/usr/bin/env node`
147
+ # shebang into this launcher. It hands off to a real Node when one is installed
148
+ # later and otherwise runs the bin under Bun with the auto-serve shim preloaded,
149
+ # so a CLI whose bundle default-exports an object with `fetch` exits instead of
150
+ # being turned into an HTTP server by `bun:main`. Installing it here also stops
151
+ # Bun synthesizing its own `node` shim in /tmp/bun-node-<hash> at daemon launch.
152
+ RUN ln -s /app/assistant/docker-node-launcher.sh /usr/local/bin/node
153
+
146
154
  # Create non-root user that also has sudo access so it can like install stuff
147
155
  RUN groupadd --system --gid 1001 assistant && \
148
156
  useradd --system --uid 1001 --gid assistant --create-home --shell /bin/bash assistant && \
@@ -237,6 +245,7 @@ RUN chmod +x \
237
245
  /app/assistant/docker-kata-chroot-exec.sh \
238
246
  /app/assistant/docker-kata-pip.sh \
239
247
  /app/assistant/docker-kata-runtime-family.sh \
248
+ /app/assistant/docker-node-launcher.sh \
240
249
  /usr/local/bin/vellum-block-volume-common.sh \
241
250
  /usr/local/bin/vellum-block-volume-init.sh \
242
251
  /usr/local/bin/vellum-block-volume-mount.sh \
@@ -0,0 +1,38 @@
1
+ // Neuters Bun's implicit auto-serve of an entrypoint module whose default
2
+ // export carries a `fetch` method: `bun:main` calls `Bun.serve()` on that
3
+ // export, and the process then stays alive forever waiting on the socket.
4
+ //
5
+ // The assistant image ships no Node, so `bunx <pkg>` follows a bin's
6
+ // `#!/usr/bin/env node` shebang into the `node` launcher the Dockerfile
7
+ // installs, which is Bun with this file preloaded. Without the shim, any
8
+ // third-party CLI whose bundle ends in something like `export { cli as
9
+ // default }` prints its answer and then hangs until the bash tool times out.
10
+ //
11
+ // Only the implicit call is suppressed. Bun's auto-serve frame is `bun:main`,
12
+ // so an explicit `Bun.serve()` from user code (whose immediate caller frame is
13
+ // that user code) passes straight through to the real implementation.
14
+ const realServe = Bun.serve;
15
+
16
+ Bun.serve = function (options) {
17
+ const caller = ((new Error().stack || "").split("\n")[2] || "").trim();
18
+ if (!caller.startsWith("at bun:main")) {
19
+ return realServe.call(Bun, options);
20
+ }
21
+ // Bun announces the auto-started server on the next console.debug call.
22
+ const realDebug = console.debug;
23
+ console.debug = (...args) => {
24
+ console.debug = realDebug;
25
+ if (!String(args[0]).startsWith("Started ")) {
26
+ realDebug(...args);
27
+ }
28
+ };
29
+ return {
30
+ stop() {},
31
+ reload() {},
32
+ port: 0,
33
+ hostname: "localhost",
34
+ protocol: "http",
35
+ development: false,
36
+ url: new URL("http://localhost:0"),
37
+ };
38
+ };
@@ -0,0 +1,37 @@
1
+ #!/bin/sh
2
+ # Installed as /usr/local/bin/node. The image ships no Node, so `bunx <pkg>`
3
+ # follows a bin's `#!/usr/bin/env node` shebang here.
4
+ #
5
+ # A real Node interpreter always wins. One can appear after the image is built:
6
+ # `apt-get install nodejs` writes /usr/bin/node, and on Kata-family runtimes the
7
+ # persistent apt chroot puts it under $VELLUM_APT_DATA_ROOT/usr/bin, which the
8
+ # sandbox PATH carries. Delegating keeps Node CLIs on Node semantics.
9
+ #
10
+ # With no real Node on PATH the bin runs under Bun with the auto-serve shim
11
+ # preloaded, so a CLI whose default export carries a `fetch` method exits
12
+ # instead of being turned into an HTTP server by `bun:main`.
13
+ #
14
+ # Bun's own synthesized shim dirs (<temp>/bun-node-<hash>/node, symlinks to
15
+ # bun) are skipped: that `node` is Bun without the preload, which is the hang
16
+ # this launcher exists to prevent.
17
+
18
+ launcher=/usr/local/bin/node
19
+ shim=/app/assistant/docker-bun-no-autoserve.js
20
+
21
+ IFS=:
22
+ for dir in $PATH; do
23
+ [ -n "$dir" ] || continue
24
+ candidate="$dir/node"
25
+ case "$candidate" in
26
+ "$launcher" | */bun-node-*/node) continue ;;
27
+ esac
28
+ [ -f "$candidate" ] && [ -x "$candidate" ] || continue
29
+ if [ -L "$candidate" ] && [ "$(readlink -f "$candidate")" = "$launcher" ]; then
30
+ continue
31
+ fi
32
+ unset IFS
33
+ exec "$candidate" "$@"
34
+ done
35
+ unset IFS
36
+
37
+ exec /usr/local/bin/bun --preload="$shim" "$@"
@@ -17,3 +17,11 @@ export const AVATAR_COLORS: ColorDefinition[] = [
17
17
  { id: "teal", hex: "#0E9B8B" },
18
18
  { id: "yellow", hex: "#E9C91A" },
19
19
  ];
20
+
21
+ /** The palette hex for a colour id, or null for an id the palette does not have. */
22
+ export function accentHexForColorId(colorId: string | null | undefined): string | null {
23
+ if (!colorId) {
24
+ return null;
25
+ }
26
+ return AVATAR_COLORS.find((c) => c.id === colorId)?.hex ?? null;
27
+ }
@@ -5,7 +5,8 @@
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
- ".": "./src/index.ts"
8
+ ".": "./src/index.ts",
9
+ "./accent": "./src/accent.ts"
9
10
  },
10
11
  "scripts": {
11
12
  "typecheck": "bunx tsc --noEmit",
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ dominantAccentHex,
5
+ isAvatarAccentHex,
6
+ normalizeAvatarAccentHex,
7
+ } from "../accent.js";
8
+
9
+ /** An RGBA block from `[r, g, b, a, count]` runs. */
10
+ function pixels(...runs: [number, number, number, number, number][]) {
11
+ const out: number[] = [];
12
+ for (const [r, g, b, a, count] of runs) {
13
+ for (let i = 0; i < count; i += 1) {
14
+ out.push(r, g, b, a);
15
+ }
16
+ }
17
+ return new Uint8ClampedArray(out);
18
+ }
19
+
20
+ function channels(hex: string): [number, number, number] {
21
+ const n = parseInt(hex.slice(1), 16);
22
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
23
+ }
24
+
25
+ function saturation(hex: string): number {
26
+ const ch = channels(hex).map((c) => c / 255);
27
+ const max = Math.max(...ch);
28
+ const min = Math.min(...ch);
29
+ const l = (max + min) / 2;
30
+ return max === min ? 0 : (max - min) / (1 - Math.abs(2 * l - 1));
31
+ }
32
+
33
+ describe("isAvatarAccentHex / normalizeAvatarAccentHex", () => {
34
+ test("accepts #rrggbb in either case and canonicalizes to lowercase", () => {
35
+ expect(isAvatarAccentHex("#E9642F")).toBe(true);
36
+ expect(normalizeAvatarAccentHex(" #E9642F ")).toBe("#e9642f");
37
+ });
38
+
39
+ test.each([
40
+ ["short form", "#abc"],
41
+ ["no hash", "e9642f"],
42
+ ["alpha channel", "#e9642fff"],
43
+ ["not a string", 0xe9642f],
44
+ ["empty", ""],
45
+ ])("rejects %s", (_label, value) => {
46
+ expect(isAvatarAccentHex(value)).toBe(false);
47
+ expect(normalizeAvatarAccentHex(value)).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe("dominantAccentHex", () => {
52
+ test("picks the coloured mark over the white ground it sits on", () => {
53
+ // Nine in ten pixels are white; the accent is still the red.
54
+ const hex = dominantAccentHex(
55
+ pixels([255, 255, 255, 255, 90], [200, 30, 30, 255, 10]),
56
+ );
57
+ expect(hex).toBe("#c81e1e");
58
+ });
59
+
60
+ test("picks the coloured mark over a black ground", () => {
61
+ expect(
62
+ dominantAccentHex(pixels([0, 0, 0, 255, 90], [40, 120, 200, 255, 10])),
63
+ ).toBe("#2878c8");
64
+ });
65
+
66
+ test("prefers the more prevalent of two accents", () => {
67
+ expect(
68
+ dominantAccentHex(
69
+ pixels([200, 30, 30, 255, 30], [40, 120, 200, 255, 50]),
70
+ ),
71
+ ).toBe("#2878c8");
72
+ });
73
+
74
+ test("averages a gradient of one hue rather than splitting it", () => {
75
+ // Three reds close together are one bin; a lone saturated blue is not
76
+ // more prevalent than the three of them together.
77
+ const hex = dominantAccentHex(
78
+ pixels(
79
+ [200, 30, 30, 255, 10],
80
+ [210, 40, 35, 255, 10],
81
+ [190, 25, 28, 255, 10],
82
+ [40, 120, 200, 255, 12],
83
+ ),
84
+ );
85
+ const [r, , b] = channels(hex!);
86
+ expect(r).toBeGreaterThan(b);
87
+ });
88
+
89
+ test("keeps a gray image gray instead of inventing a hue", () => {
90
+ const hex = dominantAccentHex(
91
+ pixels([120, 120, 120, 255, 80], [122, 121, 119, 255, 20]),
92
+ );
93
+ expect(saturation(hex!)).toBeLessThan(0.05);
94
+ });
95
+
96
+ test("a stray colourful pixel does not outrank a gray image", () => {
97
+ const hex = dominantAccentHex(
98
+ pixels([128, 128, 128, 255, 98], [255, 0, 0, 255, 2]),
99
+ );
100
+ expect(saturation(hex!)).toBeLessThan(0.05);
101
+ });
102
+
103
+ test("skips transparent pixels: a cutout's ground is not its colour", () => {
104
+ const cutout = dominantAccentHex(
105
+ pixels([0, 0, 0, 0, 90], [40, 120, 200, 255, 10]),
106
+ );
107
+ expect(cutout).toBe(dominantAccentHex(pixels([40, 120, 200, 255, 10])));
108
+ });
109
+
110
+ test("returns null when nothing is opaque enough to count", () => {
111
+ expect(dominantAccentHex(pixels([12, 34, 56, 0, 40]))).toBeNull();
112
+ expect(dominantAccentHex(new Uint8ClampedArray())).toBeNull();
113
+ });
114
+
115
+ test("returns a canonical lowercase #rrggbb", () => {
116
+ expect(dominantAccentHex(pixels([233, 100, 47, 255, 4]))).toBe("#e9642f");
117
+ });
118
+ });
@@ -16,7 +16,13 @@ describe("parseAvatarManifest", () => {
16
16
  test("accepts a character manifest", () => {
17
17
  expect(
18
18
  parseAvatarManifest({ kind: "character", traits, source: "builder" }),
19
- ).toEqual({ kind: "character", traits, source: "builder", image: null });
19
+ ).toEqual({
20
+ kind: "character",
21
+ traits,
22
+ source: "builder",
23
+ image: null,
24
+ accent: null,
25
+ });
20
26
  });
21
27
 
22
28
  test("accepts an image manifest", () => {
@@ -25,6 +31,58 @@ describe("parseAvatarManifest", () => {
25
31
  traits: null,
26
32
  source: null,
27
33
  image,
34
+ accent: null,
35
+ });
36
+ });
37
+
38
+ test("keeps a well-formed accent on a character or image manifest", () => {
39
+ const accent = { hex: "#e9642f", source: "custom" as const };
40
+ expect(parseAvatarManifest({ kind: "image", image, accent })).toEqual({
41
+ kind: "image",
42
+ traits: null,
43
+ source: null,
44
+ image,
45
+ accent,
46
+ });
47
+ expect(parseAvatarManifest({ kind: "character", traits, accent })).toEqual({
48
+ kind: "character",
49
+ traits,
50
+ source: null,
51
+ image: null,
52
+ accent,
53
+ });
54
+ });
55
+
56
+ test.each([
57
+ ["a short hex", { hex: "#abc", source: "derived" }],
58
+ ["an unknown source", { hex: "#e9642f", source: "guess" }],
59
+ ["a missing source", { hex: "#e9642f" }],
60
+ ["a non-object", "#e9642f"],
61
+ ])(
62
+ "normalizes %s accent to null without failing the manifest",
63
+ (_label, accent) => {
64
+ expect(parseAvatarManifest({ kind: "image", image, accent })).toEqual({
65
+ kind: "image",
66
+ traits: null,
67
+ source: null,
68
+ image,
69
+ accent: null,
70
+ });
71
+ },
72
+ );
73
+
74
+ test("drops an accent from a none manifest", () => {
75
+ expect(
76
+ parseAvatarManifest({
77
+ kind: "none",
78
+ accent: { hex: "#e9642f", source: "custom" },
79
+ }),
80
+ ).toEqual({
81
+ kind: "none",
82
+ traits: null,
83
+ source: null,
84
+ image: null,
85
+ accent: null,
28
86
  });
29
87
  });
30
88
 
@@ -34,6 +92,7 @@ describe("parseAvatarManifest", () => {
34
92
  traits: null,
35
93
  source: null,
36
94
  image: null,
95
+ accent: null,
37
96
  });
38
97
  });
39
98
 
@@ -61,27 +120,47 @@ describe("parseAvatarManifest", () => {
61
120
  test("normalizes an unknown source to null", () => {
62
121
  expect(
63
122
  parseAvatarManifest({ kind: "image", image, source: "unknown" }),
64
- ).toEqual({ kind: "image", traits: null, source: null, image });
123
+ ).toEqual({
124
+ kind: "image",
125
+ traits: null,
126
+ source: null,
127
+ image,
128
+ accent: null,
129
+ });
65
130
  expect(parseAvatarManifest({ kind: "none", source: 7 })).toEqual({
66
131
  kind: "none",
67
132
  traits: null,
68
133
  source: null,
69
134
  image: null,
135
+ accent: null,
70
136
  });
71
137
  });
72
138
 
73
139
  test("drops payload irrelevant to the kind", () => {
74
140
  expect(
75
141
  parseAvatarManifest({ kind: "image", image, traits: { bodyShape: 1 } }),
76
- ).toEqual({ kind: "image", traits: null, source: null, image });
142
+ ).toEqual({
143
+ kind: "image",
144
+ traits: null,
145
+ source: null,
146
+ image,
147
+ accent: null,
148
+ });
77
149
  expect(
78
150
  parseAvatarManifest({ kind: "character", traits, image: "stale" }),
79
- ).toEqual({ kind: "character", traits, source: null, image: null });
151
+ ).toEqual({
152
+ kind: "character",
153
+ traits,
154
+ source: null,
155
+ image: null,
156
+ accent: null,
157
+ });
80
158
  expect(parseAvatarManifest({ kind: "none", traits, image })).toEqual({
81
159
  kind: "none",
82
160
  traits: null,
83
161
  source: null,
84
162
  image: null,
163
+ accent: null,
85
164
  });
86
165
  });
87
166
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The avatar accent: the one colour every surface that tints itself to the
3
+ * assistant paints with.
4
+ *
5
+ * A character avatar's accent is its palette colour. An uploaded image has no
6
+ * colour as data, so one is read out of its pixels here. Pure pixel math with
7
+ * no decoder: the daemon feeds it a raster decoded by sharp and the web feeds
8
+ * it a canvas, and because both go through this one function they cannot
9
+ * disagree about what colour an image is.
10
+ */
11
+
12
+ /** `#rrggbb` only: the one form CSS, the native hex parsers, and the colour picker all agree on. */
13
+ const ACCENT_HEX_PATTERN = /^#[0-9a-f]{6}$/i;
14
+
15
+ /** Whether `value` is a `#rrggbb` string. */
16
+ export function isAvatarAccentHex(value: unknown): value is string {
17
+ return typeof value === "string" && ACCENT_HEX_PATTERN.test(value);
18
+ }
19
+
20
+ /**
21
+ * Canonical form of an accent hex (`#rrggbb`, lowercase), or null when the
22
+ * input is not one. Surrounding whitespace is tolerated because the value can
23
+ * arrive from a text field.
24
+ */
25
+ export function normalizeAvatarAccentHex(value: unknown): string | null {
26
+ if (typeof value !== "string") {
27
+ return null;
28
+ }
29
+ const trimmed = value.trim();
30
+ return ACCENT_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
31
+ }
32
+
33
+ /** Pixels below this alpha are skipped: a cutout's transparent ground is not its colour. */
34
+ const MIN_ALPHA = 128;
35
+
36
+ /** Hue is bucketed this finely, so a gradient of one hue lands in one bin. */
37
+ const HUE_BINS = 24;
38
+ /** Chromatic bins are split by lightness this many ways, so a colour and its shadow stay apart. */
39
+ const LIGHT_BANDS = 3;
40
+ /** Below this saturation a pixel is counted as gray, bucketed by lightness alone. */
41
+ const GRAY_SATURATION = 0.15;
42
+ /** Gray bins by lightness. */
43
+ const GRAY_BANDS = 5;
44
+
45
+ interface Bin {
46
+ count: number;
47
+ r: number;
48
+ g: number;
49
+ b: number;
50
+ s: number;
51
+ l: number;
52
+ }
53
+
54
+ function rgbToHsl(
55
+ r: number,
56
+ g: number,
57
+ b: number,
58
+ ): { h: number; s: number; l: number } {
59
+ const rn = r / 255;
60
+ const gn = g / 255;
61
+ const bn = b / 255;
62
+ const max = Math.max(rn, gn, bn);
63
+ const min = Math.min(rn, gn, bn);
64
+ const l = (max + min) / 2;
65
+ const d = max - min;
66
+ if (d === 0) {
67
+ return { h: 0, s: 0, l };
68
+ }
69
+ const s = d / (1 - Math.abs(2 * l - 1));
70
+ let h: number;
71
+ if (max === rn) {
72
+ h = ((gn - bn) / d) % 6;
73
+ } else if (max === gn) {
74
+ h = (bn - rn) / d + 2;
75
+ } else {
76
+ h = (rn - gn) / d + 4;
77
+ }
78
+ h /= 6;
79
+ return { h: h < 0 ? h + 1 : h, s, l };
80
+ }
81
+
82
+ function toHex(r: number, g: number, b: number): string {
83
+ const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
84
+ return `#${((1 << 24) | (ch(r) << 16) | (ch(g) << 8) | ch(b)).toString(16).slice(1)}`;
85
+ }
86
+
87
+ /**
88
+ * How much a bin's colour deserves to be the accent, per pixel in it.
89
+ *
90
+ * Population alone picks the background: most uploads are a subject on white
91
+ * or black. Saturation and mid lightness weight the bin instead, so a red mark
92
+ * on a white ground reads as red while an all-gray photo still reads as gray,
93
+ * because with nothing colourful to beat, the gray bin's small weight wins.
94
+ */
95
+ function salience(s: number, l: number): number {
96
+ return (0.1 + 0.9 * s) * Math.max(0.1, 1 - Math.abs(l - 0.5) * 1.6);
97
+ }
98
+
99
+ /**
100
+ * The most prevalent accent colour in a block of RGBA pixels, as `#rrggbb`, or
101
+ * null when no pixel is opaque enough to count.
102
+ *
103
+ * Pixels are bucketed by hue and lightness (grays by lightness alone), each
104
+ * bucket is scored by population times how much it looks like an accent
105
+ * (saturated, mid-toned), and the winner's mean colour is the answer. The
106
+ * mean rather than the bucket centre, so a brand's exact orange comes back
107
+ * as that orange and not the nearest of twenty-four hues.
108
+ */
109
+ export function dominantAccentHex(pixels: ArrayLike<number>): string | null {
110
+ const bins = new Map<number, Bin>();
111
+ for (let i = 0; i + 3 < pixels.length; i += 4) {
112
+ if (pixels[i + 3]! < MIN_ALPHA) {
113
+ continue;
114
+ }
115
+ const r = pixels[i]!;
116
+ const g = pixels[i + 1]!;
117
+ const b = pixels[i + 2]!;
118
+ const { h, s, l } = rgbToHsl(r, g, b);
119
+ const key =
120
+ s < GRAY_SATURATION
121
+ ? -1 - Math.min(GRAY_BANDS - 1, Math.floor(l * GRAY_BANDS))
122
+ : Math.min(HUE_BINS - 1, Math.floor(h * HUE_BINS)) * LIGHT_BANDS +
123
+ Math.min(LIGHT_BANDS - 1, Math.floor(l * LIGHT_BANDS));
124
+ let bin = bins.get(key);
125
+ if (!bin) {
126
+ bin = { count: 0, r: 0, g: 0, b: 0, s: 0, l: 0 };
127
+ bins.set(key, bin);
128
+ }
129
+ bin.count += 1;
130
+ bin.r += r;
131
+ bin.g += g;
132
+ bin.b += b;
133
+ bin.s += s;
134
+ bin.l += l;
135
+ }
136
+
137
+ let best: Bin | null = null;
138
+ let bestScore = -1;
139
+ for (const bin of bins.values()) {
140
+ const score = bin.count * salience(bin.s / bin.count, bin.l / bin.count);
141
+ if (score > bestScore) {
142
+ best = bin;
143
+ bestScore = score;
144
+ }
145
+ }
146
+ if (!best) {
147
+ return null;
148
+ }
149
+ return toHex(best.r / best.count, best.g / best.count, best.b / best.count);
150
+ }
@@ -14,11 +14,18 @@ export {
14
14
  AVATAR_TRAITS_FILENAME,
15
15
  resolveAvatarDir,
16
16
  } from "./layout.js";
17
+ export {
18
+ dominantAccentHex,
19
+ isAvatarAccentHex,
20
+ normalizeAvatarAccentHex,
21
+ } from "./accent.js";
17
22
  export {
18
23
  deriveAvatarFromLegacyFiles,
19
24
  parseAvatarManifest,
20
25
  } from "./manifest.js";
21
26
  export type {
27
+ AvatarAccent,
28
+ AvatarAccentSource,
22
29
  AvatarImageMeta,
23
30
  AvatarKind,
24
31
  AvatarSource,
@@ -1,3 +1,5 @@
1
+ import { isAvatarAccentHex } from "./accent.js";
2
+
1
3
  export type AvatarKind = "character" | "image" | "none";
2
4
  export type AvatarSource = "builder" | "upload" | "ai";
3
5
 
@@ -12,12 +14,27 @@ export interface AvatarImageMeta {
12
14
  etag: string;
13
15
  }
14
16
 
17
+ /**
18
+ * Where an accent came from. `palette`: a character's chosen colour.
19
+ * `derived`: read out of an uploaded image's pixels. `custom`: set by the
20
+ * user over an image. Only the last is worth offering a reset from.
21
+ */
22
+ export type AvatarAccentSource = "palette" | "derived" | "custom";
23
+
24
+ /** The colour every avatar-tinted surface paints with, as `#rrggbb`. */
25
+ export interface AvatarAccent {
26
+ hex: string;
27
+ source: AvatarAccentSource;
28
+ }
29
+
15
30
  /** The persisted manifest (`avatar.json`). */
16
31
  export interface AvatarState {
17
32
  kind: AvatarKind;
18
33
  traits: CharacterTraits | null;
19
34
  source: AvatarSource | null;
20
35
  image: AvatarImageMeta | null;
36
+ /** Null for `none`, and for an image whose colour could not be read. */
37
+ accent: AvatarAccent | null;
21
38
  }
22
39
 
23
40
  const AVATAR_KINDS: ReadonlySet<string> = new Set<AvatarKind>([
@@ -32,6 +49,12 @@ const AVATAR_SOURCES: ReadonlySet<string> = new Set<AvatarSource>([
32
49
  "ai",
33
50
  ]);
34
51
 
52
+ const AVATAR_ACCENT_SOURCES: ReadonlySet<string> = new Set<AvatarAccentSource>([
53
+ "palette",
54
+ "derived",
55
+ "custom",
56
+ ]);
57
+
35
58
  function isRecord(value: unknown): value is Record<string, unknown> {
36
59
  return !!value && typeof value === "object";
37
60
  }
@@ -66,6 +89,23 @@ function isValidAvatarImageMeta(value: unknown): value is AvatarImageMeta {
66
89
  );
67
90
  }
68
91
 
92
+ /**
93
+ * An accent is optional on the wire and in the file: manifests written before
94
+ * accents existed carry none, and an unreadable image has none. Anything not
95
+ * a well-formed accent normalizes to null rather than failing the manifest.
96
+ */
97
+ function parseAvatarAccent(value: unknown): AvatarAccent | null {
98
+ if (
99
+ !isRecord(value) ||
100
+ !isAvatarAccentHex(value.hex) ||
101
+ typeof value.source !== "string" ||
102
+ !AVATAR_ACCENT_SOURCES.has(value.source)
103
+ ) {
104
+ return null;
105
+ }
106
+ return { hex: value.hex, source: value.source as AvatarAccentSource };
107
+ }
108
+
69
109
  /**
70
110
  * Validates a parsed `avatar.json`. Returns `null` for a non-object, an
71
111
  * invalid or missing `kind`, or a valid `kind` whose per-kind payload is
@@ -90,15 +130,27 @@ export function parseAvatarManifest(value: unknown): AvatarState | null {
90
130
  if (!isValidCharacterTraits(value.traits)) {
91
131
  return null;
92
132
  }
93
- return { kind, traits: value.traits, source, image: null };
133
+ return {
134
+ kind,
135
+ traits: value.traits,
136
+ source,
137
+ image: null,
138
+ accent: parseAvatarAccent(value.accent),
139
+ };
94
140
  }
95
141
  if (kind === "image") {
96
142
  if (!isValidAvatarImageMeta(value.image)) {
97
143
  return null;
98
144
  }
99
- return { kind, traits: null, source, image: value.image };
145
+ return {
146
+ kind,
147
+ traits: null,
148
+ source,
149
+ image: value.image,
150
+ accent: parseAvatarAccent(value.accent),
151
+ };
100
152
  }
101
- return { kind, traits: null, source, image: null };
153
+ return { kind, traits: null, source, image: null, accent: null };
102
154
  }
103
155
 
104
156
  type LegacyAvatarDerivation =
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { CHANNEL_IDS, isChannelId } from "../channels.js";
3
+ import {
4
+ CHANNEL_BOT_PROVIDER,
5
+ CHANNEL_IDS,
6
+ isChannelBotProvider,
7
+ isChannelUserIntegration,
8
+ isChannelId,
9
+ } from "../channels.js";
4
10
 
5
11
  describe("isChannelId", () => {
6
12
  test("accepts every canonical channel id", () => {
@@ -34,3 +40,36 @@ describe("isChannelId", () => {
34
40
  expect(isChannelId(42)).toBe(false);
35
41
  });
36
42
  });
43
+
44
+ describe("isChannelUserIntegration", () => {
45
+ test("names the grant standing beside a bot of the same brand", () => {
46
+ expect(isChannelUserIntegration("slack")).toBe(true);
47
+ expect(isChannelUserIntegration("discord")).toBe(true);
48
+ });
49
+
50
+ test("excludes a channel whose bot is its own key", () => {
51
+ // `telegram` names the bot, so no second provider carries the brand and
52
+ // there is nothing to mistake for it.
53
+ expect(isChannelUserIntegration("telegram")).toBe(false);
54
+ });
55
+
56
+ test("excludes the bots themselves and unrelated providers", () => {
57
+ expect(isChannelUserIntegration("slack_channel")).toBe(false);
58
+ expect(isChannelUserIntegration("discord_channel")).toBe(false);
59
+ expect(isChannelUserIntegration("google")).toBe(false);
60
+ });
61
+
62
+ test("is the complement of isChannelBotProvider over the map", () => {
63
+ // The two questions partition the brands that have both halves: a key is
64
+ // one or the other, never both, so a caller picking the wrong one gets an
65
+ // empty answer rather than a plausible wrong one.
66
+ for (const [channelId, botProviderKey] of Object.entries(
67
+ CHANNEL_BOT_PROVIDER,
68
+ )) {
69
+ expect(isChannelUserIntegration(channelId)).toBe(
70
+ channelId !== botProviderKey,
71
+ );
72
+ expect(isChannelBotProvider(botProviderKey)).toBe(true);
73
+ }
74
+ });
75
+ });