@agentprojectcontext/apx 1.15.6 → 1.17.0

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 (222) hide show
  1. package/package.json +46 -5
  2. package/src/cli/commands/log.js +113 -0
  3. package/src/cli/commands/overlay.js +253 -0
  4. package/src/cli/commands/sys.js +88 -16
  5. package/src/cli/index.js +23 -1
  6. package/src/cli/terminal-chat/renderer.js +71 -56
  7. package/src/cli-ts/commands/agent.ts +173 -0
  8. package/src/cli-ts/commands/chat.ts +119 -0
  9. package/src/cli-ts/commands/daemon.ts +112 -0
  10. package/src/cli-ts/commands/exec.ts +109 -0
  11. package/src/cli-ts/commands/mcp.ts +235 -0
  12. package/src/cli-ts/commands/session.ts +224 -0
  13. package/src/cli-ts/commands/status.ts +61 -0
  14. package/src/cli-ts/http.ts +36 -0
  15. package/src/cli-ts/index.ts +73 -0
  16. package/src/cli-ts/ui.ts +107 -0
  17. package/src/core/logging.js +81 -0
  18. package/src/daemon/api.js +58 -0
  19. package/src/daemon/engines/anthropic.js +60 -1
  20. package/src/daemon/engines/index.js +2 -1
  21. package/src/daemon/engines/ollama.js +70 -3
  22. package/src/daemon/index.js +58 -0
  23. package/src/daemon/overlay-ws.js +40 -0
  24. package/src/daemon/plugins/index.js +2 -1
  25. package/src/daemon/plugins/overlay.js +177 -0
  26. package/src/daemon/plugins/telegram.js +15 -3
  27. package/src/daemon/super-agent-langchain.js +296 -0
  28. package/src/daemon/super-agent.js +115 -19
  29. package/src/daemon/transcription.js +262 -59
  30. package/src/daemon/whisper-server.py +57 -6
  31. package/src/overlay/index.html +44 -0
  32. package/src/overlay/main.js +480 -0
  33. package/src/overlay/package.json +3 -0
  34. package/src/overlay/preload.js +34 -0
  35. package/src/overlay/renderer.js +371 -0
  36. package/src/overlay/style.css +250 -0
  37. package/src/tui/_shims/cli-error.ts +6 -0
  38. package/src/tui/_shims/cli-logo.ts +18 -0
  39. package/src/tui/_shims/cli-ui.ts +1 -0
  40. package/src/tui/_shims/config-console-state.ts +7 -0
  41. package/src/tui/_shims/core-any.ts +30 -0
  42. package/src/tui/_shims/core-binary.ts +13 -0
  43. package/src/tui/_shims/core-flag.ts +3 -0
  44. package/src/tui/_shims/core-log.ts +14 -0
  45. package/src/tui/_shims/lsp-language.ts +1 -0
  46. package/src/tui/_shims/opencode-any.ts +135 -0
  47. package/src/tui/_shims/opencode-sdk-v2.ts +48 -0
  48. package/src/tui/_shims/plugin-tui.ts +13 -0
  49. package/src/tui/_shims/provider-provider.ts +10 -0
  50. package/src/tui/_shims/session-retry.ts +1 -0
  51. package/src/tui/_shims/session-schema.ts +15 -0
  52. package/src/tui/_shims/session-session.ts +3 -0
  53. package/src/tui/_shims/snapshot.ts +4 -0
  54. package/src/tui/_shims/tool-any.ts +18 -0
  55. package/src/tui/_shims/util-error.ts +7 -0
  56. package/src/tui/_shims/util-filesystem.ts +79 -0
  57. package/src/tui/_shims/util-format.ts +7 -0
  58. package/src/tui/_shims/util-iife.ts +3 -0
  59. package/src/tui/_shims/util-locale.ts +10 -0
  60. package/src/tui/_shims/util-process.ts +38 -0
  61. package/src/tui/app.tsx +783 -0
  62. package/src/tui/asset/charge.wav +0 -0
  63. package/src/tui/asset/pulse-a.wav +0 -0
  64. package/src/tui/asset/pulse-b.wav +0 -0
  65. package/src/tui/asset/pulse-c.wav +0 -0
  66. package/src/tui/attach.ts +100 -0
  67. package/src/tui/component/bg-pulse-render.ts +436 -0
  68. package/src/tui/component/bg-pulse.tsx +99 -0
  69. package/src/tui/component/border.tsx +21 -0
  70. package/src/tui/component/dialog-agent.tsx +31 -0
  71. package/src/tui/component/dialog-console-org.tsx +103 -0
  72. package/src/tui/component/dialog-mcp.tsx +85 -0
  73. package/src/tui/component/dialog-model.tsx +175 -0
  74. package/src/tui/component/dialog-provider.tsx +456 -0
  75. package/src/tui/component/dialog-retry-action.tsx +160 -0
  76. package/src/tui/component/dialog-session-delete-failed.tsx +99 -0
  77. package/src/tui/component/dialog-session-list.tsx +323 -0
  78. package/src/tui/component/dialog-session-rename.tsx +31 -0
  79. package/src/tui/component/dialog-skill.tsx +36 -0
  80. package/src/tui/component/dialog-stash.tsx +87 -0
  81. package/src/tui/component/dialog-status.tsx +168 -0
  82. package/src/tui/component/dialog-tag.tsx +44 -0
  83. package/src/tui/component/dialog-theme-list.tsx +50 -0
  84. package/src/tui/component/dialog-variant.tsx +39 -0
  85. package/src/tui/component/dialog-workspace-create.tsx +302 -0
  86. package/src/tui/component/dialog-workspace-file-changes.tsx +138 -0
  87. package/src/tui/component/dialog-workspace-unavailable.tsx +69 -0
  88. package/src/tui/component/error-component.tsx +92 -0
  89. package/src/tui/component/logo.tsx +896 -0
  90. package/src/tui/component/plugin-route-missing.tsx +14 -0
  91. package/src/tui/component/prompt/autocomplete.tsx +869 -0
  92. package/src/tui/component/prompt/cwd.ts +0 -0
  93. package/src/tui/component/prompt/frecency.tsx +90 -0
  94. package/src/tui/component/prompt/history.tsx +108 -0
  95. package/src/tui/component/prompt/index.tsx +1809 -0
  96. package/src/tui/component/prompt/part.ts +16 -0
  97. package/src/tui/component/prompt/stash.tsx +101 -0
  98. package/src/tui/component/prompt/traits.ts +35 -0
  99. package/src/tui/component/spinner.tsx +24 -0
  100. package/src/tui/component/startup-loading.tsx +63 -0
  101. package/src/tui/component/todo-item.tsx +32 -0
  102. package/src/tui/component/use-connected.tsx +9 -0
  103. package/src/tui/component/workspace-label.tsx +19 -0
  104. package/src/tui/config/cwd.ts +5 -0
  105. package/src/tui/config/keybind.ts +432 -0
  106. package/src/tui/config/tui-migrate.ts +154 -0
  107. package/src/tui/config/tui-schema.ts +34 -0
  108. package/src/tui/config/tui.ts +46 -0
  109. package/src/tui/context/aggregate-failures.ts +34 -0
  110. package/src/tui/context/args.tsx +15 -0
  111. package/src/tui/context/command-palette.tsx +163 -0
  112. package/src/tui/context/directory.ts +15 -0
  113. package/src/tui/context/editor-zed.ts +283 -0
  114. package/src/tui/context/editor.ts +468 -0
  115. package/src/tui/context/event-apx.ts +22 -0
  116. package/src/tui/context/event.ts +6 -0
  117. package/src/tui/context/exit.tsx +60 -0
  118. package/src/tui/context/helper.tsx +25 -0
  119. package/src/tui/context/kv.tsx +81 -0
  120. package/src/tui/context/local.tsx +608 -0
  121. package/src/tui/context/path-format.tsx +39 -0
  122. package/src/tui/context/project-apx.tsx +48 -0
  123. package/src/tui/context/project.tsx +7 -0
  124. package/src/tui/context/prompt.tsx +18 -0
  125. package/src/tui/context/route.tsx +52 -0
  126. package/src/tui/context/sdk-apx.tsx +185 -0
  127. package/src/tui/context/sdk.tsx +6 -0
  128. package/src/tui/context/sync-apx.tsx +178 -0
  129. package/src/tui/context/sync-v2.tsx +16 -0
  130. package/src/tui/context/sync.tsx +118 -0
  131. package/src/tui/context/theme/aura.json +69 -0
  132. package/src/tui/context/theme/ayu.json +80 -0
  133. package/src/tui/context/theme/carbonfox.json +248 -0
  134. package/src/tui/context/theme/catppuccin-frappe.json +230 -0
  135. package/src/tui/context/theme/catppuccin-macchiato.json +230 -0
  136. package/src/tui/context/theme/catppuccin.json +112 -0
  137. package/src/tui/context/theme/cobalt2.json +225 -0
  138. package/src/tui/context/theme/cursor.json +249 -0
  139. package/src/tui/context/theme/dracula.json +219 -0
  140. package/src/tui/context/theme/everforest.json +241 -0
  141. package/src/tui/context/theme/flexoki.json +237 -0
  142. package/src/tui/context/theme/github.json +233 -0
  143. package/src/tui/context/theme/gruvbox.json +242 -0
  144. package/src/tui/context/theme/kanagawa.json +77 -0
  145. package/src/tui/context/theme/lucent-orng.json +234 -0
  146. package/src/tui/context/theme/material.json +235 -0
  147. package/src/tui/context/theme/matrix.json +77 -0
  148. package/src/tui/context/theme/mercury.json +252 -0
  149. package/src/tui/context/theme/monokai.json +221 -0
  150. package/src/tui/context/theme/nightowl.json +221 -0
  151. package/src/tui/context/theme/nord.json +223 -0
  152. package/src/tui/context/theme/one-dark.json +84 -0
  153. package/src/tui/context/theme/opencode.json +245 -0
  154. package/src/tui/context/theme/orng.json +249 -0
  155. package/src/tui/context/theme/osaka-jade.json +93 -0
  156. package/src/tui/context/theme/palenight.json +222 -0
  157. package/src/tui/context/theme/rosepine.json +234 -0
  158. package/src/tui/context/theme/solarized.json +223 -0
  159. package/src/tui/context/theme/synthwave84.json +226 -0
  160. package/src/tui/context/theme/tokyonight.json +243 -0
  161. package/src/tui/context/theme/vercel.json +245 -0
  162. package/src/tui/context/theme/vesper.json +218 -0
  163. package/src/tui/context/theme/zenburn.json +223 -0
  164. package/src/tui/context/theme.tsx +1247 -0
  165. package/src/tui/context/tui-config.tsx +9 -0
  166. package/src/tui/event.ts +16 -0
  167. package/src/tui/feature-plugins/home/footer.tsx +94 -0
  168. package/src/tui/feature-plugins/home/tips-view.tsx +166 -0
  169. package/src/tui/feature-plugins/home/tips.tsx +59 -0
  170. package/src/tui/feature-plugins/sidebar/context.tsx +65 -0
  171. package/src/tui/feature-plugins/sidebar/files.tsx +63 -0
  172. package/src/tui/feature-plugins/sidebar/footer.tsx +94 -0
  173. package/src/tui/feature-plugins/sidebar/lsp.tsx +65 -0
  174. package/src/tui/feature-plugins/sidebar/mcp.tsx +97 -0
  175. package/src/tui/feature-plugins/sidebar/todo.tsx +49 -0
  176. package/src/tui/feature-plugins/system/plugins.tsx +269 -0
  177. package/src/tui/feature-plugins/system/session-v2.tsx +1143 -0
  178. package/src/tui/feature-plugins/system/which-key.tsx +608 -0
  179. package/src/tui/keymap.tsx +166 -0
  180. package/src/tui/layer.ts +6 -0
  181. package/src/tui/plugin/api.tsx +381 -0
  182. package/src/tui/plugin/command-shim.ts +109 -0
  183. package/src/tui/plugin/internal.ts +33 -0
  184. package/src/tui/plugin/runtime.ts +1069 -0
  185. package/src/tui/plugin/slots.tsx +60 -0
  186. package/src/tui/routes/home.tsx +96 -0
  187. package/src/tui/routes/session/dialog-fork-from-timeline.tsx +76 -0
  188. package/src/tui/routes/session/dialog-message.tsx +108 -0
  189. package/src/tui/routes/session/dialog-subagent.tsx +26 -0
  190. package/src/tui/routes/session/dialog-timeline.tsx +47 -0
  191. package/src/tui/routes/session/footer.tsx +91 -0
  192. package/src/tui/routes/session/index.tsx +188 -0
  193. package/src/tui/routes/session/permission.tsx +722 -0
  194. package/src/tui/routes/session/question.tsx +490 -0
  195. package/src/tui/routes/session/sidebar.tsx +102 -0
  196. package/src/tui/routes/session/subagent-footer.tsx +133 -0
  197. package/src/tui/run.ts +84 -0
  198. package/src/tui/thread.ts +261 -0
  199. package/src/tui/tsconfig.json +40 -0
  200. package/src/tui/ui/dialog-alert.tsx +66 -0
  201. package/src/tui/ui/dialog-confirm.tsx +108 -0
  202. package/src/tui/ui/dialog-export-options.tsx +217 -0
  203. package/src/tui/ui/dialog-help.tsx +40 -0
  204. package/src/tui/ui/dialog-prompt.tsx +101 -0
  205. package/src/tui/ui/dialog-select.tsx +553 -0
  206. package/src/tui/ui/dialog.tsx +211 -0
  207. package/src/tui/ui/link.tsx +34 -0
  208. package/src/tui/ui/spinner.ts +368 -0
  209. package/src/tui/ui/toast.tsx +111 -0
  210. package/src/tui/util/clipboard.ts +217 -0
  211. package/src/tui/util/editor.ts +37 -0
  212. package/src/tui/util/model.ts +23 -0
  213. package/src/tui/util/provider-origin.ts +7 -0
  214. package/src/tui/util/revert-diff.ts +18 -0
  215. package/src/tui/util/scroll.ts +25 -0
  216. package/src/tui/util/selection.ts +65 -0
  217. package/src/tui/util/signal.ts +41 -0
  218. package/src/tui/util/sound.ts +156 -0
  219. package/src/tui/util/transcript.ts +112 -0
  220. package/src/tui/validate-session.ts +29 -0
  221. package/src/tui/win32.ts +130 -0
  222. package/src/tui/worker.ts +104 -0
@@ -64,12 +64,18 @@ class _Handler(BaseHTTPRequestHandler):
64
64
  pass # suppress access log; APX daemon handles its own logging
65
65
 
66
66
  def _send_json(self, code, body):
67
- data = json.dumps(body).encode()
68
- self.send_response(code)
69
- self.send_header("Content-Type", "application/json")
70
- self.send_header("Content-Length", str(len(data)))
71
- self.end_headers()
72
- self.wfile.write(data)
67
+ # Swallow BrokenPipe / ConnectionReset — these happen when the daemon
68
+ # times out and aborts the request before we finish responding, and
69
+ # they used to fill the daemon log with multi-page Python tracebacks.
70
+ try:
71
+ data = json.dumps(body).encode()
72
+ self.send_response(code)
73
+ self.send_header("Content-Type", "application/json")
74
+ self.send_header("Content-Length", str(len(data)))
75
+ self.end_headers()
76
+ self.wfile.write(data)
77
+ except (BrokenPipeError, ConnectionResetError):
78
+ pass
73
79
 
74
80
  def _read_body(self):
75
81
  n = int(self.headers.get("Content-Length", 0))
@@ -92,6 +98,51 @@ class _Handler(BaseHTTPRequestHandler):
92
98
  self._send_json(404, {"ok": False, "error": "not found"})
93
99
 
94
100
  def do_POST(self):
101
+ # /transcribe_chunk reads raw bytes — must be handled BEFORE _read_body()
102
+ # which would consume rfile for JSON endpoints.
103
+ if self.path == "/transcribe_chunk":
104
+ _touch()
105
+ content_length = int(self.headers.get("Content-Length", 0))
106
+ if content_length <= 0:
107
+ self._send_json(400, {"ok": False, "error": "empty body"})
108
+ return
109
+ audio_bytes = self.rfile.read(content_length)
110
+ audio_format = (self.headers.get("X-Audio-Format") or "webm").strip().lstrip(".")
111
+ language_hdr = self.headers.get("X-Language") or None
112
+ language = language_hdr if language_hdr and language_hdr != "auto" else None
113
+ beam_size = int(self.headers.get("X-Beam-Size") or 3)
114
+
115
+ with _model_lock:
116
+ try:
117
+ m = _load_model_if_needed(_Handler.model_name, _Handler.device, _Handler.compute_type)
118
+ except ImportError:
119
+ self._send_json(500, {"ok": False, "error": "faster-whisper not installed"})
120
+ return
121
+ except Exception as e:
122
+ self._send_json(500, {"ok": False, "error": f"model load failed: {e}"})
123
+ return
124
+
125
+ import tempfile
126
+ tmp = tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False)
127
+ try:
128
+ tmp.write(audio_bytes)
129
+ tmp.close()
130
+ segments, info = m.transcribe(tmp.name, beam_size=beam_size, language=language)
131
+ text = " ".join(seg.text.strip() for seg in segments).strip()
132
+ self._send_json(200, {
133
+ "ok": True, "text": text,
134
+ "language": info.language,
135
+ "language_probability": round(info.language_probability, 4),
136
+ "duration": round(info.duration, 2) if hasattr(info, "duration") else None,
137
+ "model": _model_name,
138
+ })
139
+ except Exception as e:
140
+ self._send_json(500, {"ok": False, "error": f"chunk transcription failed: {e}"})
141
+ finally:
142
+ try: os.unlink(tmp.name)
143
+ except Exception: pass
144
+ return
145
+
95
146
  req = self._read_body()
96
147
 
97
148
  if self.path == "/transcribe":
@@ -0,0 +1,44 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta http-equiv="Content-Security-Policy"
6
+ content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src *;">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <title>APX</title>
9
+ <link rel="stylesheet" href="style.css">
10
+ </head>
11
+ <body>
12
+ <div id="app">
13
+ <!-- Header -->
14
+ <div id="header">
15
+ <span class="logo">APX</span>
16
+ <span id="status-text" class="status">Ready</span>
17
+ <div id="conn-badge" title="Daemon disconnected"></div>
18
+ <button class="btn-close" id="btn-close" title="Close (Esc)">✕</button>
19
+ </div>
20
+
21
+ <!-- Messages -->
22
+ <div id="messages">
23
+ <div id="empty-state">
24
+ <div class="icon">🎙</div>
25
+ <div>Press <kbd id="empty-shortcut-hint">⌘G</kbd> to start speaking</div>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Live transcription bar -->
30
+ <div id="live-bar">
31
+ <div class="rec-dot"></div>
32
+ <div id="live-text">Listening…</div>
33
+ </div>
34
+
35
+ <!-- Hint bar -->
36
+ <div id="hint-bar">
37
+ <span class="hint"><kbd id="shortcut-hint">⌘⇧\</kbd> record</span>
38
+ <span class="hint"><kbd>Esc</kbd> cancel / close</span>
39
+ </div>
40
+ </div>
41
+
42
+ <script src="renderer.js"></script>
43
+ </body>
44
+ </html>
@@ -0,0 +1,480 @@
1
+ // APX Overlay — Electron main process.
2
+ // Provides: system tray icon, configurable global shortcut, transparent
3
+ // floating chat window, WebSocket connection to APX daemon.
4
+ //
5
+ // Default shortcut: Cmd+Shift+\ (Mac) / Ctrl+Shift+\ (Win/Linux).
6
+ // Override in ~/.apx/config.json: "overlay": { "shortcut": "CommandOrControl+Shift+Space" }
7
+ //
8
+ // Launch via: electron src/overlay/main.js [--port 7430] [--shortcut <accel>]
9
+ // Or via: apx overlay start
10
+
11
+ "use strict";
12
+ const { app, BrowserWindow, Tray, globalShortcut, ipcMain, nativeImage, screen, Menu } = require("electron");
13
+ const path = require("path");
14
+ const os = require("os");
15
+ const fs = require("fs");
16
+ const http = require("http");
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Config from CLI args or env
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const args = process.argv.slice(2);
23
+ function getArg(name) {
24
+ const i = args.indexOf(name);
25
+ return i !== -1 ? args[i + 1] : null;
26
+ }
27
+
28
+ const DAEMON_PORT = parseInt(getArg("--port") || process.env.APX_PORT || "7430", 10);
29
+ const DAEMON_HOST = getArg("--host") || process.env.APX_HOST || "127.0.0.1";
30
+ const WHISPER_PORT = 18765;
31
+ const TOKEN_PATH = path.join(os.homedir(), ".apx", "daemon.token");
32
+ const CONFIG_PATH = path.join(os.homedir(), ".apx", "config.json");
33
+
34
+ // Default shortcut: Cmd/Ctrl + Shift + \ (backslash — rarely used by other apps)
35
+ // User can override via config overlay.shortcut or --shortcut CLI arg.
36
+ const DEFAULT_SHORTCUT = "CommandOrControl+G";
37
+
38
+ function readApxConfig() {
39
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
40
+ }
41
+
42
+ function getShortcut() {
43
+ const fromArg = getArg("--shortcut");
44
+ if (fromArg) return fromArg;
45
+ const cfg = readApxConfig();
46
+ return cfg?.overlay?.shortcut || DEFAULT_SHORTCUT;
47
+ }
48
+
49
+ function readToken() {
50
+ try { return fs.readFileSync(TOKEN_PATH, "utf8").trim(); } catch { return ""; }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Window size + position helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ const WIN_W = 420;
58
+ const WIN_H = 560;
59
+
60
+ function getWindowPosition() {
61
+ const display = screen.getPrimaryDisplay();
62
+ const { workArea } = display;
63
+ const isMac = process.platform === "darwin";
64
+ if (isMac) {
65
+ // Top-right, below menu bar (workArea.y already accounts for it)
66
+ return { x: workArea.x + workArea.width - WIN_W - 12, y: workArea.y + 8 };
67
+ }
68
+ // Windows/Linux: bottom-right, above taskbar
69
+ return {
70
+ x: workArea.x + workArea.width - WIN_W - 12,
71
+ y: workArea.y + workArea.height - WIN_H - 12,
72
+ };
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Globals
77
+ // ---------------------------------------------------------------------------
78
+
79
+ let mainWindow = null;
80
+ let tray = null;
81
+ let wsConn = null; // WebSocket to daemon
82
+ let isRecording = false;
83
+ let overlayVisible = false;
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // App lifecycle
87
+ // ---------------------------------------------------------------------------
88
+
89
+ // On macOS, don't show in the dock — it's a tray-only utility
90
+ if (process.platform === "darwin") app.dock?.hide();
91
+
92
+ app.whenReady().then(() => {
93
+ console.log(`overlay: starting — daemon ${DAEMON_HOST}:${DAEMON_PORT} — pid ${process.pid}`);
94
+ try { createTray(); console.log("overlay: tray created"); }
95
+ catch (e) { console.error("overlay: createTray failed:", e.message); }
96
+ try { createWindow(); console.log("overlay: window created"); }
97
+ catch (e) { console.error("overlay: createWindow failed:", e.message); }
98
+ try { registerShortcut(); }
99
+ catch (e) { console.error("overlay: registerShortcut failed:", e.message); }
100
+ connectDaemon();
101
+ });
102
+
103
+ process.on("uncaughtException", (e) => {
104
+ console.error("overlay: uncaught exception:", e.stack || e.message);
105
+ });
106
+
107
+ app.on("window-all-closed", (e) => {
108
+ // Prevent app from quitting when window closes — keep in tray
109
+ e.preventDefault?.();
110
+ });
111
+
112
+ app.on("will-quit", () => {
113
+ globalShortcut.unregisterAll();
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Tray
118
+ // ---------------------------------------------------------------------------
119
+
120
+ // Minimal valid 16x16 transparent PNG (base64).
121
+ // macOS requires a real PNG for Tray — raw RGBA buffers are not accepted.
122
+ // We use setTitle() to display the visible symbol in the menu bar.
123
+ const ICON_TRANSPARENT_PNG =
124
+ "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCS" +
125
+ "VQICAgIfAhkiAAAAAlwSFlzAAALEwAACxMBAJqcGAAAABl0RVh0U29" +
126
+ "mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAOSURBVDiNY2Bg" +
127
+ "YPgPAAEEAQABZQMuAAAAAElFTkSuQmCC";
128
+
129
+ // Red dot PNG for recording state (16x16 solid red circle).
130
+ const ICON_RED_PNG =
131
+ "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAASUlEQVQ4" +
132
+ "y2NgYGD4z0ABYBpFkKoFVAMDA8N/CiygWgMDA8N/qgygWgMDA8N/og" +
133
+ "ygSgMDA8N/igygWgMDA8N/BgYGBgCZJCULAAAAAElFTkSuQmCC";
134
+
135
+ function buildTrayIcon(recording) {
136
+ const b64 = recording ? ICON_RED_PNG : ICON_TRANSPARENT_PNG;
137
+ const img = nativeImage.createFromDataURL(`data:image/png;base64,${b64}`);
138
+ // Template image: macOS auto-adapts colour to dark/light menu bar
139
+ if (process.platform === "darwin") img.setTemplateImage(!recording);
140
+ return img;
141
+ }
142
+
143
+ function createTray() {
144
+ const icon = buildTrayIcon(false);
145
+ tray = new Tray(icon);
146
+
147
+ // On macOS, setTitle shows text right in the menu bar (most visible approach)
148
+ if (process.platform === "darwin") tray.setTitle(" ◉");
149
+
150
+ tray.setToolTip("APX Voice Overlay — click to toggle, right-click for menu");
151
+
152
+ const contextMenu = Menu.buildFromTemplate([
153
+ { label: "Show / Hide", click: toggleWindow },
154
+ { label: "Start Recording", click: startRecording },
155
+ { type: "separator" },
156
+ { label: "Quit APX Overlay", click: () => app.exit(0) },
157
+ ]);
158
+ tray.setContextMenu(contextMenu);
159
+ tray.on("click", toggleWindow);
160
+ }
161
+
162
+ function updateTrayRecording(rec) {
163
+ if (!tray) return;
164
+ tray.setImage(buildTrayIcon(rec));
165
+ if (process.platform === "darwin") tray.setTitle(rec ? " ⏺" : " ◉");
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Window
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function createWindow() {
173
+ const pos = getWindowPosition();
174
+ mainWindow = new BrowserWindow({
175
+ width: WIN_W,
176
+ height: WIN_H,
177
+ x: pos.x,
178
+ y: pos.y,
179
+ frame: false,
180
+ transparent: true,
181
+ alwaysOnTop: true,
182
+ skipTaskbar: true,
183
+ resizable: false,
184
+ hasShadow: false,
185
+ show: false,
186
+ focusable: true,
187
+ webPreferences: {
188
+ preload: path.join(__dirname, "preload.js"),
189
+ contextIsolation: true,
190
+ nodeIntegration: false,
191
+ // Allow getUserMedia for microphone access
192
+ allowRunningInsecureContent: false,
193
+ },
194
+ });
195
+
196
+ mainWindow.loadFile(path.join(__dirname, "index.html"));
197
+
198
+ mainWindow.on("blur", () => {
199
+ // Don't auto-hide while recording or streaming
200
+ if (!isRecording) {
201
+ setTimeout(() => {
202
+ if (mainWindow && !mainWindow.isFocused() && !isRecording) {
203
+ // Keep visible — user might be reading the response
204
+ }
205
+ }, 200);
206
+ }
207
+ });
208
+
209
+ mainWindow.on("closed", () => { mainWindow = null; });
210
+
211
+ // ESC key handled in renderer via preload
212
+ }
213
+
214
+ function toggleWindow() {
215
+ if (!mainWindow) { createWindow(); return; }
216
+ if (overlayVisible) {
217
+ hideOverlay();
218
+ } else {
219
+ showOverlay();
220
+ }
221
+ }
222
+
223
+ function showOverlay() {
224
+ if (!mainWindow) createWindow();
225
+ const pos = getWindowPosition();
226
+ mainWindow.setPosition(pos.x, pos.y);
227
+ mainWindow.show();
228
+ mainWindow.focus();
229
+ overlayVisible = true;
230
+ }
231
+
232
+ function hideOverlay() {
233
+ if (mainWindow) mainWindow.hide();
234
+ overlayVisible = false;
235
+ if (isRecording) stopRecording();
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Global shortcut: Cmd/Ctrl+Shift+Space toggles recording
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function registerShortcut() {
243
+ const shortcut = getShortcut();
244
+ const ok = globalShortcut.register(shortcut, () => {
245
+ if (!overlayVisible) {
246
+ showOverlay();
247
+ // Auto-start recording when opening via shortcut
248
+ setTimeout(startRecording, 150);
249
+ } else if (isRecording) {
250
+ stopRecording();
251
+ } else {
252
+ startRecording();
253
+ }
254
+ });
255
+ if (!ok) {
256
+ console.error(`overlay: failed to register shortcut "${shortcut}". Try a different shortcut in ~/.apx/config.json: overlay.shortcut`);
257
+ } else {
258
+ console.log(`overlay: shortcut registered: ${shortcut}`);
259
+ }
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Recording control
264
+ // ---------------------------------------------------------------------------
265
+
266
+ function startRecording() {
267
+ if (isRecording) return;
268
+ isRecording = true;
269
+ updateTrayRecording(true);
270
+ mainWindow?.webContents.send("recording-start");
271
+ }
272
+
273
+ function stopRecording() {
274
+ if (!isRecording) return;
275
+ isRecording = false;
276
+ updateTrayRecording(false);
277
+ mainWindow?.webContents.send("recording-stop");
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // IPC handlers (renderer → main)
282
+ // ---------------------------------------------------------------------------
283
+
284
+ // Renderer sends audio chunk for transcription
285
+ ipcMain.handle("transcribe-chunk", async (_event, { buffer, format, language }) => {
286
+ try {
287
+ console.log(`overlay: transcribe chunk — ${buffer.byteLength}b ${format}`);
288
+ const result = await transcribeChunk(Buffer.from(buffer), format || "webm", language || "auto");
289
+ if (result?.ok) console.log(`overlay: transcribed → "${(result.text || "").slice(0, 80)}"`);
290
+ else console.error("overlay: transcription error:", result?.error);
291
+ return result;
292
+ } catch (e) {
293
+ console.error("overlay: transcribeChunk exception:", e.message);
294
+ return { ok: false, error: e.message };
295
+ }
296
+ });
297
+
298
+ // Renderer sends final transcribed text to daemon
299
+ ipcMain.handle("send-message", async (_event, { text, previousMessages }) => {
300
+ console.log(`overlay: send-message → "${text.slice(0, 80)}"`);
301
+ return sendMessageToDaemon(text, previousMessages || []);
302
+ });
303
+
304
+ // Renderer requests cancel
305
+ ipcMain.handle("cancel", async () => {
306
+ if (wsConn && wsConn.readyState === 1) {
307
+ wsConn.send(JSON.stringify({ type: "cancel" }));
308
+ }
309
+ stopRecording();
310
+ });
311
+
312
+ // Renderer requests close/hide
313
+ ipcMain.handle("close-overlay", async () => {
314
+ hideOverlay();
315
+ });
316
+
317
+ // Renderer queries the configured shortcut for display
318
+ ipcMain.handle("get-shortcut", () => getShortcut());
319
+
320
+ // Check if the whisper server is running and the model is loaded
321
+ ipcMain.handle("check-whisper-ready", () => {
322
+ return new Promise((resolve) => {
323
+ const options = {
324
+ hostname: "127.0.0.1",
325
+ port: WHISPER_PORT,
326
+ path: "/health",
327
+ method: "GET",
328
+ };
329
+ const req = http.request(options, (res) => {
330
+ let data = "";
331
+ res.on("data", (c) => data += c);
332
+ res.on("end", () => {
333
+ try {
334
+ const json = JSON.parse(data);
335
+ resolve({ ready: json.ok && json.loaded === true });
336
+ } catch {
337
+ resolve({ ready: false });
338
+ }
339
+ });
340
+ });
341
+ req.on("error", () => resolve({ ready: false }));
342
+ req.setTimeout(800, () => { req.destroy(); resolve({ ready: false }); });
343
+ req.end();
344
+ });
345
+ });
346
+
347
+ // Renderer requests recording toggle (ESC cancels, shortcut toggles)
348
+ ipcMain.handle("toggle-recording", async () => {
349
+ if (isRecording) stopRecording(); else startRecording();
350
+ });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Whisper chunk transcription — proxied through the daemon (auto-starts whisper server)
354
+ // ---------------------------------------------------------------------------
355
+
356
+ function transcribeChunk(buf, format, language) {
357
+ return new Promise((resolve, reject) => {
358
+ const token = readToken();
359
+ const options = {
360
+ hostname: DAEMON_HOST,
361
+ port: DAEMON_PORT,
362
+ path: "/transcribe/chunk",
363
+ method: "POST",
364
+ headers: {
365
+ "Content-Type": "application/octet-stream",
366
+ "Content-Length": buf.length,
367
+ "X-Audio-Format": format,
368
+ "X-Language": language,
369
+ // Overlay is real-time → local whisper only. Never fall back to OpenAI.
370
+ "X-Provider": "local",
371
+ ...(token ? { "Authorization": `Bearer ${token}` } : {}),
372
+ },
373
+ };
374
+ const req = http.request(options, (res) => {
375
+ let data = "";
376
+ res.on("data", (c) => data += c);
377
+ res.on("end", () => {
378
+ try { resolve(JSON.parse(data)); }
379
+ catch { reject(new Error("bad json from daemon")); }
380
+ });
381
+ });
382
+ req.on("error", reject);
383
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error("transcription timeout")); });
384
+ req.write(buf);
385
+ req.end();
386
+ });
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Daemon communication
391
+ // ---------------------------------------------------------------------------
392
+
393
+ function connectDaemon() {
394
+ // Lazy-load ws from the APX node_modules (co-located)
395
+ let WS;
396
+ try {
397
+ WS = require("ws");
398
+ } catch {
399
+ console.warn("overlay: 'ws' module not found — WebSocket disabled. Install with: npm install ws");
400
+ return;
401
+ }
402
+
403
+ const token = readToken();
404
+ const url = `ws://${DAEMON_HOST}:${DAEMON_PORT}/overlay/ws`;
405
+
406
+ function connect() {
407
+ try {
408
+ wsConn = new WS(url, {
409
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
410
+ });
411
+
412
+ wsConn.on("open", () => {
413
+ console.log("overlay: connected to daemon");
414
+ resetReconnectDelay();
415
+ mainWindow?.webContents.send("daemon-connected");
416
+ });
417
+
418
+ wsConn.on("message", (raw) => {
419
+ let msg;
420
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
421
+ // Forward all daemon events to the renderer
422
+ mainWindow?.webContents.send("daemon-event", msg);
423
+ });
424
+
425
+ wsConn.on("close", () => {
426
+ wsConn = null;
427
+ mainWindow?.webContents.send("daemon-disconnected");
428
+ scheduleReconnect();
429
+ });
430
+
431
+ wsConn.on("error", (e) => {
432
+ console.warn("overlay ws error:", e.message);
433
+ });
434
+ } catch (e) {
435
+ console.warn("overlay: connect failed —", e.message);
436
+ scheduleReconnect();
437
+ }
438
+ }
439
+
440
+ // Exponential backoff with cap: 1s → 2s → 4s → … → 30s. Resets to 1s
441
+ // after a successful open() (see below).
442
+ let reconnectDelay = 1000;
443
+ function scheduleReconnect() {
444
+ const delay = reconnectDelay;
445
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
446
+ setTimeout(connect, delay);
447
+ }
448
+ function resetReconnectDelay() { reconnectDelay = 1000; }
449
+
450
+ connect();
451
+ }
452
+
453
+ async function sendMessageToDaemon(text, previousMessages) {
454
+ const token = readToken();
455
+ return new Promise((resolve, reject) => {
456
+ const body = JSON.stringify({ text, previousMessages });
457
+ const options = {
458
+ hostname: DAEMON_HOST,
459
+ port: DAEMON_PORT,
460
+ path: "/overlay/message",
461
+ method: "POST",
462
+ headers: {
463
+ "Content-Type": "application/json",
464
+ "Content-Length": Buffer.byteLength(body),
465
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
466
+ },
467
+ };
468
+ const req = http.request(options, (res) => {
469
+ let data = "";
470
+ res.on("data", (c) => data += c);
471
+ res.on("end", () => {
472
+ try { resolve(JSON.parse(data)); }
473
+ catch { resolve({ ok: true }); }
474
+ });
475
+ });
476
+ req.on("error", reject);
477
+ req.write(body);
478
+ req.end();
479
+ });
480
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,34 @@
1
+ // Preload — context bridge between Electron main and renderer.
2
+ "use strict";
3
+ const { contextBridge, ipcRenderer } = require("electron");
4
+
5
+ contextBridge.exposeInMainWorld("apx", {
6
+ // Recording control
7
+ onRecordingStart: (fn) => ipcRenderer.on("recording-start", fn),
8
+ onRecordingStop: (fn) => ipcRenderer.on("recording-stop", fn),
9
+ toggleRecording: () => ipcRenderer.invoke("toggle-recording"),
10
+ cancel: () => ipcRenderer.invoke("cancel"),
11
+ close: () => ipcRenderer.invoke("close-overlay"),
12
+
13
+ // Transcription
14
+ transcribeChunk: (buffer, format, language) =>
15
+ ipcRenderer.invoke("transcribe-chunk", { buffer, format, language }),
16
+
17
+ // Check if the whisper model is loaded (false = still loading)
18
+ checkWhisperReady: () => ipcRenderer.invoke("check-whisper-ready"),
19
+
20
+ // Send final text to daemon
21
+ sendMessage: (text, previousMessages) =>
22
+ ipcRenderer.invoke("send-message", { text, previousMessages }),
23
+
24
+ // Daemon events (tokens, tools, done, error)
25
+ onDaemonEvent: (fn) => ipcRenderer.on("daemon-event", (_e, msg) => fn(msg)),
26
+ onDaemonConnected: (fn) => ipcRenderer.on("daemon-connected", fn),
27
+ onDaemonDisconnected: (fn) => ipcRenderer.on("daemon-disconnected", fn),
28
+
29
+ // Platform info
30
+ platform: process.platform,
31
+
32
+ // Query configured shortcut for display in hint bar
33
+ getShortcut: () => ipcRenderer.invoke("get-shortcut"),
34
+ });