@agentprojectcontext/apx 1.73.1 → 1.74.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 (33) hide show
  1. package/package.json +3 -2
  2. package/src/core/channels/telegram/ask-callbacks.js +4 -0
  3. package/src/core/channels/telegram/dispatch.js +13 -3
  4. package/src/core/channels/telegram/reply.js +19 -7
  5. package/src/core/desktop/autostart.js +31 -14
  6. package/src/core/mcp/runner.js +6 -1
  7. package/src/core/net/ipv4-first.js +32 -0
  8. package/src/core/stores/messages.js +19 -2
  9. package/src/core/util/index.js +1 -0
  10. package/src/core/util/path-env.js +73 -0
  11. package/src/core/voice/transcription.js +28 -3
  12. package/src/host/daemon/api/super-agent.js +35 -5
  13. package/src/host/daemon/api/voice.js +20 -1
  14. package/src/host/daemon/index.js +2 -0
  15. package/src/host/daemon/plugins/desktop/index.js +13 -2
  16. package/src/host/daemon/whisper-server.js +27 -0
  17. package/src/interfaces/cli/commands/exec.js +81 -3
  18. package/src/interfaces/web/dist/assets/index-COrRuBp1.css +1 -0
  19. package/src/interfaces/web/dist/assets/index-DUXlrW8P.js +819 -0
  20. package/src/interfaces/web/dist/assets/index-DUXlrW8P.js.map +1 -0
  21. package/src/interfaces/web/dist/index.html +2 -2
  22. package/src/interfaces/web/package-lock.json +407 -378
  23. package/src/interfaces/web/src/components/chat/ContextBar.tsx +83 -28
  24. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +23 -1
  25. package/src/interfaces/web/src/hooks/useChat.ts +37 -5
  26. package/src/interfaces/web/src/i18n/en.ts +3 -0
  27. package/src/interfaces/web/src/i18n/es.ts +3 -0
  28. package/src/interfaces/web/src/lib/api/agents.ts +2 -2
  29. package/src/interfaces/web/src/screens/base/LogsTab.tsx +19 -0
  30. package/src/interfaces/web/src/types/daemon.ts +11 -0
  31. package/src/interfaces/web/dist/assets/index-CUeIhw7z.css +0 -1
  32. package/src/interfaces/web/dist/assets/index-CqlRznhf.js +0 -819
  33. package/src/interfaces/web/dist/assets/index-CqlRznhf.js.map +0 -1
@@ -104,6 +104,10 @@ export function register(app, { projects, plugins, registries }) {
104
104
  const channel = body.channel || "voice";
105
105
  let suggestions = [];
106
106
  let toolsUsed = [];
107
+ // Attribution for the persisted turn (see the appendGlobalMessage below).
108
+ let replyModel = null;
109
+ let replyUsage = null;
110
+ let replyName = null;
107
111
 
108
112
  const channelCtx = buildVoiceChannelContext(channel, {
109
113
  projectId: body.projectId,
@@ -125,6 +129,9 @@ export function register(app, { projects, plugins, registries }) {
125
129
  previousMessages,
126
130
  });
127
131
  const raw = (result?.text || "").trim();
132
+ replyModel = result?.model || null;
133
+ replyUsage = result?.usage || null;
134
+ replyName = result?.name || null;
128
135
  // Surface the tools the agent actually executed this turn so
129
136
  // the overlay can show "lo que APX hizo" instead of echoing
130
137
  // the transcript. Dedup by name (a list_tasks before+after
@@ -188,7 +195,19 @@ export function register(app, { projects, plugins, registries }) {
188
195
  const logCh = channelCtx.channel || channel;
189
196
  if (logCh && logCh !== "api") {
190
197
  appendGlobalMessage({ channel: logCh, direction: "in", type: "user", author: "user", body: userText });
191
- if (replyText) appendGlobalMessage({ channel: logCh, direction: "out", type: "agent", body: replyText });
198
+ if (replyText) {
199
+ appendGlobalMessage({
200
+ channel: logCh,
201
+ direction: "out",
202
+ type: "agent",
203
+ author: replyName || undefined,
204
+ body: replyText,
205
+ meta: {
206
+ ...(replyModel ? { model: replyModel } : {}),
207
+ ...(replyUsage ? { usage: replyUsage } : {}),
208
+ },
209
+ });
210
+ }
192
211
  }
193
212
  } catch { /* best-effort */ }
194
213
 
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // APX daemon entry point. Boots config + projects + Express + plugins.
3
+ // Must run before any outbound fetch — see the module header for why.
4
+ import "#core/net/ipv4-first.js";
3
5
  import fs from "node:fs";
4
6
  import path from "node:path";
5
7
  import { fileURLToPath } from "node:url";
@@ -199,9 +199,20 @@ async function _handleMessage({ ws, text, previousMessages }, { projects, config
199
199
  }
200
200
  }
201
201
 
202
- // Persist assistant response
202
+ // Persist assistant response, stamped with the model that answered and the
203
+ // turn's token usage so the web thread viewer can attribute it.
203
204
  try {
204
- await appendGlobalMessage({ channel: CHANNEL, direction: "out", type: "agent", body: finalText });
205
+ await appendGlobalMessage({
206
+ channel: CHANNEL,
207
+ direction: "out",
208
+ type: "agent",
209
+ author: result?.name || undefined,
210
+ body: finalText,
211
+ meta: {
212
+ ...(result?.model ? { model: result.model } : {}),
213
+ ...(result?.usage ? { usage: result.usage } : {}),
214
+ },
215
+ });
205
216
  } catch {}
206
217
 
207
218
  } catch (e) {
@@ -15,7 +15,9 @@ import {
15
15
  WHISPER_LOCAL_PORT,
16
16
  DEFAULT_LOCAL,
17
17
  getConfig,
18
+ setLocalServerEnsure,
18
19
  } from "#core/voice/transcription.js";
20
+ import { envWithPath } from "#core/util/path-env.js";
19
21
  import { pythonForWhisper } from "./stt-venv.js";
20
22
 
21
23
  const __filename = fileURLToPath(import.meta.url);
@@ -82,7 +84,21 @@ async function _killOrphanWhisper() {
82
84
  }
83
85
  }
84
86
 
87
+ // Serializes concurrent ensures. Two voice messages arriving together (or a
88
+ // desktop utterance racing a Telegram note) would otherwise each see a dead
89
+ // port and spawn their own python, and the loser then trips
90
+ // "address already in use".
91
+ let _ensureInFlight = null;
92
+
85
93
  export async function ensureWhisperServer(opts) {
94
+ if (_ensureInFlight) return _ensureInFlight;
95
+ _ensureInFlight = _ensureWhisperServer(opts).finally(() => {
96
+ _ensureInFlight = null;
97
+ });
98
+ return _ensureInFlight;
99
+ }
100
+
101
+ async function _ensureWhisperServer(opts) {
86
102
  const model = opts.model || DEFAULT_LOCAL.model;
87
103
  const backend = opts.backend || "faster";
88
104
 
@@ -129,9 +145,15 @@ async function _spawnWhisper(opts, model, backend, retried) {
129
145
 
130
146
  // Prefer APX's dedicated venv interpreter (isolated mlx/faster-whisper);
131
147
  // fall back to system python3 for the legacy user-site install.
148
+ //
149
+ // envWithPath: both whisper backends shell out to `ffmpeg` to decode
150
+ // .oga/.webm. Booted from launchd the daemon's PATH is /usr/bin:/bin:
151
+ // /usr/sbin:/sbin, so a Homebrew ffmpeg is invisible and every transcription
152
+ // dies with "[Errno 2] No such file or directory: 'ffmpeg'".
132
153
  const proc = spawn(pythonForWhisper(), args, {
133
154
  stdio: ["ignore", "pipe", "inherit"],
134
155
  detached: false,
156
+ env: envWithPath(),
135
157
  });
136
158
 
137
159
  _serverProcess = proc;
@@ -236,3 +258,8 @@ export const WHISPER_PATHS = {
236
258
  whisper_server: WHISPER_SERVER,
237
259
  port: WHISPER_LOCAL_PORT,
238
260
  };
261
+
262
+ // Hand core the ability to revive the subprocess on demand. Importing this
263
+ // module (the daemon does, to preload at boot) is what wires it up — core
264
+ // itself never reaches into host/.
265
+ setLocalServerEnsure(ensureWhisperServer);
@@ -58,6 +58,69 @@ export function resolveExecChannel(args) {
58
58
  return CHANNELS.CLI;
59
59
  }
60
60
 
61
+ // Braille spinner frames for the live "pensando…" indicator.
62
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
63
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
64
+
65
+ /**
66
+ * A single-line progress indicator drawn on stderr while the super-agent turn
67
+ * runs, so `apx exec` never looks frozen. Renders only on an interactive stderr
68
+ * (TTY); when piped/redirected it is a no-op so stdout stays clean for scripts.
69
+ * Set APX_NO_SPINNER=1 to disable.
70
+ */
71
+ function createExecStatus() {
72
+ const active =
73
+ process.stderr.isTTY && process.env.APX_NO_SPINNER !== "1" && !process.env.NO_COLOR;
74
+ let label = "pensando…";
75
+ let frame = 0;
76
+ let timer = null;
77
+ const t0 = Date.now();
78
+
79
+ const draw = () => {
80
+ const secs = Math.floor((Date.now() - t0) / 1000);
81
+ process.stderr.write(`\r\x1b[2K${dim(SPINNER[frame])} ${label} ${dim(secs + "s")}`);
82
+ frame = (frame + 1) % SPINNER.length;
83
+ };
84
+
85
+ return {
86
+ start() {
87
+ if (!active || timer) return;
88
+ draw();
89
+ timer = setInterval(draw, 90);
90
+ timer.unref?.();
91
+ },
92
+ set(next) {
93
+ if (next) label = next;
94
+ },
95
+ clear() {
96
+ if (timer) {
97
+ clearInterval(timer);
98
+ timer = null;
99
+ }
100
+ if (active) process.stderr.write("\r\x1b[2K");
101
+ },
102
+ };
103
+ }
104
+
105
+ /** Map a stream event to the label shown next to the spinner. */
106
+ function labelForEvent(event) {
107
+ switch (event?.type) {
108
+ case "skill_inspector":
109
+ return "cargando skills…";
110
+ case "model_routed":
111
+ case "model_start": {
112
+ const m = event.model || event.to || event.engine;
113
+ return m ? `pensando… ${dim(m)}` : "pensando…";
114
+ }
115
+ case "tool_start":
116
+ return `${event.trace?.tool || event.tool || "herramienta"}…`;
117
+ case "tool_result":
118
+ return "pensando…";
119
+ default:
120
+ return null;
121
+ }
122
+ }
123
+
61
124
  async function readPromptFromStdin() {
62
125
  const fs = await import("node:fs");
63
126
  if (process.stdin.isTTY) return "";
@@ -99,11 +162,26 @@ export async function cmdExec(args) {
99
162
  if (args.flags["max-tokens"]) body.maxTokens = parseInt(args.flags["max-tokens"], 10);
100
163
 
101
164
  if (useSuperAgent) {
102
- const result = await http.post(`/projects/${pid}/super-agent/chat`, body);
103
- process.stdout.write(result.text + "\n");
165
+ // Stream the turn so we can render a live progress indicator instead of a
166
+ // silent hang. `confirm: false` opts out of the interactive confirmation
167
+ // round-trip (which the CLI can't answer), keeping the same semantics as the
168
+ // blocking POST /super-agent/chat endpoint.
169
+ const status = createExecStatus();
170
+ status.start();
171
+ let result;
172
+ try {
173
+ result = await http.streamPost(
174
+ `/projects/${pid}/super-agent/chat/stream`,
175
+ { ...body, confirm: false },
176
+ (event) => status.set(labelForEvent(event))
177
+ );
178
+ } finally {
179
+ status.clear();
180
+ }
181
+ process.stdout.write((result?.text ?? "") + "\n");
104
182
  if (process.stderr.isTTY || args.flags.verbose) {
105
183
  process.stderr.write(
106
- `\n— ${result.name || "super-agent"} | model=${result.trace ? "tools" : "engine"} | in=${result.usage?.input_tokens || "?"} out=${result.usage?.output_tokens || "?"}${result.model ? ` | ${result.model}` : ""}\n`
184
+ `\n— ${result?.name || "super-agent"} | model=${result?.trace ? "tools" : "engine"} | in=${result?.usage?.input_tokens || "?"} out=${result?.usage?.output_tokens || "?"}${result?.model ? ` | ${result.model}` : ""}\n`
107
185
  );
108
186
  }
109
187
  return;