@clowder-ai/companion 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (50) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +48 -0
  3. package/manifest.json +5 -5
  4. package/package.json +17 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/client.mjs +6 -0
  7. package/renderer/companion-identity.mjs +41 -0
  8. package/renderer/conversation.mjs +34 -17
  9. package/renderer/decision-view.mjs +30 -0
  10. package/renderer/index.html +84 -26
  11. package/renderer/living-body.mjs +147 -0
  12. package/renderer/living-edge.mjs +16 -0
  13. package/renderer/living-geometry.mjs +96 -0
  14. package/renderer/motion-facts.mjs +79 -0
  15. package/renderer/native-work-motion.mjs +24 -0
  16. package/renderer/pet-controls.mjs +84 -0
  17. package/renderer/pet-motion.mjs +352 -0
  18. package/renderer/recent-bubble.mjs +39 -0
  19. package/renderer/skins/ragdoll-v1.png +0 -0
  20. package/renderer/skins/xianxian-codex.webp +0 -0
  21. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  22. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  23. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  24. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  25. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  26. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  27. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  28. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  29. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  30. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  31. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  32. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  33. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  34. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  35. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  36. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  37. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  38. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  39. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  40. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  41. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  42. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  43. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  44. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  45. package/renderer/skins/xianxian-working-row.png +0 -0
  46. package/renderer/skins/yarn-ball.png +0 -0
  47. package/renderer/style.css +114 -308
  48. package/renderer/surface.mjs +198 -63
  49. package/renderer/transcript-view.mjs +42 -7
  50. package/source-lock.json +138 -4
@@ -0,0 +1,352 @@
1
+ import { isLivingClip } from './living-body.mjs';
2
+ import { MotionFacts } from './motion-facts.mjs';
3
+
4
+ // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
5
+ const atlas = {
6
+ idle: { row: 0, frames: [1200, 400, 400, 500, 500, 800] },
7
+ 'running-right': { row: 1, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
8
+ 'running-left': { row: 2, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
9
+ waving: { row: 3, frames: [140, 140, 140, 280] },
10
+ jumping: { row: 4, frames: [140, 140, 140, 140, 280] },
11
+ failed: { row: 5, frames: [140, 140, 140, 140, 140, 140, 140, 240] },
12
+ waiting: { row: 6, frames: [150, 150, 150, 150, 150, 260] },
13
+ running: { row: 7, frames: [120, 120, 120, 120, 120, 220] },
14
+ review: { row: 8, frames: [150, 150, 150, 150, 150, 280] },
15
+ };
16
+ const additional = {
17
+ sleeping: { file: 'xianxian-sleeping-row.png', frames: [700, 700, 700, 700, 700, 700] },
18
+ working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
19
+ staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
20
+ };
21
+ const visualAlias = { fetching: 'working', studying: 'staged_thought', delivering: 'review', held: 'idle' };
22
+ const workAction = { reasoning: 'staged_thought', tool: 'working', workspace: 'fetching', screen_reading: 'studying' };
23
+ const signals = { connected: 'waving', received: 'jumping', failed: 'failed' };
24
+ const staticSkins = { 'ragdoll-v1': 'ragdoll-v1.png', 'yarn-ball': 'yarn-ball.png' };
25
+ const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
26
+
27
+ /** Select one truthful body from Host facts and local, user-controlled gestures. */
28
+ export class PetMotion {
29
+ constructor(element, options = {}) {
30
+ this.element = element;
31
+ const setTimer = options.setTimer ?? globalThis.setTimeout;
32
+ const clearTimer = options.clearTimer ?? globalThis.clearTimeout;
33
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
34
+ this.clearTimer = id => clearTimer.call(globalThis, id);
35
+ this.now = options.now ?? Date.now;
36
+ this.livingBody = options.livingBody;
37
+ this.reducedMotion = options.reducedMotion ?? globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
38
+ this.autoPlay = options.autoPlay === true;
39
+ this.autoPlayIdleMs = Math.max(90_000, options.autoPlayIdleMs ?? 90_000);
40
+ this.autoPlayCooldownMs = Math.max(180_000, options.autoPlayCooldownMs ?? 180_000);
41
+ this.lastPlayAt = Number.NEGATIVE_INFINITY;
42
+ this.skin = 'xianxian-codex';
43
+ this.phase = 'idle';
44
+ this.base = 'idle';
45
+ this.action = '';
46
+ this.frame = 0;
47
+ this.dragging = false;
48
+ this.oneShot = false;
49
+ this.pendingDecision = false;
50
+ this.dockedEdge = null;
51
+ this.dockZone = null;
52
+ this.resting = false;
53
+ this.transient = null;
54
+ this.facts = new MotionFacts({ now: this.now, setTimer: this.setTimer, clearTimer: this.clearTimer,
55
+ changed: () => this.refresh() });
56
+ this.seenEvents = new Map();
57
+ }
58
+
59
+ setContext({ skin, phase, nativeActivity, muted }) {
60
+ const selectedSkin = ['xianxian-codex', 'yanyan-codex', ...Object.keys(staticSkins)].includes(skin)
61
+ ? skin : 'xianxian-codex';
62
+ const wasSleeping = this.action === 'sleeping' || this.resting;
63
+ this.context = { skin: selectedSkin, phase, nativeActivity, muted };
64
+ const changedSkin = selectedSkin !== this.skin;
65
+ this.skin = selectedSkin;
66
+ this.phase = phase;
67
+ if (phase !== 'idle' || nativeActivity !== 'none') {
68
+ this.resting = false;
69
+ this.cancelIdleTimers();
70
+ }
71
+ if (nativeActivity !== 'none' && this.transient?.action !== 'failed') this.transient = null;
72
+ else if (wasSleeping && ['talking', 'connecting'].includes(phase) && !muted)
73
+ this.transient = { action: 'waking' };
74
+ this.refresh(changedSkin);
75
+ }
76
+
77
+ syncSnapshot(value) {
78
+ const now = this.now();
79
+ this.hasNativeWork = value !== null && value !== undefined;
80
+ for (const [id, expiresAt] of this.seenEvents) if (expiresAt <= now) this.seenEvents.delete(id);
81
+ this.facts.sync(value, this.dragging);
82
+ if (this.snapshot.work) {
83
+ this.resting = false;
84
+ this.cancelIdleTimers();
85
+ if (this.transient?.action !== 'failed') this.transient = null;
86
+ } else if (this.resting && (this.snapshot.delivery || this.snapshot.travel)) {
87
+ this.resting = false;
88
+ this.cancelRest();
89
+ this.transient = { action: 'waking' };
90
+ }
91
+ this.refresh();
92
+ }
93
+
94
+ setPendingDecision(value) {
95
+ const pending = value === true;
96
+ if (this.pendingDecision === pending) return;
97
+ this.pendingDecision = pending;
98
+ if (pending) {
99
+ this.resting = false;
100
+ this.transient = null;
101
+ this.cancelIdleTimers();
102
+ }
103
+ this.refresh();
104
+ }
105
+
106
+ setDockedEdge(value) {
107
+ const candidate = typeof value === 'string' ? { edge: value, zone: 'corner' } : value;
108
+ const edge = ['left', 'right'].includes(candidate?.edge) ? candidate.edge : null;
109
+ const zone = edge && ['corner', 'mid'].includes(candidate?.zone) ? candidate.zone : edge ? 'corner' : null;
110
+ if (this.dockedEdge === edge && this.dockZone === zone) return;
111
+ this.dockedEdge = edge;
112
+ this.dockZone = zone;
113
+ if (edge) this.cancelIdleTimers();
114
+ this.refresh(this.action === 'peek');
115
+ }
116
+
117
+ signal(kind, eventId) {
118
+ if (kind === 'answered') return;
119
+ if (kind === 'connected' && this.living()) return;
120
+ if (kind === 'play') {
121
+ if (!this.canPlay()) return;
122
+ this.lastPlayAt = this.now();
123
+ this.resting = false;
124
+ this.cancelIdleTimers();
125
+ this.transient = { action: 'play' };
126
+ this.refresh();
127
+ return;
128
+ }
129
+ let requested = signals[kind];
130
+ if (kind === 'received' && (this.action === 'sleeping' || this.resting)) requested = 'waking';
131
+ if (!requested || this.dragging || staticSkins[this.skin]) return;
132
+ const now = this.now();
133
+ if (validId(eventId)) {
134
+ if (this.seenEvents.has(eventId)) return;
135
+ this.seenEvents.set(eventId, now + 3_600_000);
136
+ }
137
+ this.resting = false;
138
+ this.cancelIdleTimers();
139
+ this.transient = { action: requested };
140
+ this.refresh();
141
+ }
142
+
143
+ move(dx, dy) {
144
+ if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 6) return;
145
+ if (this.dragging) return;
146
+ this.dragging = true;
147
+ this.element.classList?.add('dragging');
148
+ this.element.dataset.interaction = 'held-candidate';
149
+ this.facts.suppressTravel();
150
+ this.resting = false;
151
+ this.transient = null;
152
+ this.cancelIdleTimers();
153
+ this.refresh();
154
+ }
155
+
156
+ stopMove() {
157
+ if (!this.dragging) return;
158
+ this.dragging = false;
159
+ this.element.classList?.remove('dragging');
160
+ delete this.element.dataset.interaction;
161
+ this.refresh();
162
+ }
163
+
164
+ close() {
165
+ this.cancelFrame();
166
+ this.cancelIdleTimers();
167
+ this.facts.close();
168
+ this.livingBody?.close();
169
+ }
170
+ living() { return this.skin === 'xianxian-codex' && Boolean(this.livingBody); }
171
+ resolve(action) {
172
+ if (action === 'pending_decision' && !this.living()) return 'waiting';
173
+ if (action === 'peek' && !this.living()) return 'idle';
174
+ if (staticSkins[this.skin]) return 'idle';
175
+ const visual = visualAlias[action] ?? action;
176
+ if (this.skin !== 'xianxian-codex' && visual in additional)
177
+ return visual === 'sleeping' ? 'idle' : 'review';
178
+ return action;
179
+ }
180
+
181
+ refresh(force = false) {
182
+ this.element.dataset.listening = String(this.listening());
183
+ const selection = this.select();
184
+ this.base = this.baseAction();
185
+ const action = this.resolve(selection.action);
186
+ const changed = action !== this.action;
187
+ if (changed || force || selection.oneShot !== this.oneShot) {
188
+ this.oneShot = selection.oneShot;
189
+ this.play(action, force);
190
+ }
191
+ if (action === 'idle') this.armRest();
192
+ else if (action !== 'sleeping') this.cancelRest();
193
+ }
194
+
195
+ select() {
196
+ if (this.dragging) return { action: 'held', oneShot: false };
197
+ if (this.transient?.action === 'failed') return { action: 'failed', oneShot: true };
198
+ if (this.pendingDecision) return { action: 'pending_decision', oneShot: false };
199
+ const work = this.activeWorkAction();
200
+ if (work) return { action: work, oneShot: false };
201
+ if (this.transient && this.transient.action !== 'play')
202
+ return { action: this.transient.action, oneShot: true };
203
+ const delivery = this.facts.consumeDelivery();
204
+ if (delivery) {
205
+ this.transient = { action: 'delivering', resultId: delivery.resultId };
206
+ return { action: 'delivering', oneShot: true };
207
+ }
208
+ if (!this.reducedMotion && this.snapshot.travel) {
209
+ const { dx, dy } = this.snapshot.travel;
210
+ return { action: Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? 'running-left' : 'running-right' : 'running', oneShot: false };
211
+ }
212
+ if (this.transient?.action === 'play') return { action: 'play', oneShot: true };
213
+ if (this.dockedEdge) return { action: 'peek', oneShot: false };
214
+ if (this.listening()) return { action: 'waiting', oneShot: false };
215
+ if (this.resting) return { action: 'sleeping', oneShot: false };
216
+ return { action: 'idle', oneShot: false };
217
+ }
218
+
219
+ baseAction() {
220
+ if (this.pendingDecision) return 'pending_decision';
221
+ const work = this.activeWorkAction();
222
+ if (work) return work;
223
+ if (this.dockedEdge) return 'peek';
224
+ if (this.listening()) return 'waiting';
225
+ return 'idle';
226
+ }
227
+
228
+ listening() { return ['talking', 'connecting'].includes(this.phase) && !this.context?.muted; }
229
+
230
+ activeWorkAction() {
231
+ if (this.snapshot.work) return workAction[this.snapshot.work.kind];
232
+ if (!this.hasNativeWork && this.context?.nativeActivity === 'tool_running') return 'working';
233
+ if (!this.hasNativeWork && this.context?.nativeActivity === 'reasoning') return 'staged_thought';
234
+ return null;
235
+ }
236
+
237
+ play(action, force = false) {
238
+ if (this.action === action && !force) return;
239
+ this.cancelFrame();
240
+ this.action = action;
241
+ this.frame = 0;
242
+ this.paint();
243
+ this.scheduleFrame();
244
+ }
245
+
246
+ paint() {
247
+ const { style, dataset } = this.element;
248
+ dataset.skin = this.skin;
249
+ dataset.action = this.action;
250
+ dataset.dockEdge = this.dockedEdge ?? 'none';
251
+ dataset.dockZone = this.dockZone ?? 'none';
252
+ if (this.living()) {
253
+ style.backgroundImage = 'none';
254
+ this.livingBody.show(this.action, { reducedMotion: this.reducedMotion, onEnded: () => this.completeOneShot() });
255
+ return;
256
+ }
257
+ this.livingBody?.hide();
258
+ const staticFile = staticSkins[this.skin];
259
+ if (staticFile) {
260
+ style.backgroundImage = `url("./skins/${staticFile}")`;
261
+ style.backgroundSize = 'contain';
262
+ style.backgroundPosition = 'center';
263
+ return;
264
+ }
265
+ const visual = visualAlias[this.action] ?? this.action;
266
+ const extra = this.skin === 'xianxian-codex' ? additional[visual] : undefined;
267
+ style.backgroundImage = `url("./skins/${extra?.file ?? `${this.skin}.webp`}")`;
268
+ style.backgroundSize = extra ? '960px 130px' : '960px 1170px';
269
+ style.backgroundPosition = `${-120 * this.frame}px ${-130 * (atlas[visual]?.row ?? 0)}px`;
270
+ }
271
+
272
+ scheduleFrame() {
273
+ if (this.reducedMotion) {
274
+ if (this.oneShot) this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 1_000);
275
+ return;
276
+ }
277
+ if (staticSkins[this.skin]) return;
278
+ if (this.living()) {
279
+ if (isLivingClip(this.action) || !this.oneShot) return;
280
+ this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 700);
281
+ return;
282
+ }
283
+ const visual = visualAlias[this.action] ?? this.action;
284
+ const frames = additional[visual]?.frames ?? atlas[visual]?.frames;
285
+ if (!frames) return;
286
+ this.frameTimer = this.setTimer(() => {
287
+ this.frameTimer = undefined;
288
+ if (this.frame + 1 === frames.length && this.oneShot) {
289
+ this.completeOneShot();
290
+ return;
291
+ }
292
+ this.frame = (this.frame + 1) % frames.length;
293
+ this.paint();
294
+ this.scheduleFrame();
295
+ }, frames[this.frame]);
296
+ }
297
+
298
+ cancelFrame() {
299
+ if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer);
300
+ this.frameTimer = undefined;
301
+ }
302
+
303
+ completeOneShot() {
304
+ if (!this.oneShot) return;
305
+ this.oneShot = false;
306
+ this.transient = null;
307
+ this.action = '';
308
+ this.refresh();
309
+ }
310
+
311
+ canPlay() {
312
+ return !this.reducedMotion && !this.dragging && !this.pendingDecision && this.phase === 'idle'
313
+ && !this.activeWorkAction() && !this.snapshot.delivery && !this.snapshot.travel && !this.dockedEdge
314
+ && !staticSkins[this.skin];
315
+ }
316
+
317
+ armRest() {
318
+ if (this.restTimer === undefined && !this.resting && !this.pendingDecision && !this.dockedEdge) {
319
+ this.restTimer = this.setTimer(() => {
320
+ this.restTimer = undefined;
321
+ if (this.action === 'idle' && !this.dragging && !this.transient) {
322
+ this.resting = true;
323
+ this.refresh();
324
+ }
325
+ }, 45_000);
326
+ }
327
+ if (this.autoPlay && !this.reducedMotion && this.autoTimer === undefined) {
328
+ const delay = Math.max(this.autoPlayIdleMs, this.lastPlayAt + this.autoPlayCooldownMs - this.now());
329
+ this.autoTimer = this.setTimer(() => {
330
+ this.autoTimer = undefined;
331
+ if (!this.canPlay()) return;
332
+ this.lastPlayAt = this.now();
333
+ this.resting = false;
334
+ this.transient = { action: 'play' };
335
+ this.refresh();
336
+ }, delay);
337
+ }
338
+ }
339
+
340
+ cancelRest() {
341
+ if (this.restTimer !== undefined) this.clearTimer(this.restTimer);
342
+ this.restTimer = undefined;
343
+ }
344
+
345
+ cancelIdleTimers() {
346
+ this.cancelRest();
347
+ if (this.autoTimer !== undefined) this.clearTimer(this.autoTimer);
348
+ this.autoTimer = undefined;
349
+ }
350
+
351
+ get snapshot() { return this.facts.snapshot; }
352
+ }
@@ -0,0 +1,39 @@
1
+ /** A compact projection of Host history plus the current unsaved voice caption. */
2
+ export class RecentBubble {
3
+ constructor(rows) {
4
+ this.rows = rows;
5
+ this.history = [];
6
+ this.live = new Map();
7
+ }
8
+ load(messages) {
9
+ this.history = messages.slice(-2);
10
+ this.render();
11
+ }
12
+ append(role, text) {
13
+ if (!['user', 'assistant'].includes(role) || typeof text !== 'string') return;
14
+ this.live.set(role, `${this.live.get(role) ?? ''}${text}`.slice(-240));
15
+ this.render();
16
+ }
17
+ finish(role) {
18
+ this.live.delete(role);
19
+ this.render();
20
+ }
21
+ reset() {
22
+ this.live.clear();
23
+ this.render();
24
+ }
25
+ hasContent() {
26
+ return this.history.length > 0 || this.live.size > 0;
27
+ }
28
+ render() {
29
+ const live = [...this.live].map(([role, text]) => ({ role, name: role === 'user' ? '你' : '猫猫', text }));
30
+ const entries = [...this.history.slice(-Math.max(0, 2 - live.length)), ...live].slice(-2);
31
+ for (const [index, row] of this.rows.entries()) {
32
+ const entry = entries[index];
33
+ row.hidden = !entry;
34
+ row.textContent = entry ? `${String(entry.name).slice(0, 28)}:${String(entry.text).slice(-120)}` : '';
35
+ row.dataset ??= {};
36
+ row.dataset.role = entry?.role ?? '';
37
+ }
38
+ }
39
+ }
Binary file
Binary file
@@ -0,0 +1,88 @@
1
+ {
2
+ "canvas": 600,
3
+ "tailPivot": [
4
+ 168.1,
5
+ 456.1
6
+ ],
7
+ "earPivotL": [
8
+ 218.9,
9
+ 101.4
10
+ ],
11
+ "earPivotR": [
12
+ 391.0,
13
+ 100.2
14
+ ],
15
+ "neckPivot": [
16
+ 300.0,
17
+ 280.4
18
+ ],
19
+ "irisL": [
20
+ 231.1,
21
+ 156.5,
22
+ 285.6,
23
+ 213.4
24
+ ],
25
+ "irisR": [
26
+ 339.7,
27
+ 154.5,
28
+ 388.0,
29
+ 211.0
30
+ ],
31
+ "whiteL": [
32
+ 230.6,
33
+ 156.5,
34
+ 294.3,
35
+ 221.1
36
+ ],
37
+ "whiteR": [
38
+ 331.1,
39
+ 154.5,
40
+ 391.4,
41
+ 219.6
42
+ ],
43
+ "tail": [
44
+ 47.8,
45
+ 313.4,
46
+ 178.9,
47
+ 507.2
48
+ ],
49
+ "earL": [
50
+ 169.4,
51
+ 26.8,
52
+ 258.4,
53
+ 140.7
54
+ ],
55
+ "earR": [
56
+ 345.9,
57
+ 20.1,
58
+ 437.8,
59
+ 140.7
60
+ ],
61
+ "mouth": [
62
+ 286.6,
63
+ 221.5,
64
+ 343.1,
65
+ 256.9
66
+ ],
67
+ "areas_native": {
68
+ "tail": 78634,
69
+ "earL": 23228,
70
+ "earR": 23149,
71
+ "fearL": 9568,
72
+ "fearR": 9138,
73
+ "irisL": 7903,
74
+ "irisR": 7624,
75
+ "whiteL": 10967,
76
+ "whiteR": 10974,
77
+ "mouth": 4342
78
+ },
79
+ "collarTop_native": 580,
80
+ "sitFloorNative": 1135,
81
+ "runCropNative": [
82
+ 27,
83
+ 181,
84
+ 1220,
85
+ 1022
86
+ ],
87
+ "runFloorNative": 1016
88
+ }
Binary file