@agentprojectcontext/apx 1.33.1 → 1.34.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 (169) hide show
  1. package/package.json +1 -1
  2. package/skills/apx/SKILL.md +49 -61
  3. package/src/core/agent/a2a/reply.js +48 -0
  4. package/src/core/agent/build-agent-system.js +4 -3
  5. package/src/core/agent/channels/voice-context.js +98 -0
  6. package/src/core/agent/memory.js +2 -1
  7. package/src/core/agent/prompt-builder.js +2 -1
  8. package/src/core/agent/prompts/modes/code-build.md +1 -0
  9. package/src/core/agent/prompts/modes/code-plan.md +1 -0
  10. package/src/core/agent/prompts/modes/index.js +28 -0
  11. package/src/core/agent/skills/loader.js +22 -18
  12. package/src/core/agent/stream/turn-accumulator.js +73 -0
  13. package/src/core/agent/suggestions.js +37 -0
  14. package/src/core/agent/tools/handlers/add-project.js +5 -2
  15. package/src/core/agent/tools/handlers/call-runtime.js +3 -2
  16. package/src/core/agent/tools/handlers/transcribe-audio.js +1 -1
  17. package/src/core/agent/tools/helpers.js +2 -2
  18. package/src/core/agent/tools/names.js +138 -0
  19. package/src/core/agent/tools/registry-bridge.js +6 -14
  20. package/src/core/agent/tools/registry.js +68 -65
  21. package/src/core/apc/context-copy.js +27 -0
  22. package/src/core/apc/notes.js +19 -0
  23. package/src/core/apc/parser.js +12 -5
  24. package/src/core/apc/paths.js +87 -0
  25. package/src/core/apc/scaffold.js +82 -76
  26. package/src/core/apc/skill-sync.js +10 -0
  27. package/src/{host/daemon/plugins → core/channels}/telegram/dispatch.js +38 -16
  28. package/src/core/config/index.js +3 -2
  29. package/src/core/config/redact.js +95 -0
  30. package/src/core/constants/channels.js +2 -0
  31. package/src/core/constants/code-modes.js +10 -0
  32. package/src/core/constants/index.js +1 -0
  33. package/src/core/deck/manifest.js +186 -0
  34. package/src/core/engines/catalog.js +83 -0
  35. package/src/core/{tools → http-tools}/browser.js +0 -1
  36. package/src/core/{tools → http-tools}/fetch.js +0 -1
  37. package/src/core/{tools → http-tools}/glob.js +0 -1
  38. package/src/core/{tools → http-tools}/grep.js +0 -1
  39. package/src/core/{tools → http-tools}/registry.js +0 -1
  40. package/src/core/{tools → http-tools}/search.js +0 -1
  41. package/src/core/i18n/en.js +9 -0
  42. package/src/core/i18n/es.js +12 -0
  43. package/src/core/i18n/index.js +54 -0
  44. package/src/core/i18n/pt.js +9 -0
  45. package/src/core/identity/telegram.js +2 -1
  46. package/src/core/mcp/runner.js +272 -14
  47. package/src/core/mcp/sources.js +3 -2
  48. package/src/core/routines/index.js +16 -0
  49. package/src/{host/daemon/routines.js → core/routines/runner.js} +36 -103
  50. package/src/core/runtime-skills/apc-context/SKILL.md +159 -0
  51. package/src/core/runtime-skills/apx/SKILL.md +95 -0
  52. package/src/core/runtime-skills/apx-mcp/SKILL.md +116 -0
  53. package/src/core/runtime-skills/{claude-code.md → claude-code/SKILL.md} +1 -0
  54. package/src/core/runtime-skills/{codex-cli.md → codex-cli/SKILL.md} +1 -0
  55. package/src/core/runtime-skills/{opencode-cli.md → opencode-cli/SKILL.md} +1 -0
  56. package/src/core/runtime-skills/{openrouter.md → openrouter/SKILL.md} +1 -0
  57. package/src/{host/daemon/env-detect.js → core/runtimes/detect.js} +1 -1
  58. package/src/core/stores/code-sessions.js +50 -2
  59. package/src/core/stores/routine-memory.js +1 -1
  60. package/src/core/stores/sessions-search.js +121 -0
  61. package/src/core/stores/sessions.js +38 -0
  62. package/src/core/vars/index.js +14 -0
  63. package/src/core/vars/interpolate.js +86 -0
  64. package/src/core/vars/sources.js +151 -0
  65. package/src/core/voice/audio-decode.js +38 -0
  66. package/src/core/voice/transcription.js +225 -0
  67. package/src/host/daemon/api/admin-config.js +5 -82
  68. package/src/host/daemon/api/agents.js +5 -5
  69. package/src/host/daemon/api/code.js +17 -169
  70. package/src/host/daemon/api/config.js +3 -4
  71. package/src/host/daemon/api/conversations.js +8 -29
  72. package/src/host/daemon/api/deck.js +37 -404
  73. package/src/host/daemon/api/engines.js +1 -80
  74. package/src/host/daemon/api/exec.js +1 -1
  75. package/src/host/daemon/api/mcps.js +32 -0
  76. package/src/host/daemon/api/routines.js +1 -1
  77. package/src/host/daemon/api/runtimes.js +4 -3
  78. package/src/host/daemon/api/sessions-search.js +24 -140
  79. package/src/host/daemon/api/sessions.js +12 -30
  80. package/src/host/daemon/api/shared.js +2 -1
  81. package/src/host/daemon/api/telegram.js +1 -11
  82. package/src/host/daemon/api/tools.js +6 -6
  83. package/src/host/daemon/api/transcribe.js +2 -2
  84. package/src/host/daemon/api/vars.js +137 -0
  85. package/src/host/daemon/api/voice.js +13 -290
  86. package/src/host/daemon/api.js +2 -0
  87. package/src/host/daemon/db.js +6 -6
  88. package/src/host/daemon/deck-exec.js +148 -0
  89. package/src/host/daemon/index.js +3 -3
  90. package/src/host/daemon/plugins/telegram/index.js +9 -9
  91. package/src/host/daemon/routines-scheduler.js +64 -0
  92. package/src/host/daemon/smoke.js +3 -2
  93. package/src/host/daemon/whisper-server.js +225 -0
  94. package/src/interfaces/cli/commands/agent.js +3 -2
  95. package/src/interfaces/cli/commands/command.js +2 -3
  96. package/src/interfaces/cli/commands/messages.js +6 -2
  97. package/src/interfaces/cli/commands/pair.js +5 -4
  98. package/src/interfaces/cli/commands/search.js +1 -1
  99. package/src/interfaces/cli/commands/sessions.js +3 -2
  100. package/src/interfaces/cli/commands/skills.js +36 -55
  101. package/src/interfaces/web/dist/assets/index-DdmSRtsz.css +1 -0
  102. package/src/interfaces/web/dist/assets/index-M4FspaCH.js +613 -0
  103. package/src/interfaces/web/dist/assets/index-M4FspaCH.js.map +1 -0
  104. package/src/interfaces/web/dist/index.html +2 -2
  105. package/src/interfaces/web/package-lock.json +182 -182
  106. package/src/interfaces/web/src/components/ModelCombobox.tsx +2 -1
  107. package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +1 -1
  108. package/src/interfaces/web/src/components/chat/AskAnswersCard.tsx +76 -0
  109. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  110. package/src/interfaces/web/src/components/chat/MessageList.tsx +23 -1
  111. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +3 -1
  112. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +4 -4
  113. package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +1 -1
  114. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +3 -2
  115. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +3 -2
  116. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +3 -2
  117. package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -1
  118. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +2 -1
  119. package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +93 -0
  120. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +449 -0
  121. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +2 -1
  122. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +2 -2
  123. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +5 -4
  124. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +3 -2
  125. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +3 -2
  126. package/src/interfaces/web/src/components/ui/chat-input.tsx +5 -4
  127. package/src/interfaces/web/src/components/ui/sidebar.tsx +3 -2
  128. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +2 -1
  129. package/src/interfaces/web/src/constants/index.ts +1 -1
  130. package/src/interfaces/web/src/i18n/en.ts +174 -7
  131. package/src/interfaces/web/src/i18n/es.ts +179 -15
  132. package/src/interfaces/web/src/lib/api/mcps.ts +25 -0
  133. package/src/interfaces/web/src/lib/api/vars.ts +38 -0
  134. package/src/interfaces/web/src/lib/api.ts +1 -0
  135. package/src/interfaces/web/src/screens/ProjectScreen.tsx +8 -31
  136. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +1 -1
  137. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +4 -3
  138. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +7 -6
  139. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +4 -3
  140. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +1 -1
  141. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +132 -1
  142. package/src/interfaces/web/src/screens/project/McpsTab.tsx +549 -104
  143. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +1 -1
  144. package/src/interfaces/web/src/screens/project/VarsTab.tsx +300 -0
  145. package/src/interfaces/web/src/types/daemon.ts +5 -0
  146. package/src/host/daemon/transcription.js +0 -538
  147. package/src/host/daemon/whisper-transcribe.py +0 -73
  148. package/src/interfaces/web/dist/assets/index-Aaiw8BZN.css +0 -1
  149. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js +0 -602
  150. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js.map +0 -1
  151. /package/src/{host/daemon → core/apc}/projects-helpers.js +0 -0
  152. /package/src/{host/daemon/plugins → core/channels}/telegram/ask.js +0 -0
  153. /package/src/{host/daemon/plugins → core/channels}/telegram/helpers.js +0 -0
  154. /package/src/{host/daemon/plugins → core/channels}/telegram/media.js +0 -0
  155. /package/src/core/{tools → http-tools}/index.js +0 -0
  156. /package/{skills → src/core/runtime-skills}/apx-agency-agents/SKILL.md +0 -0
  157. /package/{skills → src/core/runtime-skills}/apx-agent/SKILL.md +0 -0
  158. /package/{skills → src/core/runtime-skills}/apx-mcp-builder/SKILL.md +0 -0
  159. /package/{skills → src/core/runtime-skills}/apx-project/SKILL.md +0 -0
  160. /package/{skills → src/core/runtime-skills}/apx-routine/SKILL.md +0 -0
  161. /package/{skills → src/core/runtime-skills}/apx-runtime/SKILL.md +0 -0
  162. /package/{skills → src/core/runtime-skills}/apx-sessions/SKILL.md +0 -0
  163. /package/{skills → src/core/runtime-skills}/apx-skill-builder/SKILL.md +0 -0
  164. /package/{skills → src/core/runtime-skills}/apx-task/SKILL.md +0 -0
  165. /package/{skills → src/core/runtime-skills}/apx-telegram/SKILL.md +0 -0
  166. /package/{skills → src/core/runtime-skills}/apx-voice/SKILL.md +0 -0
  167. /package/src/{host/daemon/compact.js → core/stores/conversations-compactor.js} +0 -0
  168. /package/src/{host/daemon → core/stores}/conversations.js +0 -0
  169. /package/src/{host/daemon → core/util}/thinking.js +0 -0
@@ -0,0 +1,14 @@
1
+ export {
2
+ globalVarsPath,
3
+ projectVarsPath,
4
+ readGlobalVars,
5
+ writeGlobalVars,
6
+ readProjectVars,
7
+ writeProjectVars,
8
+ loadAllVars,
9
+ setVar,
10
+ deleteVar,
11
+ maskValue,
12
+ } from "./sources.js";
13
+
14
+ export { interpolate, findRefs, MissingVarError } from "./interpolate.js";
@@ -0,0 +1,86 @@
1
+ // ${var.NAME} interpolation engine.
2
+ //
3
+ // Used at MCP boot (and any other site that opts in) so files committed to
4
+ // the repo can hold references like
5
+ // "Authorization": "Bearer ${var.ASANA_TOKEN}"
6
+ // while the real value lives in ~/.apx/vars.json or
7
+ // <storagePath>/vars.json (see ./sources.js).
8
+ //
9
+ // Semantics:
10
+ // - Only top-level strings inside the input object/array are walked
11
+ // recursively. Numbers/booleans/null pass through.
12
+ // - A `${var.NAME}` token where NAME is missing throws a MissingVarError
13
+ // listing every missing name (so the UI can show a single useful message
14
+ // instead of "first failure wins").
15
+ // - Names match [A-Z0-9_] / [a-z0-9_] / dot — we don't enforce a charset
16
+ // beyond "no whitespace and no closing brace". Stay liberal here, strict
17
+ // at the UI.
18
+
19
+ const VAR_RE = /\$\{var\.([^}\s]+)\}/g;
20
+
21
+ export class MissingVarError extends Error {
22
+ constructor(missing) {
23
+ super(
24
+ `Undefined variable${missing.length > 1 ? "s" : ""}: ${missing
25
+ .map((n) => `\${var.${n}}`)
26
+ .join(", ")}`
27
+ );
28
+ this.name = "MissingVarError";
29
+ this.missing = missing;
30
+ }
31
+ }
32
+
33
+ // Collect every `${var.NAME}` reference found in `value` (deep walk).
34
+ // Returns an array of unique names in encounter order.
35
+ export function findRefs(value) {
36
+ const seen = new Set();
37
+ const walk = (v) => {
38
+ if (typeof v === "string") {
39
+ let m;
40
+ VAR_RE.lastIndex = 0;
41
+ while ((m = VAR_RE.exec(v)) !== null) seen.add(m[1]);
42
+ return;
43
+ }
44
+ if (Array.isArray(v)) {
45
+ for (const x of v) walk(x);
46
+ return;
47
+ }
48
+ if (v && typeof v === "object") {
49
+ for (const x of Object.values(v)) walk(x);
50
+ }
51
+ };
52
+ walk(value);
53
+ return Array.from(seen);
54
+ }
55
+
56
+ // Replace every `${var.NAME}` inside `value` using the `vars` lookup. Missing
57
+ // names accumulate and surface as a single MissingVarError at the end so
58
+ // callers can show "missing: TOKEN_A, TOKEN_B" in one shot.
59
+ export function interpolate(value, vars) {
60
+ const missing = new Set();
61
+
62
+ const replaceString = (s) => {
63
+ return s.replace(VAR_RE, (_, name) => {
64
+ if (Object.prototype.hasOwnProperty.call(vars, name)) {
65
+ return String(vars[name]);
66
+ }
67
+ missing.add(name);
68
+ return _;
69
+ });
70
+ };
71
+
72
+ const walk = (v) => {
73
+ if (typeof v === "string") return replaceString(v);
74
+ if (Array.isArray(v)) return v.map(walk);
75
+ if (v && typeof v === "object") {
76
+ const out = {};
77
+ for (const [k, x] of Object.entries(v)) out[k] = walk(x);
78
+ return out;
79
+ }
80
+ return v;
81
+ };
82
+
83
+ const result = walk(value);
84
+ if (missing.size) throw new MissingVarError(Array.from(missing));
85
+ return result;
86
+ }
@@ -0,0 +1,151 @@
1
+ // Variable storage for APX. Two scopes:
2
+ // global — ~/.apx/vars.json (chmod 0600)
3
+ // project — <storagePath>/vars.json (chmod 0600)
4
+ // i.e. ~/.apx/projects/<apxId>/vars.json
5
+ //
6
+ // Both files live outside the project repo so values never get committed.
7
+ // The .apc/ files committed to the repo only reference vars by name
8
+ // (e.g. `${var.ASANA_TOKEN}`) — actual values live here.
9
+ //
10
+ // Each file is a flat object: { "NAME": "value", ... }. Names are
11
+ // uppercase letters / digits / underscore by convention (we don't enforce it
12
+ // — anything safe to interpolate works).
13
+ //
14
+ // project wins over global when the same name exists in both.
15
+
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+
20
+ const APX_HOME = path.join(os.homedir(), ".apx");
21
+ const GLOBAL_VARS_FILE = path.join(APX_HOME, "vars.json");
22
+ const PROJECT_VARS_FILENAME = "vars.json";
23
+
24
+ export function globalVarsPath() {
25
+ return GLOBAL_VARS_FILE;
26
+ }
27
+
28
+ export function projectVarsPath(storagePath) {
29
+ if (!storagePath) return null;
30
+ return path.join(storagePath, PROJECT_VARS_FILENAME);
31
+ }
32
+
33
+ function readJsonSafe(absPath) {
34
+ if (!absPath || !fs.existsSync(absPath)) return {};
35
+ try {
36
+ const json = JSON.parse(fs.readFileSync(absPath, "utf8"));
37
+ return json && typeof json === "object" && !Array.isArray(json) ? json : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function writeJsonSecure(absPath, obj) {
44
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
45
+ fs.writeFileSync(absPath, JSON.stringify(obj, null, 2) + "\n");
46
+ try {
47
+ fs.chmodSync(absPath, 0o600);
48
+ } catch {
49
+ // Best effort on non-POSIX filesystems.
50
+ }
51
+ }
52
+
53
+ export function readGlobalVars() {
54
+ return readJsonSafe(GLOBAL_VARS_FILE);
55
+ }
56
+
57
+ export function writeGlobalVars(obj) {
58
+ writeJsonSecure(GLOBAL_VARS_FILE, obj);
59
+ }
60
+
61
+ export function readProjectVars(storagePath) {
62
+ return readJsonSafe(projectVarsPath(storagePath));
63
+ }
64
+
65
+ export function writeProjectVars(storagePath, obj) {
66
+ if (!storagePath) throw new Error("writeProjectVars: storagePath required");
67
+ writeJsonSecure(projectVarsPath(storagePath), obj);
68
+ }
69
+
70
+ function sanitizeMap(obj) {
71
+ const out = {};
72
+ for (const [k, v] of Object.entries(obj || {})) {
73
+ out[k] = String(v)
74
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "")
75
+ .replace(/\u00A0/g, " ")
76
+ .trim();
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // Aggregate project + global with project winning.
82
+ // Returns { project, global, effective, sources } where sources[name] is
83
+ // "project" or "global" so callers know where each effective value came from.
84
+ // Values are sanitized at read time so legacy entries written before the
85
+ // save-time trim land also come out clean.
86
+ export function loadAllVars({ storagePath } = {}) {
87
+ const project = sanitizeMap(storagePath ? readProjectVars(storagePath) : {});
88
+ const global = sanitizeMap(readGlobalVars());
89
+ const effective = { ...global, ...project };
90
+ const sources = {};
91
+ for (const name of Object.keys(global)) sources[name] = "global";
92
+ for (const name of Object.keys(project)) sources[name] = "project";
93
+ return { project, global, effective, sources };
94
+ }
95
+
96
+ // Strip leading/trailing whitespace + invisible chars (ZWSP, BOM, …). The #1
97
+ // reason a pasted token "doesn't work" is a stray newline picked up from the
98
+ // copy buffer; defaulting to trim removes that whole class of bugs while
99
+ // leaving real values untouched.
100
+ function sanitizeVarValue(raw) {
101
+ return String(raw)
102
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "")
103
+ .replace(/\u00A0/g, " ")
104
+ .trim();
105
+ }
106
+
107
+ // Convenience: set/unset on either scope. Returns the new full object.
108
+ export function setVar({ storagePath, scope, name, value }) {
109
+ const v = sanitizeVarValue(value);
110
+ if (scope === "project") {
111
+ if (!storagePath) throw new Error("project scope requires storagePath");
112
+ const obj = readProjectVars(storagePath);
113
+ obj[name] = v;
114
+ writeProjectVars(storagePath, obj);
115
+ return obj;
116
+ }
117
+ if (scope === "global") {
118
+ const obj = readGlobalVars();
119
+ obj[name] = v;
120
+ writeGlobalVars(obj);
121
+ return obj;
122
+ }
123
+ throw new Error(`unknown scope "${scope}"`);
124
+ }
125
+
126
+ export function deleteVar({ storagePath, scope, name }) {
127
+ if (scope === "project") {
128
+ if (!storagePath) throw new Error("project scope requires storagePath");
129
+ const obj = readProjectVars(storagePath);
130
+ if (!(name in obj)) return false;
131
+ delete obj[name];
132
+ writeProjectVars(storagePath, obj);
133
+ return true;
134
+ }
135
+ if (scope === "global") {
136
+ const obj = readGlobalVars();
137
+ if (!(name in obj)) return false;
138
+ delete obj[name];
139
+ writeGlobalVars(obj);
140
+ return true;
141
+ }
142
+ throw new Error(`unknown scope "${scope}"`);
143
+ }
144
+
145
+ // Mask helper for read paths — never send the raw value to the UI by default.
146
+ export function maskValue(value) {
147
+ if (value == null) return "";
148
+ const s = String(value);
149
+ if (s.length <= 4) return "•".repeat(s.length);
150
+ return "•".repeat(Math.min(s.length - 4, 8)) + s.slice(-4);
151
+ }
@@ -0,0 +1,38 @@
1
+ // Decode the `audio` field from a voice/turn request into a filesystem path
2
+ // the STT layer can read. Three input shapes:
3
+ // - undefined / null → returns null (caller falls through to text-only)
4
+ // - filesystem path → returns it as-is (cleanup: false)
5
+ // - base64 (raw or `data:...;base64,...`) → writes to tmp, cleanup: true
6
+ //
7
+ // `cleanup: true` means the caller must `fs.unlinkSync(decoded.path)` when
8
+ // done. Pure helper, no daemon dependencies.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+ import { randomUUID } from "node:crypto";
13
+
14
+ const MAX_PATH_LEN = 1024;
15
+
16
+ export async function decodeAudioInput({ audio, format = "webm" }) {
17
+ if (!audio) return null;
18
+ // A short string that starts with "/" and exists on disk is treated as a
19
+ // path. Anything else is interpreted as base64 (optionally with a data:
20
+ // prefix).
21
+ if (
22
+ typeof audio === "string" &&
23
+ audio.length < MAX_PATH_LEN &&
24
+ audio.startsWith("/") &&
25
+ fs.existsSync(audio)
26
+ ) {
27
+ return { path: audio, cleanup: false };
28
+ }
29
+ let b64 = audio;
30
+ const m = /^data:[^;]+;base64,(.+)$/.exec(b64);
31
+ if (m) b64 = m[1];
32
+ const buf = Buffer.from(b64, "base64");
33
+ if (!buf.length) throw new Error("decodeAudioInput: decoded audio is empty");
34
+ const ext = String(format || "webm").replace(/^\./, "");
35
+ const tmp = path.join(os.tmpdir(), `apx-voice-${Date.now()}-${randomUUID()}.${ext}`);
36
+ fs.writeFileSync(tmp, buf);
37
+ return { path: tmp, cleanup: true };
38
+ }
@@ -0,0 +1,225 @@
1
+ // Audio transcription client. Two backends, both pure (no subprocess lifecycle):
2
+ //
3
+ // - LOCAL: HTTP client that talks to the persistent whisper-server.py at
4
+ // localhost:WHISPER_LOCAL_PORT. The server itself is spun up/down by
5
+ // host/daemon/whisper-server.js — this file just assumes it is reachable.
6
+ // - OPENAI: Whisper-1 cloud API. Needs OPENAI_API_KEY or
7
+ // engines.openai.api_key in config.
8
+ //
9
+ // Provider selection in ~/.apx/config.json:
10
+ // "transcription": {
11
+ // "provider": "auto" | "local" | "openai",
12
+ // "local": { model, device, compute_type, language, beam_size, idle_minutes }
13
+ // }
14
+ // "auto" tries local first, falls back to OpenAI if a key is configured.
15
+ //
16
+ // The split rule: anything that boots/teardown a process lives in host/daemon.
17
+ // Anything that sends bytes over HTTP and parses JSON lives here.
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { logInfo, logWarn } from "#core/logging.js";
21
+
22
+ /** Port the host-side whisper-server.py listens on. Single source of truth. */
23
+ export const WHISPER_LOCAL_PORT = 18765;
24
+
25
+ export const DEFAULT_LOCAL = {
26
+ model: "small",
27
+ device: "cpu",
28
+ compute_type: "int8",
29
+ language: "auto",
30
+ beam_size: 5,
31
+ idle_minutes: 10,
32
+ // Long audio (Telegram voice notes > 10 min) can take several minutes on
33
+ // CPU. 20 minutes covers ~60-minute notes on a small int8 model.
34
+ timeout_ms: 20 * 60_000,
35
+ };
36
+
37
+ /**
38
+ * Resolve the effective transcription language. Priority:
39
+ * explicit local config → config.user.language → "auto" (whisper detects).
40
+ */
41
+ export function resolveTranscriptionLanguage(localCfg, userLang) {
42
+ if (localCfg.language && localCfg.language !== "auto") return localCfg.language;
43
+ if (userLang) return userLang;
44
+ return "auto";
45
+ }
46
+
47
+ export async function getConfig() {
48
+ try {
49
+ const { readConfig } = await import("#core/config/index.js");
50
+ const cfg = readConfig() || {};
51
+ const t = cfg.transcription || {};
52
+ const openaiKey = cfg.engines?.openai?.api_key || process.env.OPENAI_API_KEY || "";
53
+ const userLang = cfg.user?.language || "";
54
+ const localBase = { ...DEFAULT_LOCAL, ...(t.local || {}) };
55
+ localBase.language = resolveTranscriptionLanguage(localBase, userLang);
56
+ return {
57
+ provider: t.provider || "auto",
58
+ local: localBase,
59
+ openaiKey,
60
+ };
61
+ } catch {
62
+ return {
63
+ provider: "auto",
64
+ local: { ...DEFAULT_LOCAL },
65
+ openaiKey: process.env.OPENAI_API_KEY || "",
66
+ };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Call the local whisper-server.py over HTTP. Does NOT spawn or check the
72
+ * subprocess — that's host/daemon/whisper-server.js's job. If the server is
73
+ * down, this throws a clear "ECONNREFUSED" the caller can surface.
74
+ */
75
+ export async function transcribeViaLocalServer(filePath, opts) {
76
+ const language = (opts.language || DEFAULT_LOCAL.language) === "auto"
77
+ ? null
78
+ : (opts.language || null);
79
+
80
+ const timeoutMs = Number(opts.timeout_ms) > 0
81
+ ? Number(opts.timeout_ms)
82
+ : DEFAULT_LOCAL.timeout_ms;
83
+
84
+ const body = JSON.stringify({
85
+ audio_path: filePath,
86
+ language,
87
+ beam_size: opts.beam_size || DEFAULT_LOCAL.beam_size,
88
+ });
89
+
90
+ // Long transcriptions on CPU sometimes trip undici keep-alive on the
91
+ // outbound socket — retry once on generic "fetch failed".
92
+ const maxAttempts = 2;
93
+ let lastErr = null;
94
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
95
+ const t0 = Date.now();
96
+ try {
97
+ logInfo("whisper", `transcribeViaLocalServer attempt ${attempt}/${maxAttempts}`, {
98
+ file: path.basename(filePath),
99
+ language: language || "auto",
100
+ timeout_ms: timeoutMs,
101
+ });
102
+ const res = await fetch(`http://127.0.0.1:${WHISPER_LOCAL_PORT}/transcribe`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json", "connection": "close" },
105
+ body,
106
+ signal: AbortSignal.timeout(timeoutMs),
107
+ });
108
+ const json = await res.json();
109
+ if (!json.ok) throw new Error(json.error || "transcription failed");
110
+ logInfo("whisper", `transcribeViaLocalServer ok in ${Date.now() - t0}ms`, {
111
+ chars: (json.text || "").length,
112
+ language: json.language,
113
+ duration: json.duration,
114
+ });
115
+ return {
116
+ ok: true,
117
+ backend: "local",
118
+ text: json.text || "",
119
+ language: json.language || null,
120
+ language_probability: json.language_probability ?? null,
121
+ duration: json.duration ?? null,
122
+ model: json.model,
123
+ compute_type: json.compute_type,
124
+ };
125
+ } catch (e) {
126
+ lastErr = e;
127
+ const isRetriable = /fetch failed|ECONNRESET|socket hang up|terminated/i.test(e.message || "");
128
+ const dt = Date.now() - t0;
129
+ logWarn("whisper", `transcribeViaLocalServer attempt ${attempt} failed in ${dt}ms`, {
130
+ error: e.message,
131
+ retriable: isRetriable,
132
+ will_retry: isRetriable && attempt < maxAttempts,
133
+ });
134
+ if (!isRetriable || attempt >= maxAttempts) break;
135
+ await new Promise((r) => setTimeout(r, 500));
136
+ }
137
+ }
138
+ throw lastErr || new Error("transcribeViaLocalServer: unknown failure");
139
+ }
140
+
141
+ /** OpenAI Whisper-1 cloud API. Needs an api_key. */
142
+ export async function transcribeOpenAI(filePath, apiKey) {
143
+ if (!apiKey) throw new Error("openai transcription: no api_key");
144
+ const buf = fs.readFileSync(filePath);
145
+ const ext = path.extname(filePath).slice(1).toLowerCase() || "webm";
146
+ const fileType = ext === "ogg" || ext === "oga" ? "audio/ogg"
147
+ : ext === "mp3" ? "audio/mpeg"
148
+ : ext === "m4a" ? "audio/mp4"
149
+ : ext === "wav" ? "audio/wav"
150
+ : ext === "webm" ? "audio/webm"
151
+ : "application/octet-stream";
152
+
153
+ const form = new FormData();
154
+ form.append("model", "whisper-1");
155
+ form.append("file", new Blob([buf], { type: fileType }), path.basename(filePath));
156
+
157
+ const res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
158
+ method: "POST",
159
+ headers: { authorization: `Bearer ${apiKey}` },
160
+ body: form,
161
+ signal: AbortSignal.timeout(60_000),
162
+ });
163
+ if (!res.ok) {
164
+ const errBody = await res.text().catch(() => "");
165
+ throw new Error(`openai whisper ${res.status}: ${errBody.slice(0, 240)}`);
166
+ }
167
+ const json = await res.json();
168
+ return {
169
+ ok: true,
170
+ backend: "openai",
171
+ text: json.text || "",
172
+ language: json.language || null,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Transcribe a file. Provider chosen by config:
178
+ * - "openai": cloud only
179
+ * - "local": whisper-server only (no fallback)
180
+ * - "auto": local first, OpenAI fallback if api_key present
181
+ */
182
+ export async function transcribe(filePath, overrides = {}) {
183
+ if (!filePath || !fs.existsSync(filePath)) {
184
+ throw new Error(`transcribe: file not found: ${filePath}`);
185
+ }
186
+ const cfg = await getConfig();
187
+ const provider = overrides.provider || cfg.provider;
188
+ const localOpts = { ...cfg.local, ...overrides };
189
+
190
+ if (provider === "openai") {
191
+ return transcribeOpenAI(filePath, cfg.openaiKey);
192
+ }
193
+ if (provider === "local") {
194
+ return transcribeViaLocalServer(filePath, localOpts);
195
+ }
196
+ // auto: local first, fall back to openai if a key is configured
197
+ try {
198
+ return await transcribeViaLocalServer(filePath, localOpts);
199
+ } catch (localErr) {
200
+ if (cfg.openaiKey) {
201
+ return transcribeOpenAI(filePath, cfg.openaiKey);
202
+ }
203
+ throw new Error(`local transcription failed: ${localErr.message}`);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Transcribe raw audio bytes. Saves to a temp file, transcribes, cleans up.
209
+ * @param {Buffer} buf
210
+ * @param {string} format extension hint ("webm" | "ogg" | "wav" | "mp3")
211
+ */
212
+ export async function transcribeBuffer(buf, format = "webm", overrides = {}) {
213
+ if (!buf || !buf.length) throw new Error("transcribeBuffer: empty buffer");
214
+ const ext = format.replace(/^\./, "") || "webm";
215
+ const tmpFile = path.join(
216
+ (await import("node:os")).default.tmpdir(),
217
+ `apx-audio-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
218
+ );
219
+ try {
220
+ fs.writeFileSync(tmpFile, buf);
221
+ return await transcribe(tmpFile, overrides);
222
+ } finally {
223
+ try { fs.unlinkSync(tmpFile); } catch {}
224
+ }
225
+ }
@@ -9,88 +9,11 @@ import { readConfig, writeConfig } from "#core/config/index.js";
9
9
  import { resolveAgentName } from "#core/identity/index.js";
10
10
  import { setDottedKey, unsetDottedKey } from "../project-config.js";
11
11
  import { PERMISSION_MODES, DEFAULT_PERMISSION_MODE } from "#core/constants/permissions.js";
12
-
13
- const SECRET_PATHS = [
14
- "engines.anthropic.api_key",
15
- "engines.openai.api_key",
16
- "engines.groq.api_key",
17
- "engines.openrouter.api_key",
18
- "engines.gemini.api_key",
19
- "voice.tts.elevenlabs.api_key",
20
- "voice.tts.openai.api_key",
21
- "voice.tts.gemini.api_key",
22
- "memory.embeddings.openai.api_key",
23
- "memory.embeddings.gemini.api_key",
24
- "telegram.channels.*.bot_token",
25
- ];
26
-
27
- function getDotted(obj, dotted) {
28
- const parts = dotted.split(".");
29
- let cur = obj;
30
- for (const p of parts) {
31
- if (cur == null) return undefined;
32
- cur = cur[p];
33
- }
34
- return cur;
35
- }
36
-
37
- function secretMarker(value) {
38
- if (typeof value !== "string" || !value.length) return value;
39
- const suffix = value.slice(-5);
40
- return `*** set *** (...${suffix})`;
41
- }
42
-
43
- function isSecretMarker(value) {
44
- return typeof value === "string" && value.startsWith("*** set ***");
45
- }
46
-
47
- // Returns a deep copy with `*** set ***` for every present secret value.
48
- function redact(cfg) {
49
- const out = JSON.parse(JSON.stringify(cfg || {}));
50
- const mark = (val) => (typeof val === "string" && val.length ? secretMarker(val) : val);
51
-
52
- // Engine api keys + voice tts keys
53
- for (const path of SECRET_PATHS) {
54
- if (path.includes("*")) continue;
55
- const parts = path.split(".");
56
- let cur = out;
57
- for (let i = 0; i < parts.length - 1; i++) {
58
- if (!cur[parts[i]] || typeof cur[parts[i]] !== "object") { cur = null; break; }
59
- cur = cur[parts[i]];
60
- }
61
- if (cur && cur[parts[parts.length - 1]]) {
62
- cur[parts[parts.length - 1]] = mark(cur[parts[parts.length - 1]]);
63
- }
64
- }
65
- // Telegram channels — array, redact bot_token per item (keep the suffix so
66
- // the UI can show which token is set, e.g. "*** set *** (...AB12)").
67
- const channels = out?.telegram?.channels;
68
- if (Array.isArray(channels)) {
69
- for (const ch of channels) {
70
- if (ch && typeof ch.bot_token === "string" && ch.bot_token.length) {
71
- ch.bot_token = mark(ch.bot_token);
72
- }
73
- }
74
- }
75
- return out;
76
- }
77
-
78
- function mergeRedactedChannels(nextChannels, priorChannels) {
79
- if (!Array.isArray(nextChannels)) return nextChannels;
80
- const priorByName = new Map(
81
- (Array.isArray(priorChannels) ? priorChannels : [])
82
- .filter((c) => c && typeof c.name === "string")
83
- .map((c) => [c.name, c])
84
- );
85
- return nextChannels.map((channel) => {
86
- if (!channel || typeof channel !== "object") return channel;
87
- const prior = priorByName.get(channel.name);
88
- if (prior?.bot_token && (channel.bot_token === undefined || isSecretMarker(channel.bot_token))) {
89
- return { ...channel, bot_token: prior.bot_token };
90
- }
91
- return channel;
92
- });
93
- }
12
+ import {
13
+ redactConfig as redact,
14
+ isSecretMarker,
15
+ mergeRedactedChannels,
16
+ } from "#core/config/redact.js";
94
17
 
95
18
  export function register(app, { config, scheduler, plugins }) {
96
19
  app.get("/admin/config", (_req, res) => {
@@ -7,6 +7,7 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { readAgents, readVaultAgents, readVaultAgent } from "#core/apc/parser.js";
10
+ import { apcAgentFile, apcDir, apcMemoryFile } from "#core/apc/paths.js";
10
11
  import {
11
12
  writeAgentFile,
12
13
  writeVaultAgentFile,
@@ -202,7 +203,7 @@ export function register(app, { projects, project }) {
202
203
  const p = project(req, res);
203
204
  if (!p) return;
204
205
  const slug = req.params.slug;
205
- const file = path.join(p.path, ".apc", "agents", `${slug}.md`);
206
+ const file = apcAgentFile(p.path, slug);
206
207
  const runtimeDir = path.dirname(agentMemoryPath(p, slug));
207
208
  const legacyDir = path.dirname(legacyAgentMemoryPath(p.path, slug));
208
209
  if (!fs.existsSync(file) && !fs.existsSync(runtimeDir) && !fs.existsSync(legacyDir))
@@ -222,7 +223,7 @@ export function register(app, { projects, project }) {
222
223
  app.get("/projects/:pid/memory", (req, res) => {
223
224
  const p = project(req, res);
224
225
  if (!p) return;
225
- const memPath = path.join(p.path, ".apc", "memory.md");
226
+ const memPath = apcMemoryFile(p.path);
226
227
  const body = fs.existsSync(memPath) ? fs.readFileSync(memPath, "utf8") : "";
227
228
  res.json({ body, path: memPath });
228
229
  });
@@ -233,9 +234,8 @@ export function register(app, { projects, project }) {
233
234
  const { body } = req.body || {};
234
235
  if (typeof body !== "string")
235
236
  return res.status(400).json({ error: "body must be string" });
236
- const apcDir = path.join(p.path, ".apc");
237
- fs.mkdirSync(apcDir, { recursive: true });
238
- const memPath = path.join(apcDir, "memory.md");
237
+ fs.mkdirSync(apcDir(p.path), { recursive: true });
238
+ const memPath = apcMemoryFile(p.path);
239
239
  fs.writeFileSync(memPath, body);
240
240
  try { projects.rebuild(p.id); } catch {}
241
241
  res.json({ ok: true, bytes: Buffer.byteLength(body, "utf8") });