flawed-avatar 0.2.1

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 (152) hide show
  1. package/assets/animations/idle/Breathing Idle.fbx +0 -0
  2. package/assets/animations/idle/look away gesture.fbx +0 -0
  3. package/assets/animations/idle/weight shift.fbx +0 -0
  4. package/assets/animations/speaking/Agreeing.fbx +0 -0
  5. package/assets/animations/speaking/Talking (1).fbx +0 -0
  6. package/assets/animations/speaking/Talking (2).fbx +0 -0
  7. package/assets/animations/speaking/Talking (3).fbx +0 -0
  8. package/assets/animations/speaking/Talking.fbx +0 -0
  9. package/assets/animations/speaking/head nod yes.fbx +0 -0
  10. package/assets/animations/thinking/Thinking.fbx +0 -0
  11. package/assets/animations/thinking/thoughtful head shake.fbx +0 -0
  12. package/assets/animations/working/acknowledging.fbx +0 -0
  13. package/assets/animations/working/lengthy head nod.fbx +0 -0
  14. package/assets/icon.png +0 -0
  15. package/assets/models/CaptainLobster.vrm +0 -0
  16. package/assets/models/default-avatar.vrm +0 -0
  17. package/dist/chat-preload.cjs +87 -0
  18. package/dist/chat-renderer-bundle/chat-index.html +16 -0
  19. package/dist/chat-renderer-bundle/chat-renderer.js +355 -0
  20. package/dist/chat-renderer-bundle/styles/base.css +106 -0
  21. package/dist/chat-renderer-bundle/styles/chat.css +516 -0
  22. package/dist/chat-renderer-bundle/styles/components/button.css +221 -0
  23. package/dist/chat-renderer-bundle/styles/components/indicator.css +216 -0
  24. package/dist/chat-renderer-bundle/styles/components/input.css +139 -0
  25. package/dist/chat-renderer-bundle/styles/components/toast.css +204 -0
  26. package/dist/chat-renderer-bundle/styles/controls.css +279 -0
  27. package/dist/chat-renderer-bundle/styles/settings.css +310 -0
  28. package/dist/chat-renderer-bundle/styles/tokens.css +220 -0
  29. package/dist/chat-renderer-bundle/styles/utilities.css +349 -0
  30. package/dist/main/main/display-utils.d.ts +12 -0
  31. package/dist/main/main/display-utils.js +29 -0
  32. package/dist/main/main/gateway-client.d.ts +13 -0
  33. package/dist/main/main/gateway-client.js +265 -0
  34. package/dist/main/main/main.d.ts +1 -0
  35. package/dist/main/main/main.js +157 -0
  36. package/dist/main/main/persistence/chat-store.d.ts +8 -0
  37. package/dist/main/main/persistence/chat-store.js +110 -0
  38. package/dist/main/main/persistence/file-store.d.ts +17 -0
  39. package/dist/main/main/persistence/file-store.js +183 -0
  40. package/dist/main/main/persistence/index.d.ts +8 -0
  41. package/dist/main/main/persistence/index.js +8 -0
  42. package/dist/main/main/persistence/migrations.d.ts +23 -0
  43. package/dist/main/main/persistence/migrations.js +191 -0
  44. package/dist/main/main/persistence/settings-store.d.ts +32 -0
  45. package/dist/main/main/persistence/settings-store.js +174 -0
  46. package/dist/main/main/persistence/types.d.ts +72 -0
  47. package/dist/main/main/persistence/types.js +69 -0
  48. package/dist/main/main/settings-broadcast.d.ts +3 -0
  49. package/dist/main/main/settings-broadcast.js +9 -0
  50. package/dist/main/main/stdin-listener.d.ts +15 -0
  51. package/dist/main/main/stdin-listener.js +27 -0
  52. package/dist/main/main/tray.d.ts +3 -0
  53. package/dist/main/main/tray.js +59 -0
  54. package/dist/main/main/window-manager.d.ts +23 -0
  55. package/dist/main/main/window-manager.js +232 -0
  56. package/dist/main/main/window.d.ts +3 -0
  57. package/dist/main/main/window.js +528 -0
  58. package/dist/main/shared/config.d.ts +91 -0
  59. package/dist/main/shared/config.js +111 -0
  60. package/dist/main/shared/ipc-channels.d.ts +54 -0
  61. package/dist/main/shared/ipc-channels.js +68 -0
  62. package/dist/main/shared/types.d.ts +6 -0
  63. package/dist/main/shared/types.js +1 -0
  64. package/dist/preload.cjs +256 -0
  65. package/dist/renderer-bundle/index.html +63 -0
  66. package/dist/renderer-bundle/renderer.js +100734 -0
  67. package/dist/renderer-bundle/styles/base.css +106 -0
  68. package/dist/renderer-bundle/styles/chat.css +516 -0
  69. package/dist/renderer-bundle/styles/components/button.css +221 -0
  70. package/dist/renderer-bundle/styles/components/indicator.css +216 -0
  71. package/dist/renderer-bundle/styles/components/input.css +139 -0
  72. package/dist/renderer-bundle/styles/components/toast.css +204 -0
  73. package/dist/renderer-bundle/styles/controls.css +279 -0
  74. package/dist/renderer-bundle/styles/settings.css +310 -0
  75. package/dist/renderer-bundle/styles/tokens.css +220 -0
  76. package/dist/renderer-bundle/styles/utilities.css +349 -0
  77. package/index.ts +32 -0
  78. package/openclaw.plugin.json +22 -0
  79. package/package.json +45 -0
  80. package/src/electron-launcher.ts +63 -0
  81. package/src/main/chat-preload.cjs +87 -0
  82. package/src/main/display-utils.ts +39 -0
  83. package/src/main/gateway-client.ts +312 -0
  84. package/src/main/main.ts +169 -0
  85. package/src/main/persistence/chat-store.ts +143 -0
  86. package/src/main/persistence/file-store.ts +221 -0
  87. package/src/main/persistence/index.ts +69 -0
  88. package/src/main/persistence/migrations.ts +232 -0
  89. package/src/main/persistence/settings-store.ts +219 -0
  90. package/src/main/persistence/types.ts +107 -0
  91. package/src/main/preload.cjs +256 -0
  92. package/src/main/settings-broadcast.ts +13 -0
  93. package/src/main/settings-preload.cjs +153 -0
  94. package/src/main/stdin-listener.ts +34 -0
  95. package/src/main/tray.ts +65 -0
  96. package/src/main/window-manager.ts +298 -0
  97. package/src/main/window.ts +614 -0
  98. package/src/renderer/audio/audio-player.ts +161 -0
  99. package/src/renderer/audio/frequency-analyzer.ts +104 -0
  100. package/src/renderer/audio/index.ts +36 -0
  101. package/src/renderer/audio/kokoro-model-loader.ts +128 -0
  102. package/src/renderer/audio/kokoro-tts-service.ts +370 -0
  103. package/src/renderer/audio/lip-sync-profile.json +1 -0
  104. package/src/renderer/audio/phoneme-mapper.ts +120 -0
  105. package/src/renderer/audio/tts-controller.ts +344 -0
  106. package/src/renderer/audio/tts-service-factory.ts +75 -0
  107. package/src/renderer/audio/tts-service.ts +16 -0
  108. package/src/renderer/audio/types.ts +120 -0
  109. package/src/renderer/audio/web-speech-tts.ts +177 -0
  110. package/src/renderer/audio/wlipsync-analyzer.ts +145 -0
  111. package/src/renderer/avatar/animation-loader.ts +114 -0
  112. package/src/renderer/avatar/animator.ts +322 -0
  113. package/src/renderer/avatar/expressions.ts +165 -0
  114. package/src/renderer/avatar/eye-gaze.ts +255 -0
  115. package/src/renderer/avatar/eye-saccades.ts +133 -0
  116. package/src/renderer/avatar/hover-awareness.ts +125 -0
  117. package/src/renderer/avatar/ibl-enhancer.ts +163 -0
  118. package/src/renderer/avatar/lip-sync.ts +258 -0
  119. package/src/renderer/avatar/mixamo-retarget.ts +169 -0
  120. package/src/renderer/avatar/pixel-transparency.ts +65 -0
  121. package/src/renderer/avatar/scene.ts +70 -0
  122. package/src/renderer/avatar/spring-bones.ts +27 -0
  123. package/src/renderer/avatar/state-machine.ts +117 -0
  124. package/src/renderer/avatar/vrm-loader.ts +71 -0
  125. package/src/renderer/chat-window/chat-index.html +16 -0
  126. package/src/renderer/chat-window/chat-renderer.ts +28 -0
  127. package/src/renderer/index.html +63 -0
  128. package/src/renderer/renderer.ts +329 -0
  129. package/src/renderer/settings-window/settings-controls.ts +223 -0
  130. package/src/renderer/settings-window/settings-index.html +16 -0
  131. package/src/renderer/settings-window/settings-panel.ts +346 -0
  132. package/src/renderer/settings-window/settings-renderer.ts +5 -0
  133. package/src/renderer/styles/base.css +106 -0
  134. package/src/renderer/styles/chat.css +516 -0
  135. package/src/renderer/styles/components/button.css +221 -0
  136. package/src/renderer/styles/components/indicator.css +216 -0
  137. package/src/renderer/styles/components/input.css +139 -0
  138. package/src/renderer/styles/components/toast.css +204 -0
  139. package/src/renderer/styles/controls.css +279 -0
  140. package/src/renderer/styles/settings.css +310 -0
  141. package/src/renderer/styles/tokens.css +220 -0
  142. package/src/renderer/styles/utilities.css +349 -0
  143. package/src/renderer/types/avatar-bridge.d.ts +86 -0
  144. package/src/renderer/types/chat-bridge.d.ts +37 -0
  145. package/src/renderer/types/settings-bridge.d.ts +54 -0
  146. package/src/renderer/ui/chat-bubble.ts +435 -0
  147. package/src/renderer/ui/icons.ts +47 -0
  148. package/src/renderer/ui/typing-indicator.ts +41 -0
  149. package/src/service.ts +163 -0
  150. package/src/shared/config.ts +135 -0
  151. package/src/shared/ipc-channels.ts +81 -0
  152. package/src/shared/types.ts +7 -0
@@ -0,0 +1,312 @@
1
+ import WebSocket from "ws";
2
+ import { randomUUID } from "node:crypto";
3
+ import { GATEWAY_RECONNECT_BASE_MS, GATEWAY_RECONNECT_MAX_MS } from "../shared/config.js";
4
+ import type { AgentState } from "../shared/types.js";
5
+
6
+ const PROTOCOL_VERSION = 3;
7
+
8
+ type AgentEventPayload = {
9
+ runId: string;
10
+ seq: number;
11
+ stream: string;
12
+ ts: number;
13
+ data: Record<string, unknown>;
14
+ sessionKey?: string;
15
+ };
16
+
17
+ type EventFrame = {
18
+ type: "event";
19
+ event: string;
20
+ payload?: unknown;
21
+ seq?: number;
22
+ };
23
+
24
+ type ResponseFrame = {
25
+ type: "res";
26
+ id: string;
27
+ ok: boolean;
28
+ payload?: unknown;
29
+ error?: { message?: string };
30
+ };
31
+
32
+ /**
33
+ * Lightweight gateway WebSocket client for the Electron main process.
34
+ * Implements the minimal protocol v3 handshake (without device auth)
35
+ * and listens for "agent" event frames to drive avatar animations.
36
+ */
37
+ export function createGatewayClient(
38
+ gatewayUrl: string,
39
+ onStateChange: (state: AgentState) => void,
40
+ onModelSwitch: (vrmPath: string) => void,
41
+ agentConfigs?: Record<string, { vrmPath?: string }>,
42
+ authToken?: string,
43
+ ): { destroy: () => void; sendChat: (text: string, sessionKey: string | null) => void; getCurrentAgentId: () => string | null } {
44
+ let ws: WebSocket | null = null;
45
+ let destroyed = false;
46
+ let backoffMs = GATEWAY_RECONNECT_BASE_MS;
47
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
48
+ let connectNonce: string | null = null;
49
+ let connectSent = false;
50
+ let connectTimer: ReturnType<typeof setTimeout> | null = null;
51
+ let currentSessionKey: string | null = null;
52
+ // Track pending request IDs to match responses
53
+ let sessionsListRequestId: string | null = null;
54
+ let agentsListRequestId: string | null = null;
55
+ // Track if we've completed initial connection setup
56
+ let connectionSetupDone = false;
57
+
58
+ function processAgentEvent(evt: AgentEventPayload): void {
59
+ const { stream, data, sessionKey } = evt;
60
+
61
+ // Track session changes - agent events contain the actual sessionKey
62
+ if (sessionKey && sessionKey !== currentSessionKey) {
63
+ console.log("flawed-avatar: setting currentSessionKey from agent event:", sessionKey);
64
+ currentSessionKey = sessionKey;
65
+ if (agentConfigs?.[sessionKey]?.vrmPath) {
66
+ onModelSwitch(agentConfigs[sessionKey].vrmPath!);
67
+ }
68
+ }
69
+
70
+ if (stream === "lifecycle") {
71
+ const phase = data?.phase;
72
+ if (phase === "start") {
73
+ onStateChange({ phase: "thinking", agentId: sessionKey });
74
+ } else if (phase === "end" || phase === "error") {
75
+ onStateChange({ phase: "idle", agentId: sessionKey });
76
+ }
77
+ } else if (stream === "assistant") {
78
+ const text = typeof data?.text === "string" ? data.text : undefined;
79
+ onStateChange({ phase: "speaking", text, agentId: sessionKey });
80
+ } else if (stream === "tool") {
81
+ onStateChange({ phase: "working", agentId: sessionKey });
82
+ } else if (stream === "error") {
83
+ onStateChange({ phase: "idle", agentId: sessionKey });
84
+ }
85
+ }
86
+
87
+ function handleMessage(raw: string): void {
88
+ try {
89
+ const parsed = JSON.parse(raw);
90
+
91
+ // Event frames
92
+ if (parsed?.type === "event") {
93
+ const evt = parsed as EventFrame;
94
+
95
+ // Handle connect challenge: gateway sends a nonce before we send connect
96
+ if (evt.event === "connect.challenge") {
97
+ const payload = evt.payload as { nonce?: string } | undefined;
98
+ if (payload?.nonce) {
99
+ connectNonce = payload.nonce;
100
+ sendConnect();
101
+ }
102
+ return;
103
+ }
104
+
105
+ // Agent events drive the avatar
106
+ if (evt.event === "agent" && evt.payload) {
107
+ processAgentEvent(evt.payload as AgentEventPayload);
108
+ }
109
+ return;
110
+ }
111
+
112
+ // Response frames (for our connect request, sessions list, or agents list)
113
+ if (parsed?.type === "res") {
114
+ const res = parsed as ResponseFrame;
115
+ if (res.ok) {
116
+ const payload = res.payload as Record<string, unknown> | undefined;
117
+
118
+ // Check if this is a sessions.list response
119
+ if (res.id === sessionsListRequestId && payload?.sessions && Array.isArray(payload.sessions)) {
120
+ sessionsListRequestId = null;
121
+ const sessions = payload.sessions as Array<{ key?: string; updatedAt?: number; displayName?: string }>;
122
+ if (sessions.length > 0 && !currentSessionKey) {
123
+ // Use the most recently active session (sessions are sorted by activity)
124
+ const firstSession = sessions[0];
125
+ if (firstSession.key) {
126
+ currentSessionKey = firstSession.key;
127
+ console.log("flawed-avatar: auto-detected session from sessions.list:", currentSessionKey, "displayName:", firstSession.displayName);
128
+ }
129
+ }
130
+ }
131
+
132
+ // Check if this is an agents list response (fallback)
133
+ if (res.id === agentsListRequestId && payload?.agents && Array.isArray(payload.agents)) {
134
+ agentsListRequestId = null;
135
+ const agents = payload.agents as Array<Record<string, unknown>>;
136
+ if (agents.length > 0 && !currentSessionKey) {
137
+ // Use first agent's main session key as fallback
138
+ const firstAgent = agents[0];
139
+ const agentId = firstAgent.id ?? "main";
140
+ const sessionKey = `agent:${agentId}:main`;
141
+ currentSessionKey = sessionKey;
142
+ console.log("flawed-avatar: fallback to agent main session:", currentSessionKey);
143
+ }
144
+ }
145
+
146
+ // Connect success - request active sessions once
147
+ if (connectSent && !connectionSetupDone && !sessionsListRequestId && !agentsListRequestId) {
148
+ connectionSetupDone = true;
149
+ console.log("flawed-avatar: gateway connected successfully");
150
+ backoffMs = GATEWAY_RECONNECT_BASE_MS;
151
+ // Request recently active sessions
152
+ requestSessionsList();
153
+ }
154
+ } else {
155
+ console.error("flawed-avatar: gateway response error:", res.error?.message ?? "unknown");
156
+ // If sessions.list failed, fall back to agents.list
157
+ if (res.id === sessionsListRequestId) {
158
+ sessionsListRequestId = null;
159
+ if (!currentSessionKey) {
160
+ requestAgentsList();
161
+ }
162
+ }
163
+ }
164
+ }
165
+ } catch (err) {
166
+ console.warn("flawed-avatar: failed to parse gateway message:", err);
167
+ }
168
+ }
169
+
170
+ function sendConnect(): void {
171
+ if (connectSent || !ws || ws.readyState !== WebSocket.OPEN) return;
172
+ connectSent = true;
173
+
174
+ if (connectTimer) {
175
+ clearTimeout(connectTimer);
176
+ connectTimer = null;
177
+ }
178
+
179
+ const frame = {
180
+ type: "req",
181
+ id: randomUUID(),
182
+ method: "connect",
183
+ params: {
184
+ minProtocol: PROTOCOL_VERSION,
185
+ maxProtocol: PROTOCOL_VERSION,
186
+ client: {
187
+ id: "gateway-client",
188
+ displayName: "Flawed Avatar",
189
+ version: "0.1.0",
190
+ platform: process.platform,
191
+ mode: "backend",
192
+ },
193
+ caps: [],
194
+ role: "operator",
195
+ scopes: ["operator.admin"],
196
+ auth: authToken ? { token: authToken } : {},
197
+ },
198
+ };
199
+
200
+ ws.send(JSON.stringify(frame));
201
+ }
202
+
203
+ function queueConnect(): void {
204
+ connectNonce = null;
205
+ connectSent = false;
206
+ connectionSetupDone = false; // Reset for new connection
207
+ if (connectTimer) clearTimeout(connectTimer);
208
+ connectTimer = setTimeout(() => sendConnect(), 750);
209
+ }
210
+
211
+ function requestSessionsList(): void {
212
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
213
+ sessionsListRequestId = randomUUID();
214
+ const frame = {
215
+ type: "req",
216
+ id: sessionsListRequestId,
217
+ method: "sessions.list",
218
+ params: {
219
+ // Get recently active sessions (within last 60 minutes)
220
+ activeMinutes: 60,
221
+ includeGlobal: false,
222
+ includeUnknown: false,
223
+ limit: 10,
224
+ },
225
+ };
226
+ console.log("flawed-avatar: requesting sessions list");
227
+ ws.send(JSON.stringify(frame));
228
+ }
229
+
230
+ function requestAgentsList(): void {
231
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
232
+ agentsListRequestId = randomUUID();
233
+ const frame = {
234
+ type: "req",
235
+ id: agentsListRequestId,
236
+ method: "agents.list",
237
+ params: {},
238
+ };
239
+ console.log("flawed-avatar: requesting agents list (fallback)");
240
+ ws.send(JSON.stringify(frame));
241
+ }
242
+
243
+ function connect(): void {
244
+ if (destroyed) return;
245
+
246
+ ws = new WebSocket(gatewayUrl, { maxPayload: 25 * 1024 * 1024 });
247
+
248
+ ws.on("open", () => {
249
+ console.log("flawed-avatar: ws open, sending connect frame");
250
+ queueConnect();
251
+ });
252
+
253
+ ws.on("message", (data) => {
254
+ const raw = typeof data === "string" ? data : data.toString();
255
+ handleMessage(raw);
256
+ });
257
+
258
+ ws.on("close", (code, reason) => {
259
+ console.log(`flawed-avatar: ws closed (code=${code}, reason=${reason?.toString() ?? ""})`);
260
+ ws = null;
261
+ scheduleReconnect();
262
+ });
263
+
264
+ ws.on("error", (err) => {
265
+ console.error("flawed-avatar: gateway connection error:", err.message);
266
+ });
267
+ }
268
+
269
+ function scheduleReconnect(): void {
270
+ if (destroyed) return;
271
+ const delay = backoffMs;
272
+ backoffMs = Math.min(backoffMs * 2, GATEWAY_RECONNECT_MAX_MS);
273
+ reconnectTimer = setTimeout(() => connect(), delay);
274
+ }
275
+
276
+ // Start the initial connection
277
+ connect();
278
+
279
+ return {
280
+ destroy() {
281
+ destroyed = true;
282
+ if (reconnectTimer) clearTimeout(reconnectTimer);
283
+ if (connectTimer) clearTimeout(connectTimer);
284
+ if (ws) {
285
+ ws.removeAllListeners();
286
+ ws.close();
287
+ ws = null;
288
+ }
289
+ },
290
+
291
+ sendChat(text: string, sessionKey: string | null) {
292
+ // Use provided sessionKey, or fall back to auto-detected session, or default
293
+ const effectiveSessionKey = sessionKey ?? currentSessionKey ?? "agent:main:main";
294
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
295
+ const frame = {
296
+ type: "req",
297
+ id: randomUUID(),
298
+ method: "chat.send",
299
+ params: {
300
+ sessionKey: effectiveSessionKey,
301
+ message: text,
302
+ idempotencyKey: randomUUID(),
303
+ },
304
+ };
305
+ ws.send(JSON.stringify(frame));
306
+ },
307
+
308
+ getCurrentAgentId() {
309
+ return currentSessionKey;
310
+ },
311
+ };
312
+ }
@@ -0,0 +1,169 @@
1
+ import { app, ipcMain } from "electron";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createWindowManager } from "./window-manager.js";
7
+ import { createTray } from "./tray.js";
8
+ import { createStdinListener, type StdinCommand } from "./stdin-listener.js";
9
+ import { createGatewayClient } from "./gateway-client.js";
10
+ import { IPC } from "../shared/ipc-channels.js";
11
+ import { GATEWAY_URL_DEFAULT, CHAT_INPUT_MAX_LENGTH } from "../shared/config.js";
12
+ import { getVrmModelPath, saveVrmModelPath } from "./persistence/index.js";
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+
17
+ // Parse CLI args
18
+ function getCliArg(prefix: string): string | undefined {
19
+ for (const arg of process.argv) {
20
+ if (arg.startsWith(prefix)) {
21
+ return arg.slice(prefix.length);
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ const cliGatewayUrl = getCliArg("--gateway-url=");
28
+ const cliVrmPath = getCliArg("--vrm-path=");
29
+ const cliAgentConfigs = getCliArg("--agent-configs=");
30
+ const cliAuthToken = getCliArg("--auth-token=");
31
+
32
+ // Resolve auth token: CLI arg > env var > openclaw.json
33
+ function resolveAuthToken(): string | undefined {
34
+ if (cliAuthToken) return cliAuthToken;
35
+ if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN;
36
+ try {
37
+ const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
38
+ const raw = fs.readFileSync(configPath, "utf-8");
39
+ const config = JSON.parse(raw);
40
+ const token = config?.gateway?.auth?.token;
41
+ if (typeof token === "string" && token.length > 0) return token;
42
+ } catch {
43
+ // No config or unreadable
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ // Parse per-agent VRM configs if provided (with prototype pollution protection)
49
+ let agentConfigs: Record<string, { vrmPath?: string }> | undefined;
50
+ if (cliAgentConfigs) {
51
+ try {
52
+ const parsed: unknown = JSON.parse(cliAgentConfigs);
53
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
54
+ const safe: Record<string, { vrmPath?: string }> = Object.create(null);
55
+ for (const [key, val] of Object.entries(parsed as Record<string, unknown>)) {
56
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
57
+ if (typeof val === "object" && val !== null) {
58
+ const v = val as Record<string, unknown>;
59
+ safe[key] = { vrmPath: typeof v.vrmPath === "string" ? v.vrmPath : undefined };
60
+ }
61
+ }
62
+ agentConfigs = safe;
63
+ }
64
+ } catch {
65
+ // Ignore malformed JSON
66
+ }
67
+ }
68
+
69
+ // Prevent multiple instances
70
+ const gotLock = app.requestSingleInstanceLock();
71
+ if (!gotLock) {
72
+ app.quit();
73
+ }
74
+
75
+ app.whenReady().then(() => {
76
+ const wm = createWindowManager();
77
+ createTray(wm);
78
+
79
+ // Return VRM model path (CLI override > persisted > default)
80
+ const defaultVrmPath = path.join(__dirname, "..", "..", "..", "assets", "models", "default-avatar.vrm");
81
+ ipcMain.handle(IPC.GET_VRM_PATH, () => {
82
+ if (cliVrmPath) return cliVrmPath;
83
+ const persisted = getVrmModelPath();
84
+ if (persisted && fs.existsSync(persisted)) return persisted;
85
+ return defaultVrmPath;
86
+ });
87
+
88
+ // Return animation clip paths from assets/animations/{phase}/ directories
89
+ ipcMain.handle(IPC.GET_ANIMATIONS_CONFIG, () => {
90
+ const animBase = path.resolve(__dirname, "..", "..", "..", "assets", "animations");
91
+ const phases = ["idle", "thinking", "speaking", "working"] as const;
92
+ const clips: Record<string, string[]> = {};
93
+
94
+ for (const phase of phases) {
95
+ const dir = path.join(animBase, phase);
96
+ try {
97
+ clips[phase] = fs.readdirSync(dir)
98
+ .filter(f => f.toLowerCase().endsWith(".fbx"))
99
+ .filter(f => !/[\\\/]/.test(f))
100
+ .map(f => {
101
+ const full = fs.realpathSync(path.join(dir, f));
102
+ if (!full.replace(/\\/g, "/").startsWith(animBase.replace(/\\/g, "/"))) {
103
+ return null;
104
+ }
105
+ return full;
106
+ })
107
+ .filter((f): f is string => f !== null);
108
+ } catch {
109
+ clips[phase] = [];
110
+ }
111
+ }
112
+ return { clips };
113
+ });
114
+
115
+ // Stdin listener for commands from the plugin service
116
+ const cleanupStdin = createStdinListener((cmd: StdinCommand) => {
117
+ switch (cmd.type) {
118
+ case "show":
119
+ wm.showAvatar();
120
+ break;
121
+ case "hide":
122
+ wm.hideAll();
123
+ break;
124
+ case "shutdown":
125
+ app.quit();
126
+ break;
127
+ case "model-switch":
128
+ wm.sendToAvatar(IPC.VRM_MODEL_CHANGED, cmd.vrmPath);
129
+ break;
130
+ }
131
+ });
132
+
133
+ // Connect to gateway WebSocket for agent event streaming
134
+ const gatewayUrl = cliGatewayUrl ?? GATEWAY_URL_DEFAULT;
135
+ const authToken = resolveAuthToken();
136
+ console.log(`flawed-avatar: connecting to ${gatewayUrl} (auth=${authToken ? "token" : "none"})`);
137
+ const gw = createGatewayClient(
138
+ gatewayUrl,
139
+ (state) => wm.sendAgentState(state),
140
+ (vrmPath) => wm.sendToAvatar(IPC.VRM_MODEL_CHANGED, vrmPath),
141
+ agentConfigs,
142
+ authToken,
143
+ );
144
+
145
+ // IPC: send chat message to active agent
146
+ ipcMain.on(IPC.SEND_CHAT, (_event, text: unknown) => {
147
+ console.log("flawed-avatar: SEND_CHAT received:", text);
148
+ if (typeof text !== "string" || text.trim().length === 0 || text.length > CHAT_INPUT_MAX_LENGTH) {
149
+ console.log("flawed-avatar: SEND_CHAT rejected (validation failed)");
150
+ return;
151
+ }
152
+ const agentId = gw.getCurrentAgentId();
153
+ console.log("flawed-avatar: current agentId:", agentId);
154
+ console.log("flawed-avatar: sending chat to gateway");
155
+ gw.sendChat(text.trim(), agentId);
156
+ });
157
+
158
+ // Clean up resources on quit
159
+ app.on("before-quit", () => {
160
+ gw.destroy();
161
+ cleanupStdin();
162
+ wm.destroyAll();
163
+ });
164
+ });
165
+
166
+ // Keep app alive when all windows are closed (tray stays)
167
+ app.on("window-all-closed", () => {
168
+ // no-op: tray keeps the app alive
169
+ });
@@ -0,0 +1,143 @@
1
+ import {
2
+ CHAT_HISTORY_FILE,
3
+ CHAT_MAX_HISTORY,
4
+ CHAT_DEBOUNCE_MS,
5
+ APPEND_QUEUE_FLUSH_MS,
6
+ } from "../../shared/config.js";
7
+ import { createFileStore, type FileStore } from "./file-store.js";
8
+ import {
9
+ ChatHistorySchema,
10
+ createDefaultChatHistory,
11
+ type ChatHistory,
12
+ type ChatMessage,
13
+ } from "./types.js";
14
+
15
+ let store: FileStore<ChatHistory> | null = null;
16
+ let appendQueue: ChatMessage[] = [];
17
+ let appendTimeout: ReturnType<typeof setTimeout> | null = null;
18
+
19
+ function getStore(): FileStore<ChatHistory> {
20
+ if (!store) {
21
+ store = createFileStore({
22
+ filename: CHAT_HISTORY_FILE,
23
+ schema: ChatHistorySchema,
24
+ defaultValue: createDefaultChatHistory,
25
+ debounceMs: CHAT_DEBOUNCE_MS,
26
+ });
27
+ }
28
+ return store;
29
+ }
30
+
31
+ function generateId(): string {
32
+ // Simple unique ID: timestamp + random hex
33
+ return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
34
+ }
35
+
36
+ function flushAppendQueue(): void {
37
+ if (appendQueue.length === 0) return;
38
+
39
+ const current = getStore().getCache() ?? loadChatHistory();
40
+ const messages = [...current.messages, ...appendQueue];
41
+
42
+ // Prune if over limit
43
+ const pruned = messages.length > CHAT_MAX_HISTORY
44
+ ? messages.slice(-CHAT_MAX_HISTORY)
45
+ : messages;
46
+
47
+ const updated: ChatHistory = {
48
+ ...current,
49
+ messages: pruned,
50
+ lastUpdated: Date.now(),
51
+ };
52
+
53
+ getStore().save(updated);
54
+ appendQueue = [];
55
+ }
56
+
57
+ export function loadChatHistory(): ChatHistory {
58
+ const result = getStore().load();
59
+ return result.ok ? result.data : result.fallback;
60
+ }
61
+
62
+ export function appendMessage(
63
+ role: "user" | "assistant",
64
+ text: string,
65
+ agentId?: string,
66
+ ): ChatMessage {
67
+ const message: ChatMessage = {
68
+ id: generateId(),
69
+ timestamp: Date.now(),
70
+ role,
71
+ text,
72
+ agentId,
73
+ };
74
+
75
+ appendQueue.push(message);
76
+
77
+ // Coalesce rapid appends
78
+ if (appendTimeout) {
79
+ clearTimeout(appendTimeout);
80
+ }
81
+ appendTimeout = setTimeout(() => {
82
+ appendTimeout = null;
83
+ flushAppendQueue();
84
+ }, APPEND_QUEUE_FLUSH_MS);
85
+
86
+ return message;
87
+ }
88
+
89
+ export function clearChatHistory(): void {
90
+ // Clear pending queue
91
+ appendQueue = [];
92
+ if (appendTimeout) {
93
+ clearTimeout(appendTimeout);
94
+ appendTimeout = null;
95
+ }
96
+
97
+ // Save empty history
98
+ const empty = createDefaultChatHistory();
99
+ getStore().save(empty);
100
+ }
101
+
102
+ export function getRecentMessages(limit: number = 50): ChatMessage[] {
103
+ // First, include any pending messages not yet flushed
104
+ const current = getStore().getCache() ?? loadChatHistory();
105
+ const allMessages = [...current.messages, ...appendQueue];
106
+ return allMessages.slice(-limit);
107
+ }
108
+
109
+ export function getChatHistory(): ChatHistory {
110
+ const current = getStore().getCache() ?? loadChatHistory();
111
+ // Include pending queue in response
112
+ if (appendQueue.length > 0) {
113
+ return {
114
+ ...current,
115
+ messages: [...current.messages, ...appendQueue],
116
+ };
117
+ }
118
+ return current;
119
+ }
120
+
121
+ export async function flushChat(): Promise<void> {
122
+ // Flush append queue first
123
+ if (appendTimeout) {
124
+ clearTimeout(appendTimeout);
125
+ appendTimeout = null;
126
+ }
127
+ flushAppendQueue();
128
+
129
+ // Then flush store
130
+ await getStore().flush();
131
+ }
132
+
133
+ export function cleanupChat(): void {
134
+ if (appendTimeout) {
135
+ clearTimeout(appendTimeout);
136
+ appendTimeout = null;
137
+ }
138
+ // Synchronously flush queue
139
+ if (appendQueue.length > 0) {
140
+ flushAppendQueue();
141
+ }
142
+ getStore().cleanup();
143
+ }