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