@vellumai/cli 0.11.8 → 0.11.9-dev.202609040043.7972a34

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 (56) hide show
  1. package/README.md +6 -7
  2. package/knip.json +0 -1
  3. package/node_modules/@vellumai/avatar-manifest/package.json +2 -1
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
  5. package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
  6. package/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
  7. package/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
  8. package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
  9. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/package.json +2 -1
  10. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
  11. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
  12. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
  13. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
  14. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
  15. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +5 -1
  16. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  17. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/desktop-presence.ts +11 -0
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  23. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token-refresh.test.ts +2 -8
  24. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +2 -8
  25. package/node_modules/@vellumai/local-mode/src/__tests__/helpers/child-process-mock.ts +55 -0
  26. package/node_modules/@vellumai/local-mode/src/__tests__/retire.test.ts +2 -8
  27. package/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +2 -8
  28. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +2 -8
  29. package/node_modules/@vellumai/local-mode/src/devices.test.ts +5 -8
  30. package/node_modules/@vellumai/local-mode/src/index.ts +1 -0
  31. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +6 -0
  32. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +165 -0
  33. package/node_modules/@vellumai/local-mode/src/lockfile.ts +63 -6
  34. package/node_modules/@vellumai/service-contracts/package.json +5 -1
  35. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  36. package/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  37. package/node_modules/@vellumai/service-contracts/src/desktop-presence.ts +11 -0
  38. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  39. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  40. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  41. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  42. package/package.json +1 -1
  43. package/src/__tests__/assistant-api-key-resolution.test.ts +102 -0
  44. package/src/__tests__/constants.test.ts +6 -2
  45. package/src/commands/hatch.ts +1 -367
  46. package/src/commands/login.ts +16 -29
  47. package/src/commands/teleport.ts +14 -26
  48. package/src/lib/assistant-api-key-resolution.ts +89 -0
  49. package/src/lib/aws.ts +0 -593
  50. package/src/lib/constants.ts +0 -2
  51. package/src/lib/gcp.ts +3 -671
  52. package/src/lib/platform-client.ts +58 -5
  53. package/src/__tests__/ssh-user-guard.test.ts +0 -28
  54. package/src/adapters/openclaw-http-server.ts +0 -209
  55. package/src/adapters/openclaw.ts +0 -131
  56. package/src/lib/openclaw-runtime-server.ts +0 -28
package/README.md CHANGED
@@ -54,18 +54,17 @@ vellum hatch [species] [options]
54
54
 
55
55
  #### Options
56
56
 
57
- | Option | Description |
58
- | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
59
- | `-d` | Detached mode. Start the instance in the background without watching startup progress. |
60
- | `--name <name>` | Use a specific instance name instead of an auto-generated one. |
61
- | `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `gcp`, `aws`, `custom`. Defaults to `local`. |
57
+ | Option | Description |
58
+ | ------------------- | ---------------------------------------------------------------------------------------------------- |
59
+ | `-d` | Detached mode. Start the instance in the background without watching startup progress. |
60
+ | `--name <name>` | Use a specific instance name instead of an auto-generated one. |
61
+ | `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `custom`. Defaults to `local`. |
62
62
 
63
63
  #### Remote Targets
64
64
 
65
65
  - **`local`** -- Starts the local assistant and local gateway. Gateway source resolution order is: repo source tree, then installed `@vellumai/vellum-gateway` package.
66
66
  - **`docker`** -- Starts the assistant, gateway, and credential service in Docker containers.
67
67
  - **`vellum`** -- Hatches an assistant on the Vellum platform.
68
- - **`gcp`** and **`aws`** -- Recognized but not supported as provisioning targets yet. The CLI exits before creating cloud resources. To self-host on AWS/GCP, SSH into the VM and run `vellum hatch` or `vellum hatch --remote docker` there.
69
68
  - **`custom`** -- Recognized but not yet implemented.
70
69
 
71
70
  #### Environment Variables
@@ -87,7 +86,7 @@ vellum hatch --remote docker
87
86
  vellum hatch --name my-assistant --remote docker
88
87
  ```
89
88
 
90
- AWS and GCP hatch targets are recognized so users receive an explicit unsupported-target error instead of an unknown-option error. They currently exit without creating cloud resources; self-hosting on an AWS/GCP VM still works by running `vellum hatch` from inside that machine.
89
+ To self-host on a cloud VM (AWS, GCP, or any other provider), SSH into the machine and run `vellum hatch` or `vellum hatch --remote docker` from inside it. The CLI does not provision cloud instances for you.
91
90
 
92
91
  ### `terminal`
93
92
 
package/knip.json CHANGED
@@ -2,7 +2,6 @@
2
2
  "entry": [
3
3
  "src/**/*.test.ts",
4
4
  "src/**/__tests__/**/*.ts",
5
- "src/adapters/openclaw-http-server.ts",
6
5
  "src/lib/version-compat.ts",
7
6
  "src/lib/platform-releases.ts",
8
7
  "src/lib/cli-error.ts"
@@ -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 =
@@ -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",