@clowder-ai/companion 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (57) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +59 -0
  3. package/manifest.json +5 -5
  4. package/package.json +19 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/call-transcript-view.mjs +238 -0
  7. package/renderer/client.mjs +13 -1
  8. package/renderer/companion-identity.mjs +42 -0
  9. package/renderer/conversation.mjs +144 -24
  10. package/renderer/decision-view.mjs +124 -0
  11. package/renderer/errors.mjs +4 -4
  12. package/renderer/index.html +168 -26
  13. package/renderer/living-body.mjs +147 -0
  14. package/renderer/living-edge.mjs +16 -0
  15. package/renderer/living-geometry.mjs +96 -0
  16. package/renderer/motion-facts.mjs +108 -0
  17. package/renderer/native-window-travel.mjs +89 -0
  18. package/renderer/native-work-motion.mjs +39 -0
  19. package/renderer/peer.mjs +1 -1
  20. package/renderer/pet-controls.mjs +130 -0
  21. package/renderer/pet-motion-assets.mjs +22 -0
  22. package/renderer/pet-motion.mjs +343 -0
  23. package/renderer/recent-bubble.mjs +74 -0
  24. package/renderer/settings-controller.mjs +171 -0
  25. package/renderer/settings-view.mjs +234 -0
  26. package/renderer/skins/ragdoll-v1.png +0 -0
  27. package/renderer/skins/xianxian-codex.webp +0 -0
  28. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  29. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  30. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  31. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  32. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  33. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  34. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  35. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  36. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  37. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  38. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  39. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  40. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  41. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  42. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  43. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  44. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  45. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  46. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  47. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  48. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  49. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  50. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  51. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  52. package/renderer/skins/xianxian-working-row.png +0 -0
  53. package/renderer/skins/yarn-ball.png +0 -0
  54. package/renderer/style.css +240 -308
  55. package/renderer/surface.mjs +350 -69
  56. package/renderer/transcript-view.mjs +60 -7
  57. package/source-lock.json +138 -4
@@ -0,0 +1,147 @@
1
+ import { isLivingClip, livingClipFor, placeLivingClip } from './living-geometry.mjs';
2
+ export { isLivingClip } from './living-geometry.mjs';
3
+ const setHidden = (node, hidden) => {
4
+ if (!node) return;
5
+ node.toggleAttribute?.('hidden', hidden);
6
+ node.hidden = hidden;
7
+ };
8
+
9
+ /** One visible body: a layered sit or one muted clip, with a bounded sleep/wake hand-off. */
10
+ export class LivingBody {
11
+ constructor({ root, sit, video, transitionVideo, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout }) {
12
+ this.root = root;
13
+ this.sit = sit;
14
+ this.video = video;
15
+ this.standby = transitionVideo;
16
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
17
+ this.clearTimer = id => clearTimer.call(globalThis, id);
18
+ this.action = '';
19
+ this.revision = 0;
20
+ }
21
+
22
+ show(action, { reducedMotion = false, onEnded = () => {} } = {}) {
23
+ const dockEdge = this.root.dataset.dockEdge;
24
+ const dockZone = this.root.dataset.dockZone;
25
+ if (this.action === action && this.reducedMotion === reducedMotion && this.dockEdge === dockEdge && this.dockZone === dockZone) return;
26
+ const previous = this.action;
27
+ this.action = action;
28
+ this.reducedMotion = reducedMotion;
29
+ this.dockEdge = dockEdge;
30
+ this.dockZone = dockZone;
31
+ const revision = ++this.revision;
32
+ this.cancelFade();
33
+ this.root.dataset.pose = this.pose(action);
34
+ this.root.dataset.direction = action === 'running-left' || (action === 'peek' && dockEdge === 'left') ? 'left' : 'right';
35
+ const clip = !reducedMotion ? livingClipFor(action, dockZone) : null;
36
+ if (!clip) {
37
+ this.hideVideos();
38
+ setHidden(this.sit, false);
39
+ return;
40
+ }
41
+ const crossfade = previous === 'sleeping' && action === 'waking' && this.standby;
42
+ if (crossfade) this.crossfade(clip, revision, onEnded);
43
+ else this.showClip(this.video, clip, revision, onEnded);
44
+ }
45
+
46
+ pose(action) {
47
+ if (action === 'failed') return 'scared';
48
+ if (action === 'waiting') return 'listening';
49
+ if (['sleeping', 'pending_decision', 'held', 'working', 'fetching', 'studying'].includes(action))
50
+ return action === 'pending_decision' ? 'pending' : action;
51
+ return 'sit';
52
+ }
53
+
54
+ showClip(video, clip, revision, onEnded) {
55
+ this.retireVideo(this.standby);
56
+ this.configure(video, clip, revision, onEnded);
57
+ video.style.opacity = '1';
58
+ setHidden(this.sit, true);
59
+ setHidden(video, false);
60
+ this.play(video, revision, onEnded);
61
+ }
62
+
63
+ crossfade(clip, revision, onEnded) {
64
+ const outgoing = this.video;
65
+ const incoming = this.standby;
66
+ this.configure(incoming, clip, revision, onEnded);
67
+ incoming.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
68
+ outgoing.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
69
+ incoming.style.opacity = '0';
70
+ outgoing.style.opacity = '1';
71
+ setHidden(this.sit, true);
72
+ setHidden(outgoing, false);
73
+ setHidden(incoming, false);
74
+ this.play(incoming, revision, onEnded);
75
+ incoming.style.opacity = '1';
76
+ outgoing.style.opacity = '0';
77
+ this.video = incoming;
78
+ this.standby = outgoing;
79
+ this.fadeTimer = this.setTimer(() => {
80
+ this.fadeTimer = undefined;
81
+ if (revision !== this.revision) return;
82
+ this.retireVideo(outgoing);
83
+ }, clip.crossfadeMs);
84
+ }
85
+
86
+ configure(video, clip, revision, onEnded) {
87
+ video.onended = null;
88
+ video.pause();
89
+ video.src = `./skins/xianxian-living/clips/${clip.name}.webm`;
90
+ video.loop = clip.loop === true;
91
+ video.playbackRate = clip.rate ?? 1;
92
+ const placement = placeLivingClip(this.action, clip, this.root.dataset.dockEdge);
93
+ video.style.width = `${placement.width}px`;
94
+ video.style.height = `${placement.height}px`;
95
+ video.style.left = `${placement.left}px`;
96
+ video.style.top = `${placement.top}px`;
97
+ video.style.animation = clip.endGround
98
+ ? `living-wake-anchor ${clip.durationMs}ms linear forwards` : 'none';
99
+ if (clip.endGround) {
100
+ video.style.setProperty?.('--living-left-start', `${placement.left}px`);
101
+ video.style.setProperty?.('--living-top-start', `${placement.top}px`);
102
+ video.style.setProperty?.('--living-left-end', `${placement.endLeft}px`);
103
+ video.style.setProperty?.('--living-top-end', `${placement.endTop}px`);
104
+ }
105
+ try { video.currentTime = 0; } catch { /* New source is not yet seekable. */ }
106
+ video.onended = () => { if (revision === this.revision) onEnded(); };
107
+ }
108
+
109
+ play(video, revision, onEnded) {
110
+ void video.play().catch(() => {
111
+ if (revision !== this.revision) return;
112
+ this.hideVideos();
113
+ setHidden(this.sit, false);
114
+ onEnded();
115
+ });
116
+ }
117
+
118
+ hideVideos() {
119
+ for (const video of [this.video, this.standby]) {
120
+ this.retireVideo(video);
121
+ }
122
+ }
123
+
124
+ retireVideo(video) {
125
+ if (!video) return;
126
+ video.onended = null;
127
+ video.pause();
128
+ setHidden(video, true);
129
+ }
130
+
131
+ cancelFade() {
132
+ if (this.fadeTimer !== undefined) this.clearTimer(this.fadeTimer);
133
+ this.fadeTimer = undefined;
134
+ }
135
+
136
+ close() {
137
+ this.revision++;
138
+ this.cancelFade();
139
+ this.hideVideos();
140
+ }
141
+
142
+ hide() {
143
+ this.close();
144
+ setHidden(this.sit, true);
145
+ this.action = '';
146
+ }
147
+ }
@@ -0,0 +1,16 @@
1
+ /** Observe the Host window's current screen; never select or move a display. */
2
+ export function detectDockedEdge(win, petRect) {
3
+ const left = win.screen?.availLeft;
4
+ const width = win.screen?.availWidth;
5
+ if (![win.screenX, left, width, petRect.left, petRect.width].every(Number.isFinite) || width <= 0) return null;
6
+ const petLeft = win.screenX + petRect.left;
7
+ const edge = petLeft - left <= 40 ? 'left'
8
+ : left + width - petLeft - petRect.width <= 40 ? 'right' : null;
9
+ if (!edge) return null;
10
+ const top = win.screen?.availTop;
11
+ const height = win.screen?.availHeight;
12
+ const vertical = [win.screenY, top, height, petRect.top, petRect.height];
13
+ const atBottom = vertical.every(Number.isFinite) && height > 0
14
+ && top + height - (win.screenY + petRect.top + petRect.height) <= 40;
15
+ return { edge, zone: atBottom ? 'corner' : 'mid' };
16
+ }
@@ -0,0 +1,96 @@
1
+ // F317 living Xianxian package v3. All values are reviewed package CSS anchors;
2
+ // every clip uses one stage scale. content bounds are the union of decoded
3
+ // alpha pixels and exist to size the native transparent surface, not to apply
4
+ // per-action "looks right" scaling.
5
+ export const LIVING_GEOMETRY = Object.freeze({
6
+ pet: Object.freeze({ width: 120, height: 130 }),
7
+ stage: Object.freeze({ size: 290, centre: 145, ground: 262.5 }),
8
+ surface: Object.freeze({ left: -32, top: -18, right: 143, bottom: 138 }),
9
+ sitContent: Object.freeze({ left: 8.8, top: 3.4, right: 94.2, bottom: 109.2 }),
10
+ });
11
+
12
+ export const LIVING_SCALE = LIVING_GEOMETRY.pet.width / LIVING_GEOMETRY.stage.size;
13
+ export const LIVING_REFERENCE = Object.freeze({
14
+ centre: LIVING_GEOMETRY.stage.centre * LIVING_SCALE,
15
+ ground: LIVING_GEOMETRY.stage.ground * LIVING_SCALE,
16
+ });
17
+
18
+ const clip = (name, size, ground, centre, content, options = {}) => Object.freeze({
19
+ name, size: Object.freeze(size), ground, centre, content: Object.freeze(content), ...options,
20
+ });
21
+
22
+ const library = Object.freeze({
23
+ trot: clip('trot', [265, 236], 234.8, 132.5,
24
+ { left: 1.5, top: 2, right: 263.5, bottom: 235.5 }, { loop: true }),
25
+ pounce: clip('pounce', [304.8, 312.6], 303.9, 201.9,
26
+ { left: 3, top: 0, right: 302, bottom: 312 }, { rate: 1.5 }),
27
+ sleep: clip('sleep', [271.2, 168], 161, 133.2,
28
+ { left: 2.5, top: 2.5, right: 268.5, bottom: 166.5 }, { loop: true }),
29
+ wake: clip('wake', [400.2, 282.7], 244.2, 200.5,
30
+ { left: 3, top: 3, right: 397, bottom: 280.5 },
31
+ { endGround: 277.4, endCentre: 197.7, durationMs: 7_250, crossfadeMs: 250 }),
32
+ peek: clip('peek', [153.5, 234.3], 234.3, 76.75,
33
+ { left: 2.5, top: 2.5, right: 153, bottom: 234 }, { loop: true }),
34
+ peek_fade: clip('peek_fade', [153.5, 234.3], 234.3, 76.75,
35
+ { left: 2.5, top: 2.5, right: 153, bottom: 231.5 }, { loop: true }),
36
+ carry: clip('carry', [344.3, 290.9], 263, 198.3,
37
+ { left: 4, top: 4, right: 340, bottom: 288.5 }),
38
+ wait: clip('wait', [197.9, 252.8], 249.6, 98.4,
39
+ { left: 4, top: 4, right: 194, bottom: 251 }, { loop: true }),
40
+ think: clip('think', [266.4, 256.9], 253.7, 132.1,
41
+ { left: 3.5, top: 3.5, right: 262, bottom: 255 }, { loop: true }),
42
+ });
43
+
44
+ const actionClip = Object.freeze({
45
+ 'running-left': 'trot',
46
+ 'running-right': 'trot',
47
+ running: 'trot',
48
+ play: 'pounce',
49
+ sleeping: 'sleep',
50
+ waking: 'wake',
51
+ peek: 'peek',
52
+ delivering: 'carry',
53
+ review: 'carry',
54
+ pending_decision: 'wait',
55
+ working: 'think',
56
+ fetching: 'think',
57
+ studying: 'think',
58
+ staged_thought: 'think',
59
+ });
60
+
61
+ export const isLivingClip = action => Object.hasOwn(actionClip, action);
62
+
63
+ export function livingClipFor(action, dockZone) {
64
+ const name = action === 'peek' && dockZone !== 'corner' ? 'peek_fade' : actionClip[action];
65
+ return name ? library[name] : null;
66
+ }
67
+
68
+ export function placeLivingClip(action, selected, dockEdge) {
69
+ const width = selected.size[0] * LIVING_SCALE;
70
+ const height = selected.size[1] * LIVING_SCALE;
71
+ const left = action === 'peek'
72
+ ? (dockEdge === 'left' ? LIVING_GEOMETRY.surface.left : LIVING_GEOMETRY.surface.right - width)
73
+ : LIVING_REFERENCE.centre - selected.centre * LIVING_SCALE;
74
+ const top = action === 'peek'
75
+ ? LIVING_GEOMETRY.surface.bottom - height
76
+ : LIVING_REFERENCE.ground - selected.ground * LIVING_SCALE;
77
+ return Object.freeze({
78
+ left, top, width, height,
79
+ endLeft: selected.endCentre === undefined
80
+ ? undefined : LIVING_REFERENCE.centre - selected.endCentre * LIVING_SCALE,
81
+ endTop: selected.endGround === undefined
82
+ ? undefined : LIVING_REFERENCE.ground - selected.endGround * LIVING_SCALE,
83
+ });
84
+ }
85
+
86
+ export function livingContentRect(selected, placement, endpoint = 'start') {
87
+ const left = endpoint === 'end' ? placement.endLeft : placement.left;
88
+ const top = endpoint === 'end' ? placement.endTop : placement.top;
89
+ if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
90
+ return Object.freeze({
91
+ left: left + selected.content.left * LIVING_SCALE,
92
+ top: top + selected.content.top * LIVING_SCALE,
93
+ right: left + selected.content.right * LIVING_SCALE,
94
+ bottom: top + selected.content.bottom * LIVING_SCALE,
95
+ });
96
+ }
@@ -0,0 +1,108 @@
1
+ const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
2
+ const validExpiry = (value, now) => Number.isFinite(value) && value > now;
3
+ const workKinds = new Set(['reasoning', 'tool', 'workspace', 'screen_reading']);
4
+ const normalizeTravel = (travel, now, suppressed) => travel && validId(travel.eventId)
5
+ && travel.status === 'active' && Number.isFinite(travel.dx) && Number.isFinite(travel.dy)
6
+ && Math.hypot(travel.dx, travel.dy) >= 1 && validExpiry(travel.expiresAt, now)
7
+ && !suppressed.has(travel.eventId)
8
+ ? { eventId: travel.eventId, dx: travel.dx, dy: travel.dy, expiresAt: travel.expiresAt } : null;
9
+
10
+ /** A bounded, expiring projection of Host-owned motion facts. */
11
+ export class MotionFacts {
12
+ constructor({ now, setTimer, clearTimer, changed }) {
13
+ this.now = now;
14
+ this.setTimer = setTimer;
15
+ this.clearTimer = clearTimer;
16
+ this.changed = changed;
17
+ this.snapshot = { work: null, delivery: null, travel: null };
18
+ this.seenResults = new Map();
19
+ this.suppressedTravel = new Map();
20
+ }
21
+
22
+ sync(value, suppressTravel = false) {
23
+ const now = this.now();
24
+ this.prune(now);
25
+ const input = value && typeof value === 'object' ? value : {};
26
+ const work = input.work;
27
+ const delivery = input.delivery;
28
+ const travel = input.travel;
29
+ this.snapshot = {
30
+ work: work && validId(work.taskId) && work.status === 'active' && workKinds.has(work.kind)
31
+ && validExpiry(work.expiresAt, now)
32
+ ? { taskId: work.taskId, kind: work.kind, expiresAt: work.expiresAt } : null,
33
+ delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
34
+ && validExpiry(delivery.expiresAt, now)
35
+ ? { resultId: delivery.resultId, expiresAt: delivery.expiresAt } : null,
36
+ travel: normalizeTravel(travel, now, this.suppressedTravel),
37
+ };
38
+ if (suppressTravel) this.suppressTravel();
39
+ this.scheduleExpiry();
40
+ }
41
+
42
+ syncNative(value) {
43
+ const now = this.now();
44
+ this.prune(now);
45
+ const input = value && typeof value === 'object' ? value : {};
46
+ const work = input.work;
47
+ const delivery = input.delivery;
48
+ const travel = this.snapshot.travel?.expiresAt > now ? this.snapshot.travel : null;
49
+ this.snapshot = {
50
+ work: work && validId(work.taskId) && work.status === 'active' && workKinds.has(work.kind)
51
+ && validExpiry(work.expiresAt, now)
52
+ ? { taskId: work.taskId, kind: work.kind, expiresAt: work.expiresAt } : null,
53
+ delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
54
+ && validExpiry(delivery.expiresAt, now)
55
+ ? { resultId: delivery.resultId, expiresAt: delivery.expiresAt } : null,
56
+ travel,
57
+ };
58
+ this.scheduleExpiry();
59
+ }
60
+
61
+ syncTravel(value, suppressTravel = false) {
62
+ const now = this.now();
63
+ this.prune(now);
64
+ this.snapshot.travel = normalizeTravel(value, now, this.suppressedTravel);
65
+ if (suppressTravel) this.suppressTravel();
66
+ this.scheduleExpiry();
67
+ }
68
+
69
+ consumeDelivery() {
70
+ const delivery = this.snapshot.delivery;
71
+ if (!delivery || this.seenResults.has(delivery.resultId)) return null;
72
+ this.seenResults.set(delivery.resultId, this.now() + 3_600_000);
73
+ return delivery;
74
+ }
75
+
76
+ suppressTravel() {
77
+ if (!this.snapshot.travel) return;
78
+ this.suppressedTravel.set(this.snapshot.travel.eventId, this.now() + 3_600_000);
79
+ this.snapshot.travel = null;
80
+ this.scheduleExpiry();
81
+ }
82
+
83
+ close() {
84
+ if (this.timer !== undefined) this.clearTimer(this.timer);
85
+ this.timer = undefined;
86
+ }
87
+
88
+ scheduleExpiry() {
89
+ this.close();
90
+ const expiries = Object.values(this.snapshot).filter(Boolean).map(value => value.expiresAt);
91
+ if (!expiries.length) return;
92
+ this.timer = this.setTimer(() => {
93
+ this.timer = undefined;
94
+ const now = this.now();
95
+ for (const key of ['work', 'delivery', 'travel']) {
96
+ if (this.snapshot[key]?.expiresAt <= now) this.snapshot[key] = null;
97
+ }
98
+ this.scheduleExpiry();
99
+ this.changed();
100
+ }, Math.max(0, Math.min(...expiries) - this.now()));
101
+ }
102
+
103
+ prune(now) {
104
+ for (const collection of [this.seenResults, this.suppressedTravel]) {
105
+ for (const [id, expiresAt] of collection) if (expiresAt <= now) collection.delete(id);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,89 @@
1
+ const validPosition = value => value && Number.isFinite(value.x) && Number.isFinite(value.y);
2
+
3
+ /** Observe the native window itself. Running animation follows measured desktop
4
+ * movement; renderer state never invents a target or claims a move succeeded. */
5
+ export class NativeWindowTravel {
6
+ constructor({
7
+ readPosition,
8
+ changed,
9
+ now = Date.now,
10
+ setTimer = globalThis.setTimeout,
11
+ clearTimer = globalThis.clearTimeout,
12
+ intervalMs = 80,
13
+ expiresMs = 240,
14
+ }) {
15
+ this.readPosition = readPosition;
16
+ this.changed = changed;
17
+ this.now = now;
18
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
19
+ this.clearTimer = id => clearTimer.call(globalThis, id);
20
+ this.intervalMs = Math.max(32, intervalMs);
21
+ this.expiresMs = Math.max(this.intervalMs, expiresMs);
22
+ this.sequence = 0;
23
+ this.manual = false;
24
+ this.closed = false;
25
+ this.active = null;
26
+ }
27
+
28
+ start() {
29
+ if (this.closed || this.timer !== undefined) return;
30
+ this.previous = this.read();
31
+ this.schedule();
32
+ }
33
+
34
+ setManual(value) {
35
+ const manual = value === true;
36
+ if (manual === this.manual) return;
37
+ this.manual = manual;
38
+ this.previous = this.read();
39
+ if (manual) this.clearActive();
40
+ }
41
+
42
+ tick() {
43
+ this.timer = undefined;
44
+ if (this.closed) return;
45
+ const next = this.read();
46
+ const now = this.now();
47
+ if (next && this.previous) {
48
+ const dx = next.x - this.previous.x;
49
+ const dy = next.y - this.previous.y;
50
+ if (!this.manual && Math.hypot(dx, dy) >= 1) {
51
+ const eventId = this.active?.eventId ?? `native-${Math.trunc(now)}-${++this.sequence}`;
52
+ this.active = { eventId, status: 'active', dx, dy, expiresAt: now + this.expiresMs };
53
+ this.changed({ ...this.active });
54
+ } else if (this.active && this.active.expiresAt <= now) {
55
+ this.clearActive();
56
+ }
57
+ }
58
+ this.previous = next;
59
+ this.schedule();
60
+ }
61
+
62
+ close() {
63
+ if (this.closed) return;
64
+ this.closed = true;
65
+ if (this.timer !== undefined) this.clearTimer(this.timer);
66
+ this.timer = undefined;
67
+ this.clearActive();
68
+ }
69
+
70
+ read() {
71
+ try {
72
+ const value = this.readPosition();
73
+ return validPosition(value) ? { x: value.x, y: value.y } : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ schedule() {
80
+ if (this.closed || this.timer !== undefined) return;
81
+ this.timer = this.setTimer(() => this.tick(), this.intervalMs);
82
+ }
83
+
84
+ clearActive() {
85
+ if (!this.active) return;
86
+ this.active = null;
87
+ this.changed(null);
88
+ }
89
+ }
@@ -0,0 +1,39 @@
1
+ const priority = { reasoning: 1, tool: 2, workspace_fetch: 3, workspace_dispatch: 4, screen_read: 5 };
2
+ const semantic = {
3
+ reasoning: 'reasoning',
4
+ tool: 'tool',
5
+ workspace_fetch: 'workspace',
6
+ workspace_dispatch: 'workspace',
7
+ screen_read: 'screen_reading',
8
+ };
9
+
10
+ /** Normalize the Host's bounded call snapshot; never infer work from chat text. */
11
+ export function normalizeNativeWork(value, now = Date.now()) {
12
+ if (!value || typeof value !== 'object' || !Array.isArray(value.active) || !Array.isArray(value.recent)) return null;
13
+ const active = value.active
14
+ .filter(entry => entry && semantic[entry.kind] && Number.isFinite(entry.expiresAt) && entry.expiresAt > now)
15
+ .sort((left, right) => priority[right.kind] - priority[left.kind] || right.startedAt - left.startedAt)[0];
16
+ const handed = [...value.recent].reverse().find(event => event?.kind === 'result'
17
+ && event.phase === 'result_handed_to_voice' && typeof event.resultId === 'string'
18
+ && event.resultId.length > 0 && Number.isFinite(event.expiresAt) && event.expiresAt > now);
19
+ return {
20
+ work: active ? { taskId: active.taskId, kind: semantic[active.kind], status: 'active', expiresAt: active.expiresAt } : null,
21
+ delivery: handed ? { resultId: handed.resultId, status: 'applied', expiresAt: handed.expiresAt } : null,
22
+ travel: null,
23
+ };
24
+ }
25
+
26
+ const workLabels = {
27
+ reasoning: '正在思考',
28
+ tool: '正在处理',
29
+ workspace: '正在处理家里的事务',
30
+ screen_reading: '正在查看共享画面',
31
+ };
32
+
33
+ /** Product wording for the same bounded work fact used by body motion. */
34
+ export function nativeWorkLabel(snapshot, nativeActivity = 'none') {
35
+ const kind = snapshot
36
+ ? snapshot.work?.kind
37
+ : nativeActivity === 'reasoning' ? 'reasoning' : nativeActivity === 'tool_running' ? 'tool' : null;
38
+ return workLabels[kind] ?? '';
39
+ }
package/renderer/peer.mjs CHANGED
@@ -10,7 +10,7 @@ export class VoicePeer {
10
10
  }
11
11
  });
12
12
  }
13
- async connect() { if (!this.closed) await this.client.connectAudio(); }
13
+ async connect(mode) { if (!this.closed) await this.client.connectAudio(mode); }
14
14
  muteMic(muted) { void this.client.muteMicrophone(muted).catch(() => this.failed()); }
15
15
  muteSpeaker(muted) { void this.client.muteSpeaker(muted).catch(() => this.failed()); }
16
16
  failed() { if (!this.closed) this.onEvent({ type: 'error', code: 'unavailable' }); }
@@ -0,0 +1,130 @@
1
+ /** Transient controls. The Host owns native placement, movement and hiding. */
2
+ export function bindPetControls(client, { action, error, changed, moved = () => {} }) {
3
+ const $ = id => document.getElementById(id);
4
+ const body = $('pet');
5
+ const panelIds = ['actions', 'menu', 'chat', 'bubble', 'decisions', 'transcript', 'settings'];
6
+ const desiredSizes = new WeakMap();
7
+ let panel = 'none', ambientPanel = 'none', layoutRevision = 0, layoutScheduled = false, drag, suppressClick = false;
8
+ const measure = node => {
9
+ if (!node) return { width: 120, height: 130 };
10
+ // Host placement is an applied cap, not the panel's desired geometry.
11
+ // Release it synchronously while measuring so CSS height and nested flex
12
+ // overflow can expose their natural demand, then restore it before paint.
13
+ const appliedMaxHeight = node.style.maxHeight;
14
+ node.style.maxHeight = '';
15
+ const desired = {
16
+ width: Math.min(420, Math.max(120, Math.ceil(Math.max(node.offsetWidth, node.scrollWidth)))),
17
+ height: Math.min(500, Math.max(32, Math.ceil(Math.max(node.offsetHeight, node.scrollHeight)))),
18
+ };
19
+ node.style.maxHeight = appliedMaxHeight;
20
+ return desired;
21
+ };
22
+ async function show(next) {
23
+ if (next === 'none' && ambientPanel !== 'none') next = ambientPanel;
24
+ const previousPanel = panel;
25
+ const node = $(next);
26
+ panel = next;
27
+ for (const id of panelIds) $(id).hidden = id !== next;
28
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
29
+ const desired = measure(node);
30
+ if (node) desiredSizes.set(node, `${desired.width}:${desired.height}`);
31
+ const revision = ++layoutRevision;
32
+ try {
33
+ const placed = await client.layout(next, desired.width, desired.height);
34
+ if (revision !== layoutRevision) return;
35
+ $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
36
+ if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
37
+ if (previousPanel !== next) {
38
+ if (next === 'chat') $('message').focus({ preventScroll: true });
39
+ if (next === 'transcript') $('call-message').focus({ preventScroll: true });
40
+ if (next === 'settings') $('settings-back').focus({ preventScroll: true });
41
+ if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
42
+ if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
43
+ changed(next);
44
+ }
45
+ } catch (cause) {
46
+ if (revision !== layoutRevision) return;
47
+ panel = previousPanel;
48
+ for (const id of panelIds) $(id).hidden = id !== panel;
49
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(panel)));
50
+ error(cause, { panel: next });
51
+ }
52
+ }
53
+ function remeasure() {
54
+ if (layoutScheduled) return;
55
+ layoutScheduled = true;
56
+ queueMicrotask(() => {
57
+ layoutScheduled = false;
58
+ const node = $(panel);
59
+ if (!node || node.hidden) return;
60
+ const desired = measure(node);
61
+ const key = `${desired.width}:${desired.height}`;
62
+ if (desiredSizes.get(node) === key) return;
63
+ void show(panel);
64
+ });
65
+ }
66
+ body.onclick = event => {
67
+ if (suppressClick) { suppressClick = false; return; }
68
+ if (event.detail > 1) return;
69
+ void show(panel === 'menu' ? 'none' : 'menu');
70
+ };
71
+ body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
72
+ body.onkeydown = event => {
73
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { event.preventDefault(); void show('menu'); }
74
+ };
75
+ body.onpointerdown = event => {
76
+ if (event.button !== 0) return;
77
+ const current = { id: event.pointerId, x: event.screenX, y: event.screenY, armed: false };
78
+ drag = current;
79
+ body.setPointerCapture(event.pointerId);
80
+ void client.drag('start').then(() => { if (drag === current) current.armed = true; }).catch(error);
81
+ };
82
+ body.onpointermove = event => {
83
+ if (!drag) return;
84
+ const dx = event.screenX - drag.x, dy = event.screenY - drag.y;
85
+ if (Math.hypot(dx, dy) < 6) return;
86
+ suppressClick = true;
87
+ if (drag.armed) moved(dx, dy);
88
+ drag.x = event.screenX; drag.y = event.screenY;
89
+ };
90
+ for (const name of ['pointerup', 'pointercancel']) body.addEventListener(name, () => {
91
+ drag = undefined; moved('stop'); void client.drag('end').catch(error);
92
+ });
93
+ document.addEventListener('pointerdown', event => {
94
+ if (!event.target.closest('#anchor, .floating')) void show('none');
95
+ });
96
+ document.addEventListener('keydown', event => {
97
+ if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
98
+ });
99
+ document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
100
+ const kind = button.dataset.action;
101
+ if (kind === 'write' || kind === 'history') { void show('chat'); return; }
102
+ if (kind === 'transcript') { void show('transcript'); return; }
103
+ if (kind === 'settings') { void show('settings'); return; }
104
+ if (kind === 'decisions') { void show('decisions'); return; }
105
+ if (kind === 'dismiss') { void show('none'); return; }
106
+ action(kind);
107
+ });
108
+ const observer = new MutationObserver(records => {
109
+ const current = $(panel);
110
+ if (!current || current.hidden) return;
111
+ // Root visibility is already handled by show(). Descendant visibility and
112
+ // text/row mutations are content demand and may require a larger surface.
113
+ if (records.some(record => record.type !== 'attributes' || !panelIds.includes(record.target.id))) remeasure();
114
+ });
115
+ for (const id of panelIds) observer.observe($(id), {
116
+ subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden'],
117
+ });
118
+ window.addEventListener('beforeunload', () => observer.disconnect());
119
+ return {
120
+ get panel() { return panel; }, show, remeasure,
121
+ setAmbient(value) {
122
+ const next = value === true ? 'bubble' : value === false ? 'none' : value;
123
+ if (!['none', 'bubble', 'actions'].includes(next)) return Promise.resolve();
124
+ if (ambientPanel === next) return Promise.resolve();
125
+ ambientPanel = next;
126
+ return ['none', 'bubble', 'actions'].includes(panel) ? show('none') : Promise.resolve();
127
+ },
128
+ dismiss() { if (drag) suppressClick = true; void show('none'); },
129
+ };
130
+ }
@@ -0,0 +1,22 @@
1
+ // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
2
+ export const atlas = {
3
+ idle: { row: 0, frames: [1200, 400, 400, 500, 500, 800] },
4
+ 'running-right': { row: 1, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
5
+ 'running-left': { row: 2, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
6
+ waving: { row: 3, frames: [140, 140, 140, 280] },
7
+ jumping: { row: 4, frames: [140, 140, 140, 140, 280] },
8
+ failed: { row: 5, frames: [140, 140, 140, 140, 140, 140, 140, 240] },
9
+ waiting: { row: 6, frames: [150, 150, 150, 150, 150, 260] },
10
+ running: { row: 7, frames: [120, 120, 120, 120, 120, 220] },
11
+ review: { row: 8, frames: [150, 150, 150, 150, 150, 280] },
12
+ };
13
+ export const additional = {
14
+ sleeping: { file: 'xianxian-sleeping-row.png', frames: [700, 700, 700, 700, 700, 700] },
15
+ working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
16
+ staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
17
+ };
18
+ export const visualAlias = { fetching: 'working', studying: 'staged_thought', delivering: 'review', held: 'idle' };
19
+ export const workAction = { reasoning: 'staged_thought', tool: 'working', workspace: 'fetching', screen_reading: 'studying' };
20
+ export const signals = { connected: 'waving', received: 'jumping', failed: 'failed' };
21
+ export const staticSkins = { 'ragdoll-v1': 'ragdoll-v1.png', 'yarn-ball': 'yarn-ball.png' };
22
+ export const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;