@agentprojectcontext/apx 1.15.6 → 1.16.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 (221) hide show
  1. package/package.json +40 -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.js +102 -19
  28. package/src/daemon/transcription.js +262 -59
  29. package/src/daemon/whisper-server.py +57 -6
  30. package/src/overlay/index.html +44 -0
  31. package/src/overlay/main.js +480 -0
  32. package/src/overlay/package.json +3 -0
  33. package/src/overlay/preload.js +34 -0
  34. package/src/overlay/renderer.js +371 -0
  35. package/src/overlay/style.css +250 -0
  36. package/src/tui/_shims/cli-error.ts +6 -0
  37. package/src/tui/_shims/cli-logo.ts +18 -0
  38. package/src/tui/_shims/cli-ui.ts +1 -0
  39. package/src/tui/_shims/config-console-state.ts +7 -0
  40. package/src/tui/_shims/core-any.ts +30 -0
  41. package/src/tui/_shims/core-binary.ts +13 -0
  42. package/src/tui/_shims/core-flag.ts +3 -0
  43. package/src/tui/_shims/core-log.ts +14 -0
  44. package/src/tui/_shims/lsp-language.ts +1 -0
  45. package/src/tui/_shims/opencode-any.ts +135 -0
  46. package/src/tui/_shims/opencode-sdk-v2.ts +48 -0
  47. package/src/tui/_shims/plugin-tui.ts +13 -0
  48. package/src/tui/_shims/provider-provider.ts +10 -0
  49. package/src/tui/_shims/session-retry.ts +1 -0
  50. package/src/tui/_shims/session-schema.ts +15 -0
  51. package/src/tui/_shims/session-session.ts +3 -0
  52. package/src/tui/_shims/snapshot.ts +4 -0
  53. package/src/tui/_shims/tool-any.ts +18 -0
  54. package/src/tui/_shims/util-error.ts +7 -0
  55. package/src/tui/_shims/util-filesystem.ts +79 -0
  56. package/src/tui/_shims/util-format.ts +7 -0
  57. package/src/tui/_shims/util-iife.ts +3 -0
  58. package/src/tui/_shims/util-locale.ts +10 -0
  59. package/src/tui/_shims/util-process.ts +38 -0
  60. package/src/tui/app.tsx +783 -0
  61. package/src/tui/asset/charge.wav +0 -0
  62. package/src/tui/asset/pulse-a.wav +0 -0
  63. package/src/tui/asset/pulse-b.wav +0 -0
  64. package/src/tui/asset/pulse-c.wav +0 -0
  65. package/src/tui/attach.ts +100 -0
  66. package/src/tui/component/bg-pulse-render.ts +436 -0
  67. package/src/tui/component/bg-pulse.tsx +99 -0
  68. package/src/tui/component/border.tsx +21 -0
  69. package/src/tui/component/dialog-agent.tsx +31 -0
  70. package/src/tui/component/dialog-console-org.tsx +103 -0
  71. package/src/tui/component/dialog-mcp.tsx +85 -0
  72. package/src/tui/component/dialog-model.tsx +175 -0
  73. package/src/tui/component/dialog-provider.tsx +456 -0
  74. package/src/tui/component/dialog-retry-action.tsx +160 -0
  75. package/src/tui/component/dialog-session-delete-failed.tsx +99 -0
  76. package/src/tui/component/dialog-session-list.tsx +323 -0
  77. package/src/tui/component/dialog-session-rename.tsx +31 -0
  78. package/src/tui/component/dialog-skill.tsx +36 -0
  79. package/src/tui/component/dialog-stash.tsx +87 -0
  80. package/src/tui/component/dialog-status.tsx +168 -0
  81. package/src/tui/component/dialog-tag.tsx +44 -0
  82. package/src/tui/component/dialog-theme-list.tsx +50 -0
  83. package/src/tui/component/dialog-variant.tsx +39 -0
  84. package/src/tui/component/dialog-workspace-create.tsx +302 -0
  85. package/src/tui/component/dialog-workspace-file-changes.tsx +138 -0
  86. package/src/tui/component/dialog-workspace-unavailable.tsx +69 -0
  87. package/src/tui/component/error-component.tsx +92 -0
  88. package/src/tui/component/logo.tsx +896 -0
  89. package/src/tui/component/plugin-route-missing.tsx +14 -0
  90. package/src/tui/component/prompt/autocomplete.tsx +869 -0
  91. package/src/tui/component/prompt/cwd.ts +0 -0
  92. package/src/tui/component/prompt/frecency.tsx +90 -0
  93. package/src/tui/component/prompt/history.tsx +108 -0
  94. package/src/tui/component/prompt/index.tsx +1809 -0
  95. package/src/tui/component/prompt/part.ts +16 -0
  96. package/src/tui/component/prompt/stash.tsx +101 -0
  97. package/src/tui/component/prompt/traits.ts +35 -0
  98. package/src/tui/component/spinner.tsx +24 -0
  99. package/src/tui/component/startup-loading.tsx +63 -0
  100. package/src/tui/component/todo-item.tsx +32 -0
  101. package/src/tui/component/use-connected.tsx +9 -0
  102. package/src/tui/component/workspace-label.tsx +19 -0
  103. package/src/tui/config/cwd.ts +5 -0
  104. package/src/tui/config/keybind.ts +432 -0
  105. package/src/tui/config/tui-migrate.ts +154 -0
  106. package/src/tui/config/tui-schema.ts +34 -0
  107. package/src/tui/config/tui.ts +46 -0
  108. package/src/tui/context/aggregate-failures.ts +34 -0
  109. package/src/tui/context/args.tsx +15 -0
  110. package/src/tui/context/command-palette.tsx +163 -0
  111. package/src/tui/context/directory.ts +15 -0
  112. package/src/tui/context/editor-zed.ts +283 -0
  113. package/src/tui/context/editor.ts +468 -0
  114. package/src/tui/context/event-apx.ts +22 -0
  115. package/src/tui/context/event.ts +6 -0
  116. package/src/tui/context/exit.tsx +60 -0
  117. package/src/tui/context/helper.tsx +25 -0
  118. package/src/tui/context/kv.tsx +81 -0
  119. package/src/tui/context/local.tsx +608 -0
  120. package/src/tui/context/path-format.tsx +39 -0
  121. package/src/tui/context/project-apx.tsx +48 -0
  122. package/src/tui/context/project.tsx +7 -0
  123. package/src/tui/context/prompt.tsx +18 -0
  124. package/src/tui/context/route.tsx +52 -0
  125. package/src/tui/context/sdk-apx.tsx +185 -0
  126. package/src/tui/context/sdk.tsx +6 -0
  127. package/src/tui/context/sync-apx.tsx +178 -0
  128. package/src/tui/context/sync-v2.tsx +16 -0
  129. package/src/tui/context/sync.tsx +118 -0
  130. package/src/tui/context/theme/aura.json +69 -0
  131. package/src/tui/context/theme/ayu.json +80 -0
  132. package/src/tui/context/theme/carbonfox.json +248 -0
  133. package/src/tui/context/theme/catppuccin-frappe.json +230 -0
  134. package/src/tui/context/theme/catppuccin-macchiato.json +230 -0
  135. package/src/tui/context/theme/catppuccin.json +112 -0
  136. package/src/tui/context/theme/cobalt2.json +225 -0
  137. package/src/tui/context/theme/cursor.json +249 -0
  138. package/src/tui/context/theme/dracula.json +219 -0
  139. package/src/tui/context/theme/everforest.json +241 -0
  140. package/src/tui/context/theme/flexoki.json +237 -0
  141. package/src/tui/context/theme/github.json +233 -0
  142. package/src/tui/context/theme/gruvbox.json +242 -0
  143. package/src/tui/context/theme/kanagawa.json +77 -0
  144. package/src/tui/context/theme/lucent-orng.json +234 -0
  145. package/src/tui/context/theme/material.json +235 -0
  146. package/src/tui/context/theme/matrix.json +77 -0
  147. package/src/tui/context/theme/mercury.json +252 -0
  148. package/src/tui/context/theme/monokai.json +221 -0
  149. package/src/tui/context/theme/nightowl.json +221 -0
  150. package/src/tui/context/theme/nord.json +223 -0
  151. package/src/tui/context/theme/one-dark.json +84 -0
  152. package/src/tui/context/theme/opencode.json +245 -0
  153. package/src/tui/context/theme/orng.json +249 -0
  154. package/src/tui/context/theme/osaka-jade.json +93 -0
  155. package/src/tui/context/theme/palenight.json +222 -0
  156. package/src/tui/context/theme/rosepine.json +234 -0
  157. package/src/tui/context/theme/solarized.json +223 -0
  158. package/src/tui/context/theme/synthwave84.json +226 -0
  159. package/src/tui/context/theme/tokyonight.json +243 -0
  160. package/src/tui/context/theme/vercel.json +245 -0
  161. package/src/tui/context/theme/vesper.json +218 -0
  162. package/src/tui/context/theme/zenburn.json +223 -0
  163. package/src/tui/context/theme.tsx +1247 -0
  164. package/src/tui/context/tui-config.tsx +9 -0
  165. package/src/tui/event.ts +16 -0
  166. package/src/tui/feature-plugins/home/footer.tsx +94 -0
  167. package/src/tui/feature-plugins/home/tips-view.tsx +166 -0
  168. package/src/tui/feature-plugins/home/tips.tsx +59 -0
  169. package/src/tui/feature-plugins/sidebar/context.tsx +65 -0
  170. package/src/tui/feature-plugins/sidebar/files.tsx +63 -0
  171. package/src/tui/feature-plugins/sidebar/footer.tsx +94 -0
  172. package/src/tui/feature-plugins/sidebar/lsp.tsx +65 -0
  173. package/src/tui/feature-plugins/sidebar/mcp.tsx +97 -0
  174. package/src/tui/feature-plugins/sidebar/todo.tsx +49 -0
  175. package/src/tui/feature-plugins/system/plugins.tsx +269 -0
  176. package/src/tui/feature-plugins/system/session-v2.tsx +1143 -0
  177. package/src/tui/feature-plugins/system/which-key.tsx +608 -0
  178. package/src/tui/keymap.tsx +166 -0
  179. package/src/tui/layer.ts +6 -0
  180. package/src/tui/plugin/api.tsx +381 -0
  181. package/src/tui/plugin/command-shim.ts +109 -0
  182. package/src/tui/plugin/internal.ts +33 -0
  183. package/src/tui/plugin/runtime.ts +1069 -0
  184. package/src/tui/plugin/slots.tsx +60 -0
  185. package/src/tui/routes/home.tsx +96 -0
  186. package/src/tui/routes/session/dialog-fork-from-timeline.tsx +76 -0
  187. package/src/tui/routes/session/dialog-message.tsx +108 -0
  188. package/src/tui/routes/session/dialog-subagent.tsx +26 -0
  189. package/src/tui/routes/session/dialog-timeline.tsx +47 -0
  190. package/src/tui/routes/session/footer.tsx +91 -0
  191. package/src/tui/routes/session/index.tsx +188 -0
  192. package/src/tui/routes/session/permission.tsx +722 -0
  193. package/src/tui/routes/session/question.tsx +490 -0
  194. package/src/tui/routes/session/sidebar.tsx +102 -0
  195. package/src/tui/routes/session/subagent-footer.tsx +133 -0
  196. package/src/tui/run.ts +84 -0
  197. package/src/tui/thread.ts +261 -0
  198. package/src/tui/tsconfig.json +40 -0
  199. package/src/tui/ui/dialog-alert.tsx +66 -0
  200. package/src/tui/ui/dialog-confirm.tsx +108 -0
  201. package/src/tui/ui/dialog-export-options.tsx +217 -0
  202. package/src/tui/ui/dialog-help.tsx +40 -0
  203. package/src/tui/ui/dialog-prompt.tsx +101 -0
  204. package/src/tui/ui/dialog-select.tsx +553 -0
  205. package/src/tui/ui/dialog.tsx +211 -0
  206. package/src/tui/ui/link.tsx +34 -0
  207. package/src/tui/ui/spinner.ts +368 -0
  208. package/src/tui/ui/toast.tsx +111 -0
  209. package/src/tui/util/clipboard.ts +217 -0
  210. package/src/tui/util/editor.ts +37 -0
  211. package/src/tui/util/model.ts +23 -0
  212. package/src/tui/util/provider-origin.ts +7 -0
  213. package/src/tui/util/revert-diff.ts +18 -0
  214. package/src/tui/util/scroll.ts +25 -0
  215. package/src/tui/util/selection.ts +65 -0
  216. package/src/tui/util/signal.ts +41 -0
  217. package/src/tui/util/sound.ts +156 -0
  218. package/src/tui/util/transcript.ts +112 -0
  219. package/src/tui/validate-session.ts +29 -0
  220. package/src/tui/win32.ts +130 -0
  221. package/src/tui/worker.ts +104 -0
@@ -0,0 +1,73 @@
1
+ // APX CLI v2 — TypeScript entry point, yargs-based.
2
+ // This replaces the manual arg-parsing in src/cli/index.js for commands
3
+ // that have been migrated here. Unmigrated commands delegate to the legacy handler.
4
+
5
+ import yargs from "yargs";
6
+ import { hideBin } from "yargs/helpers";
7
+ import { sessionCmd } from "./commands/session.js";
8
+ import { agentCmd } from "./commands/agent.js";
9
+ import { mcpCmd } from "./commands/mcp.js";
10
+ import { daemonCmd } from "./commands/daemon.js";
11
+ import { statusCmd } from "./commands/status.js";
12
+ import { execCmd } from "./commands/exec.js";
13
+ import { chatCmd } from "./commands/chat.js";
14
+
15
+ process.on("unhandledRejection", (err) => {
16
+ process.stderr.write(
17
+ "\x1b[91m✖ Unhandled error:\x1b[0m " + String(err) + "\n",
18
+ );
19
+ process.exit(1);
20
+ });
21
+
22
+ yargs(hideBin(process.argv))
23
+ .scriptName("apx")
24
+ .usage("$0 <command> [options]")
25
+ .version(false) // we handle --version ourselves below
26
+ .option("project", {
27
+ alias: "p",
28
+ type: "string",
29
+ describe: "Project name, ID, or path",
30
+ global: true,
31
+ })
32
+ .option("json", {
33
+ type: "boolean",
34
+ describe: "Output as JSON",
35
+ global: false,
36
+ })
37
+ .command(sessionCmd)
38
+ .command(agentCmd)
39
+ .command(mcpCmd)
40
+ .command(daemonCmd)
41
+ .command(statusCmd)
42
+ .command(execCmd)
43
+ .command(chatCmd)
44
+ .command({
45
+ command: "version",
46
+ aliases: ["--version", "-v"],
47
+ describe: "Print APX version",
48
+ handler: async () => {
49
+ const { createRequire } = await import("node:module");
50
+ const req = createRequire(import.meta.url);
51
+ try {
52
+ const pkg = req("../../package.json") as { version: string };
53
+ process.stdout.write(pkg.version + "\n");
54
+ } catch {
55
+ process.stdout.write("unknown\n");
56
+ }
57
+ },
58
+ })
59
+ .demandCommand(1, "Specify a command. Use --help for available commands.")
60
+ .strict()
61
+ .wrap(Math.min(100, yargs().terminalWidth()))
62
+ .help()
63
+ .alias("help", "h")
64
+ .fail((msg, err) => {
65
+ if (err) throw err;
66
+ process.stderr.write("\x1b[91m✖ " + msg + "\x1b[0m\n\n");
67
+ process.exit(1);
68
+ })
69
+ .parseAsync()
70
+ .catch((err: Error) => {
71
+ process.stderr.write("\x1b[91m✖ " + err.message + "\x1b[0m\n");
72
+ process.exit(1);
73
+ });
@@ -0,0 +1,107 @@
1
+ // Terminal UI utilities — ANSI colors, output helpers.
2
+ // Adapted from opencode_ref/packages/opencode/src/cli/ui.ts
3
+
4
+ import { createInterface } from "node:readline";
5
+
6
+ export const Style = {
7
+ TEXT_HIGHLIGHT: "\x1b[96m",
8
+ TEXT_DIM: "\x1b[90m",
9
+ TEXT_NORMAL: "\x1b[0m",
10
+ TEXT_WARNING: "\x1b[93m",
11
+ TEXT_DANGER: "\x1b[91m",
12
+ TEXT_SUCCESS: "\x1b[92m",
13
+ TEXT_INFO: "\x1b[94m",
14
+ TEXT_BOLD: "\x1b[1m",
15
+ TEXT_BOLD_END: "\x1b[22m",
16
+ } as const;
17
+
18
+ let _lastEmpty = false;
19
+
20
+ export function println(...parts: string[]): void {
21
+ _lastEmpty = false;
22
+ process.stderr.write(parts.join(" ") + "\n");
23
+ }
24
+
25
+ export function print(...parts: string[]): void {
26
+ _lastEmpty = false;
27
+ process.stderr.write(parts.join(" "));
28
+ }
29
+
30
+ export function empty(): void {
31
+ if (_lastEmpty) return;
32
+ _lastEmpty = true;
33
+ process.stderr.write("\n");
34
+ }
35
+
36
+ export function error(message: string): void {
37
+ println(Style.TEXT_DANGER + "✖ " + Style.TEXT_NORMAL + message);
38
+ }
39
+
40
+ export function success(message: string): void {
41
+ println(Style.TEXT_SUCCESS + "✔ " + Style.TEXT_NORMAL + message);
42
+ }
43
+
44
+ export function info(message: string): void {
45
+ println(Style.TEXT_INFO + "ℹ " + Style.TEXT_NORMAL + message);
46
+ }
47
+
48
+ export function warn(message: string): void {
49
+ println(Style.TEXT_WARNING + "⚠ " + Style.TEXT_NORMAL + message);
50
+ }
51
+
52
+ export function dim(message: string): string {
53
+ return Style.TEXT_DIM + message + Style.TEXT_NORMAL;
54
+ }
55
+
56
+ export function highlight(message: string): string {
57
+ return Style.TEXT_HIGHLIGHT + message + Style.TEXT_NORMAL;
58
+ }
59
+
60
+ export function bold(message: string): string {
61
+ return Style.TEXT_BOLD + message + Style.TEXT_BOLD_END;
62
+ }
63
+
64
+ export async function input(prompt: string): Promise<string> {
65
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
66
+ return new Promise<string>((resolve) => {
67
+ rl.question(Style.TEXT_HIGHLIGHT + prompt + Style.TEXT_NORMAL + " ", (answer) => {
68
+ rl.close();
69
+ resolve(answer.trim());
70
+ });
71
+ });
72
+ }
73
+
74
+ export function logo(): void {
75
+ if (!process.stdout.isTTY) {
76
+ println(bold("APX"));
77
+ return;
78
+ }
79
+ println(
80
+ Style.TEXT_DIM +
81
+ " ██████╗ ██████╗ ██╗ ██╗\n" +
82
+ Style.TEXT_INFO +
83
+ " ██╔══██╗██╔══██╗╚██╗██╔╝\n" +
84
+ Style.TEXT_HIGHLIGHT +
85
+ " ███████║██████╔╝ ╚███╔╝ \n" +
86
+ " ██╔══██║██╔═══╝ ██╔██╗ \n" +
87
+ Style.TEXT_NORMAL +
88
+ " ██║ ██║██║ ██╔╝ ██╗\n" +
89
+ " ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝" +
90
+ Style.TEXT_NORMAL,
91
+ );
92
+ }
93
+
94
+ export function table(
95
+ rows: Array<Record<string, string>>,
96
+ cols: string[],
97
+ ): void {
98
+ const widths = cols.map((col) =>
99
+ Math.max(col.length, ...rows.map((r) => (r[col] ?? "").length)),
100
+ );
101
+ const header = cols.map((col, i) => bold(col.padEnd(widths[i]!))).join(" ");
102
+ println(header);
103
+ println(dim("─".repeat(widths.reduce((a, b) => a + b + 2, -2))));
104
+ for (const row of rows) {
105
+ println(cols.map((col, i) => (row[col] ?? "").padEnd(widths[i]!)).join(" "));
106
+ }
107
+ }
@@ -4,6 +4,10 @@ import { APX_HOME } from "./config.js";
4
4
 
5
5
  export const LOG_DIR = path.join(APX_HOME, "logs");
6
6
  export const ERROR_TRACE_PATH = path.join(LOG_DIR, "errors.jsonl");
7
+ // Unified daemon log. Every module (daemon, telegram, whisper, super-agent,
8
+ // tools, overlay) writes here with one consistent format so the user can
9
+ // follow the whole system from a single tail.
10
+ export const APX_LOG_PATH = path.join(LOG_DIR, "apx.log");
7
11
 
8
12
  const SECRET_KEY_RE = /(token|secret|password|api[_-]?key|authorization|bot[_-]?token)/i;
9
13
 
@@ -35,3 +39,80 @@ export function previewText(text, max = 500) {
35
39
  const clean = String(text || "").replace(/\s+/g, " ").trim();
36
40
  return clean.length > max ? clean.slice(0, max - 1) + "…" : clean;
37
41
  }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Unified logger — writes to ~/.apx/logs/apx.log in the format:
45
+ // [2026-05-13 22:35:01.234] [LEVEL ] [module] message {meta}
46
+ //
47
+ // `level` is INFO | WARN | ERROR (case-insensitive). Unknown levels fall back
48
+ // to INFO. `module` is a short tag (telegram, whisper, super-agent, daemon…).
49
+ // `meta` is optional; if present and non-empty, it's stringified at the end.
50
+ // Secrets in meta are redacted via the same SECRET_KEY_RE used by error traces.
51
+ //
52
+ // Returns the line that was written, so callers can also surface it elsewhere
53
+ // (e.g. process.stdout for the daemon's existing stdout log).
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const LEVELS = new Set(["INFO", "WARN", "ERROR"]);
57
+
58
+ function fmtTs(d = new Date()) {
59
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
60
+ return (
61
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
62
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
63
+ );
64
+ }
65
+
66
+ export function formatLogLine(level, module, message, meta) {
67
+ const lvl = LEVELS.has(String(level || "").toUpperCase())
68
+ ? String(level).toUpperCase()
69
+ : "INFO";
70
+ const mod = String(module || "apx").slice(0, 24);
71
+ const msg = String(message ?? "").replace(/\n/g, " ");
72
+ let suffix = "";
73
+ if (meta && typeof meta === "object" && Object.keys(meta).length > 0) {
74
+ try { suffix = " " + JSON.stringify(redact(meta)); }
75
+ catch { suffix = " {meta:unserializable}"; }
76
+ }
77
+ return `[${fmtTs()}] [${lvl.padEnd(5)}] [${mod}] ${msg}${suffix}`;
78
+ }
79
+
80
+ export function log(level, module, message, meta) {
81
+ const line = formatLogLine(level, module, message, meta);
82
+ try {
83
+ fs.mkdirSync(LOG_DIR, { recursive: true });
84
+ fs.appendFileSync(APX_LOG_PATH, line + "\n", "utf8");
85
+ } catch {
86
+ // never throw from the logger — losing a log line must not crash the daemon
87
+ }
88
+ return line;
89
+ }
90
+
91
+ // Convenience helpers so callers don't repeat the level string everywhere.
92
+ export const logInfo = (module, message, meta) => log("INFO", module, message, meta);
93
+ export const logWarn = (module, message, meta) => log("WARN", module, message, meta);
94
+ export const logError = (module, message, meta) => log("ERROR", module, message, meta);
95
+
96
+ // Build a module-bound logger so plugins can do `const log = loggerFor("telegram")`
97
+ // and then `log.info("...")` without repeating the module tag.
98
+ export function loggerFor(module) {
99
+ return {
100
+ info: (message, meta) => logInfo(module, message, meta),
101
+ warn: (message, meta) => logWarn(module, message, meta),
102
+ error: (message, meta) => logError(module, message, meta),
103
+ // Shorthand call form preserved for the daemon's old `log(msg)` callers:
104
+ // const log = loggerFor("daemon"); log("hello") // INFO
105
+ // We wrap it so `log` is callable directly *and* exposes .info/.warn/.error.
106
+ };
107
+ }
108
+
109
+ // Make a callable+method logger: `log("msg")` works AND `log.warn("msg")` works.
110
+ // This lets us replace the daemon's existing `log = (msg) => stdout.write(...)`
111
+ // with one that fans out to apx.log too, without rewriting every call site.
112
+ export function callableLogger(module) {
113
+ const fn = (message, meta) => logInfo(module, message, meta);
114
+ fn.info = (message, meta) => logInfo(module, message, meta);
115
+ fn.warn = (message, meta) => logWarn(module, message, meta);
116
+ fn.error = (message, meta) => logError(module, message, meta);
117
+ return fn;
118
+ }
package/src/daemon/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Express REST API for APX. See APC docs reference/apx-daemon.
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { execFile } from "node:child_process";
@@ -1441,6 +1442,63 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
1441
1442
  }
1442
1443
  });
1443
1444
 
1445
+ // ---- Transcription chunk (shared: overlay, Telegram, any channel) -
1446
+ // POST /transcribe/chunk ← raw audio bytes (webm, ogg, wav, mp3 …)
1447
+ // Headers: X-Audio-Format, X-Language (ISO or "auto"), X-Provider (auto|local|openai)
1448
+ // Returns: { ok, text, backend, language, … }
1449
+ app.post("/transcribe/chunk", async (req, res) => {
1450
+ const chunks = [];
1451
+ req.on("data", (c) => chunks.push(c));
1452
+ req.on("end", async () => {
1453
+ const buf = Buffer.concat(chunks);
1454
+ if (!buf.length) return res.status(400).json({ ok: false, error: "empty body" });
1455
+ const format = req.headers["x-audio-format"] || "webm";
1456
+ const language = req.headers["x-language"] || "auto";
1457
+ const provider = req.headers["x-provider"];
1458
+ try {
1459
+ const { transcribeBuffer } = await import("./transcription.js");
1460
+ const result = await transcribeBuffer(buf, format, {
1461
+ language: language === "auto" ? undefined : language,
1462
+ beam_size: 3,
1463
+ ...(provider ? { provider } : {}),
1464
+ });
1465
+ res.json(result);
1466
+ } catch (e) {
1467
+ res.status(500).json({ ok: false, error: e.message });
1468
+ }
1469
+ });
1470
+ req.on("error", (e) => res.status(500).json({ ok: false, error: e.message }));
1471
+ });
1472
+
1473
+ // ---- Overlay channel (voice/floating window) ----------------------
1474
+ app.get("/overlay/status", (_req, res) => {
1475
+ // Lazy import to avoid hard dep
1476
+ import("./overlay-ws.js").then(({ overlayClients }) => {
1477
+ res.json({ ok: true, connected_clients: overlayClients.size });
1478
+ }).catch(() => res.json({ ok: true, connected_clients: 0 }));
1479
+ });
1480
+
1481
+ // POST /overlay/message ← text sent by the overlay after transcription
1482
+ // Runs the super-agent and streams tokens back via WebSocket.
1483
+ app.post("/overlay/message", async (req, res) => {
1484
+ const { text, previousMessages = [] } = req.body || {};
1485
+ if (!text) return res.status(400).json({ error: "text required" });
1486
+ res.json({ ok: true }); // respond immediately; result comes via WebSocket
1487
+
1488
+ // Inline execution — the overlay plugin handles the heavy lift;
1489
+ // here we just trigger it if the plugin is registered.
1490
+ try {
1491
+ const overlayPlugin = plugins.instances.get("overlay");
1492
+ if (overlayPlugin?.handleMessage) {
1493
+ await overlayPlugin.handleMessage({ text, previousMessages });
1494
+ }
1495
+ } catch (e) {
1496
+ import("./overlay-ws.js").then(({ broadcastOverlay }) => {
1497
+ broadcastOverlay({ type: "error", message: e.message });
1498
+ }).catch(() => {});
1499
+ }
1500
+ });
1501
+
1444
1502
  // ---- Admin --------------------------------------------------------
1445
1503
  app.post("/admin/shutdown", (_req, res) => {
1446
1504
  res.json({ ok: true });
@@ -11,7 +11,7 @@ function getKey(config) {
11
11
  export default {
12
12
  id: "anthropic",
13
13
 
14
- async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {}, tools, toolChoice, signal }) {
14
+ async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {}, tools, toolChoice, signal, onToken }) {
15
15
  const key = getKey(config);
16
16
  if (!key) throw new Error("anthropic: no api_key (set ANTHROPIC_API_KEY or engines.anthropic.api_key)");
17
17
  if (!model) throw new Error("anthropic: model required");
@@ -39,6 +39,65 @@ export default {
39
39
  }
40
40
  }
41
41
 
42
+ // Streaming path — only when onToken provided AND no tool_choice=required
43
+ // (we can't stream tool-forced turns because tool_calls are embedded in SSE)
44
+ if (typeof onToken === "function" && toolChoice !== "required" && toolChoice !== "any") {
45
+ body.stream = true;
46
+ const res = await fetch(API_BASE, {
47
+ method: "POST",
48
+ headers: {
49
+ "content-type": "application/json",
50
+ "x-api-key": key,
51
+ "anthropic-version": API_VERSION,
52
+ },
53
+ body: JSON.stringify(body),
54
+ signal,
55
+ });
56
+ if (!res.ok) {
57
+ const err = await res.text().catch(() => "");
58
+ throw new Error(`anthropic ${res.status}: ${err.slice(0, 200)}`);
59
+ }
60
+
61
+ const decoder = new TextDecoder();
62
+ let text = "";
63
+ let inputTokens = 0;
64
+ let outputTokens = 0;
65
+ let stopReason = null;
66
+ let buf = "";
67
+
68
+ for await (const chunk of res.body) {
69
+ buf += decoder.decode(chunk, { stream: true });
70
+ const lines = buf.split("\n");
71
+ buf = lines.pop(); // keep incomplete last line
72
+ for (const line of lines) {
73
+ if (!line.startsWith("data: ")) continue;
74
+ const raw = line.slice(6).trim();
75
+ if (raw === "[DONE]") continue;
76
+ let evt;
77
+ try { evt = JSON.parse(raw); } catch { continue; }
78
+ if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta") {
79
+ const t = evt.delta.text || "";
80
+ if (t) { text += t; onToken(t); }
81
+ } else if (evt.type === "message_delta") {
82
+ stopReason = evt.delta?.stop_reason || stopReason;
83
+ outputTokens = evt.usage?.output_tokens || outputTokens;
84
+ } else if (evt.type === "message_start") {
85
+ inputTokens = evt.message?.usage?.input_tokens || 0;
86
+ outputTokens = evt.message?.usage?.output_tokens || 0;
87
+ }
88
+ }
89
+ }
90
+
91
+ return {
92
+ text,
93
+ tool_uses: undefined,
94
+ stop_reason: stopReason,
95
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
96
+ raw: null,
97
+ };
98
+ }
99
+
100
+ // Non-streaming path (original)
42
101
  const res = await fetch(API_BASE, {
43
102
  method: "POST",
44
103
  headers: {
@@ -46,7 +46,7 @@ export function getAdapter(provider) {
46
46
  return a;
47
47
  }
48
48
 
49
- export async function callEngine({ modelId, system, messages, config, temperature, maxTokens, tools, toolChoice, signal }) {
49
+ export async function callEngine({ modelId, system, messages, config, temperature, maxTokens, tools, toolChoice, signal, onToken }) {
50
50
  const { provider, model } = resolveProvider(modelId);
51
51
  const adapter = getAdapter(provider);
52
52
  const providerCfg = (config && config.engines && config.engines[provider]) || {};
@@ -60,6 +60,7 @@ export async function callEngine({ modelId, system, messages, config, temperatur
60
60
  toolChoice,
61
61
  config: providerCfg,
62
62
  signal,
63
+ onToken,
63
64
  });
64
65
  }
65
66
 
@@ -8,15 +8,32 @@ function baseUrl(config) {
8
8
  export default {
9
9
  id: "ollama",
10
10
 
11
- async chat({ system, messages, model, temperature = 0.7, maxTokens = 1024, tools, config = {}, signal }) {
11
+ async chat({ system, messages, model, temperature = 0.7, maxTokens = 1024, tools, toolChoice, config = {}, signal, onToken }) {
12
12
  if (!model) throw new Error("ollama: model required");
13
13
 
14
+ // Ollama's /api/chat does not honor a tool_choice field. When the caller
15
+ // wants to force a tool call ("required" / "any") we inject a strong
16
+ // system-message hint instead so the model is much less likely to emit a
17
+ // text-only acknowledgement like "ok dame un minuto" without calling a tool.
18
+ const forceTool =
19
+ Array.isArray(tools) && tools.length > 0 &&
20
+ (toolChoice === "required" || toolChoice === "any");
21
+
22
+ let effectiveSystem = system;
23
+ if (forceTool) {
24
+ const hint =
25
+ "You MUST call one of the available tools to satisfy this turn. " +
26
+ "Do NOT reply with text-only acknowledgements (no 'ok', 'sure', 'on it', 'dame un minuto'). " +
27
+ "If you cannot decide which tool, pick the closest match and call it.";
28
+ effectiveSystem = system ? `${system}\n\n${hint}` : hint;
29
+ }
30
+
14
31
  // The caller can pass `messages` as either:
15
32
  // [{role, content}] — usual shape
16
33
  // [{role, content, tool_calls?}, {role: "tool", tool_call_id?, content}, ...]
17
34
  // We forward those fields straight through so the agent loop works.
18
35
  const fullMessages = [];
19
- if (system) fullMessages.push({ role: "system", content: system });
36
+ if (effectiveSystem) fullMessages.push({ role: "system", content: effectiveSystem });
20
37
  for (const m of messages) {
21
38
  const out = { role: m.role };
22
39
  if (m.content !== undefined) {
@@ -30,6 +47,57 @@ export default {
30
47
  fullMessages.push(out);
31
48
  }
32
49
 
50
+ const url = `${baseUrl(config).replace(/\/$/, "")}/api/chat`;
51
+
52
+ // Streaming path — only when onToken provided AND no tools (Ollama streaming + tools is unreliable)
53
+ if (typeof onToken === "function" && (!tools || tools.length === 0)) {
54
+ const body = {
55
+ model,
56
+ messages: fullMessages,
57
+ stream: true,
58
+ options: { temperature, num_predict: maxTokens },
59
+ };
60
+ const res = await fetch(url, {
61
+ method: "POST",
62
+ headers: { "content-type": "application/json" },
63
+ body: JSON.stringify(body),
64
+ signal,
65
+ });
66
+ if (!res.ok) {
67
+ const t = await res.text();
68
+ throw new Error(`ollama ${res.status}: ${t}`);
69
+ }
70
+ const decoder = new TextDecoder();
71
+ let text = "";
72
+ let inputTokens = 0;
73
+ let outputTokens = 0;
74
+ let buf = "";
75
+ for await (const chunk of res.body) {
76
+ buf += decoder.decode(chunk, { stream: true });
77
+ const lines = buf.split("\n");
78
+ buf = lines.pop();
79
+ for (const line of lines) {
80
+ if (!line.trim()) continue;
81
+ let evt;
82
+ try { evt = JSON.parse(line); } catch { continue; }
83
+ const t = evt.message?.content || "";
84
+ if (t) { text += t; onToken(t); }
85
+ if (evt.done) {
86
+ inputTokens = evt.prompt_eval_count || 0;
87
+ outputTokens = evt.eval_count || 0;
88
+ }
89
+ }
90
+ }
91
+ return {
92
+ text,
93
+ tool_calls: null,
94
+ message: { role: "assistant", content: text },
95
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
96
+ raw: null,
97
+ };
98
+ }
99
+
100
+ // Non-streaming path (original)
33
101
  const body = {
34
102
  model,
35
103
  messages: fullMessages,
@@ -40,7 +108,6 @@ export default {
40
108
  body.tools = tools;
41
109
  }
42
110
 
43
- const url = `${baseUrl(config).replace(/\/$/, "")}/api/chat`;
44
111
  const res = await fetch(url, {
45
112
  method: "POST",
46
113
  headers: { "content-type": "application/json" },
@@ -21,6 +21,8 @@ import { PluginManager } from "./plugins/index.js";
21
21
  import { RoutineScheduler } from "./routines.js";
22
22
  import { buildApi } from "./api.js";
23
23
  import { triggerWakeup } from "./wakeup.js";
24
+ import { registerOverlayClient } from "./overlay-ws.js";
25
+ import { log as logToUnified } from "../core/logging.js";
24
26
 
25
27
  const __filename = fileURLToPath(import.meta.url);
26
28
  const __dirname = path.dirname(__filename);
@@ -32,8 +34,41 @@ const PKG = JSON.parse(
32
34
  // to ~/.apx/daemon.log via `stdio: ["ignore", out, out]`. So a single
33
35
  // process.stdout.write reaches the file once. In foreground (npm start), it
34
36
  // still prints to the console. No double-append.
37
+ //
38
+ // Beyond the legacy stdout sink we also fan out every line to the unified
39
+ // ~/.apx/logs/apx.log via core/logging.js so `apx log` and `apx log -f`
40
+ // see everything that any plugin/module writes through the daemon's log fn.
41
+ //
42
+ // Heuristic for level/module inference: messages prefixed "fatal:" /
43
+ // "uncaughtException:" / "error:" are ERROR; "warn:" / "could not" / "skipping"
44
+ // are WARN; everything else INFO. Plugins normally pass `plugin <id> ...` or
45
+ // `<id>[<name>] ...` — we use the first bracketed token (or first word before
46
+ // ":") as the module tag.
47
+ function inferLevel(msg) {
48
+ if (/^fatal:|^uncaughtException:|^error:|failed|crash/i.test(msg)) return "ERROR";
49
+ if (/^warn:|could not|skipping|orphan|broken pipe/i.test(msg)) return "WARN";
50
+ return "INFO";
51
+ }
52
+ function inferModule(msg) {
53
+ // "plugin telegram initialized" → telegram
54
+ const plug = msg.match(/^plugin\s+([a-z_-]+)/i);
55
+ if (plug) return plug[1];
56
+ // "telegram[default] ..." → telegram
57
+ const bracket = msg.match(/^([a-z_-]+)\[/i);
58
+ if (bracket) return bracket[1];
59
+ // "whisper: preloading ..." → whisper
60
+ const colon = msg.match(/^([a-z_-]+):\s/i);
61
+ if (colon) return colon[1];
62
+ // "overlay: ..." caught above; "loaded project ..." → daemon
63
+ return "daemon";
64
+ }
35
65
  const log = (msg) => {
36
66
  process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
67
+ try {
68
+ logToUnified(inferLevel(msg), inferModule(msg), msg);
69
+ } catch {
70
+ // logger is best-effort, never throw
71
+ }
37
72
  };
38
73
 
39
74
  function ensureHome() {
@@ -170,6 +205,25 @@ async function main() {
170
205
  scheduler.start();
171
206
  // Fire wake-up message after a short delay so plugins (Telegram) are ready
172
207
  setTimeout(() => triggerWakeup(cfg, log), 3000);
208
+ // Preload whisper-server in the background so first overlay transcription is fast.
209
+ // Adopts an existing one if already on the port; otherwise spawns fresh.
210
+ import("./transcription.js").then(({ preloadWhisperServer }) => {
211
+ preloadWhisperServer((m) => log(m));
212
+ }).catch(() => {});
213
+ });
214
+
215
+ // Attach WebSocket upgrade for overlay channel on /overlay/ws
216
+ server.on("upgrade", async (req, socket, head) => {
217
+ if (req.url !== "/overlay/ws") { socket.destroy(); return; }
218
+ // Lazy-import ws to avoid hard dep on startup
219
+ let WebSocketServer;
220
+ try { ({ WebSocketServer } = await import("ws")); } catch {
221
+ socket.destroy(); return;
222
+ }
223
+ const wss = new WebSocketServer({ noServer: true });
224
+ wss.handleUpgrade(req, socket, head, (ws) => {
225
+ registerOverlayClient(ws);
226
+ });
173
227
  });
174
228
 
175
229
  server.on("error", (e) => {
@@ -185,6 +239,10 @@ async function main() {
185
239
  scheduler.stop();
186
240
  plugins.stopAll();
187
241
  registries.shutdown();
242
+ // Best-effort shutdown of whisper-server subprocess.
243
+ import("./transcription.js").then(({ shutdownWhisperServer }) => {
244
+ shutdownWhisperServer().catch(() => {});
245
+ }).catch(() => {});
188
246
  server.close(() => {
189
247
  clearPid();
190
248
  process.exit(0);
@@ -0,0 +1,40 @@
1
+ // Singleton WebSocket hub for the overlay channel.
2
+ // Imported by api.js (to register connections) and by plugins/overlay.js (to broadcast).
3
+
4
+ const _clients = new Set(); // Set<WebSocket>
5
+ let _messageHandler = null; // (ws, data) => void — set by overlay plugin
6
+
7
+ export const overlayClients = _clients;
8
+
9
+ export function setOverlayMessageHandler(fn) {
10
+ _messageHandler = fn;
11
+ }
12
+
13
+ export function registerOverlayClient(ws) {
14
+ _clients.add(ws);
15
+ ws.on("close", () => _clients.delete(ws));
16
+ ws.on("error", () => _clients.delete(ws));
17
+ ws.on("message", (raw) => {
18
+ if (typeof _messageHandler === "function") {
19
+ let data;
20
+ try { data = JSON.parse(raw.toString()); } catch { data = { type: "raw", raw: raw.toString() }; }
21
+ _messageHandler(ws, data);
22
+ }
23
+ });
24
+ }
25
+
26
+ export function broadcastOverlay(msg) {
27
+ const payload = typeof msg === "string" ? msg : JSON.stringify(msg);
28
+ for (const ws of _clients) {
29
+ try {
30
+ if (ws.readyState === 1) ws.send(payload); // 1 = OPEN
31
+ } catch {}
32
+ }
33
+ }
34
+
35
+ export function sendToClient(ws, msg) {
36
+ const payload = typeof msg === "string" ? msg : JSON.stringify(msg);
37
+ try {
38
+ if (ws.readyState === 1) ws.send(payload);
39
+ } catch {}
40
+ }
@@ -13,8 +13,9 @@
13
13
  // Plugins are discovered by static import here. Adding a new plugin = importing
14
14
  // it and pushing into PLUGINS.
15
15
  import telegramPlugin from "./telegram.js";
16
+ import overlayPlugin from "./overlay.js";
16
17
 
17
- export const PLUGINS = [telegramPlugin];
18
+ export const PLUGINS = [telegramPlugin, overlayPlugin];
18
19
 
19
20
  export class PluginManager {
20
21
  constructor({ projects, config, log, registries }) {