@agentprojectcontext/apx 1.33.0 → 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 (172) hide show
  1. package/package.json +1 -1
  2. package/skills/apc-context/SKILL.md +2 -5
  3. package/skills/apx/SKILL.md +49 -61
  4. package/src/core/agent/a2a/reply.js +48 -0
  5. package/src/core/agent/build-agent-system.js +4 -3
  6. package/src/core/agent/channels/voice-context.js +98 -0
  7. package/src/core/agent/memory.js +2 -1
  8. package/src/core/agent/prompt-builder.js +2 -1
  9. package/src/core/agent/prompts/modes/code-build.md +1 -0
  10. package/src/core/agent/prompts/modes/code-plan.md +1 -0
  11. package/src/core/agent/prompts/modes/index.js +28 -0
  12. package/src/core/agent/skills/loader.js +22 -18
  13. package/src/core/agent/stream/turn-accumulator.js +73 -0
  14. package/src/core/agent/suggestions.js +37 -0
  15. package/src/core/agent/tools/handlers/add-project.js +5 -2
  16. package/src/core/agent/tools/handlers/call-runtime.js +3 -2
  17. package/src/core/agent/tools/handlers/transcribe-audio.js +1 -1
  18. package/src/core/agent/tools/helpers.js +2 -2
  19. package/src/core/agent/tools/names.js +138 -0
  20. package/src/core/agent/tools/registry-bridge.js +6 -14
  21. package/src/core/agent/tools/registry.js +68 -65
  22. package/src/core/apc/context-copy.js +27 -0
  23. package/src/core/apc/notes.js +19 -0
  24. package/src/core/apc/parser.js +13 -6
  25. package/src/core/apc/paths.js +87 -0
  26. package/src/core/apc/scaffold.js +82 -74
  27. package/src/core/apc/skill-sync.js +13 -1
  28. package/src/core/channels/telegram/dispatch.js +595 -0
  29. package/src/core/channels/telegram/helpers.js +130 -0
  30. package/src/core/config/index.js +3 -2
  31. package/src/core/config/redact.js +95 -0
  32. package/src/core/constants/channels.js +2 -0
  33. package/src/core/constants/code-modes.js +10 -0
  34. package/src/core/constants/index.js +1 -0
  35. package/src/core/deck/manifest.js +186 -0
  36. package/src/core/engines/catalog.js +83 -0
  37. package/src/core/engines/gemini.js +28 -11
  38. package/src/core/engines/index.js +11 -1
  39. package/src/core/{tools → http-tools}/browser.js +0 -1
  40. package/src/core/{tools → http-tools}/fetch.js +0 -1
  41. package/src/core/{tools → http-tools}/glob.js +0 -1
  42. package/src/core/{tools → http-tools}/grep.js +0 -1
  43. package/src/core/{tools → http-tools}/registry.js +0 -1
  44. package/src/core/{tools → http-tools}/search.js +0 -1
  45. package/src/core/i18n/en.js +9 -0
  46. package/src/core/i18n/es.js +12 -0
  47. package/src/core/i18n/index.js +54 -0
  48. package/src/core/i18n/pt.js +9 -0
  49. package/src/core/identity/telegram.js +2 -1
  50. package/src/core/mcp/runner.js +272 -14
  51. package/src/core/mcp/sources.js +3 -2
  52. package/src/core/routines/index.js +16 -0
  53. package/src/{host/daemon/routines.js → core/routines/runner.js} +36 -103
  54. package/src/core/runtime-skills/apc-context/SKILL.md +159 -0
  55. package/src/core/runtime-skills/apx/SKILL.md +95 -0
  56. package/src/core/runtime-skills/apx-mcp/SKILL.md +116 -0
  57. package/src/core/runtime-skills/{claude-code.md → claude-code/SKILL.md} +1 -0
  58. package/src/core/runtime-skills/{codex-cli.md → codex-cli/SKILL.md} +1 -0
  59. package/src/core/runtime-skills/{opencode-cli.md → opencode-cli/SKILL.md} +1 -0
  60. package/src/core/runtime-skills/{openrouter.md → openrouter/SKILL.md} +1 -0
  61. package/src/{host/daemon/env-detect.js → core/runtimes/detect.js} +1 -1
  62. package/src/core/stores/code-sessions.js +50 -2
  63. package/src/core/stores/routine-memory.js +1 -1
  64. package/src/core/stores/sessions-search.js +121 -0
  65. package/src/core/stores/sessions.js +38 -0
  66. package/src/core/vars/index.js +14 -0
  67. package/src/core/vars/interpolate.js +86 -0
  68. package/src/core/vars/sources.js +151 -0
  69. package/src/core/voice/audio-decode.js +38 -0
  70. package/src/core/voice/transcription.js +225 -0
  71. package/src/host/daemon/api/admin-config.js +5 -82
  72. package/src/host/daemon/api/agents.js +5 -5
  73. package/src/host/daemon/api/code.js +17 -169
  74. package/src/host/daemon/api/config.js +3 -4
  75. package/src/host/daemon/api/conversations.js +8 -29
  76. package/src/host/daemon/api/deck.js +37 -404
  77. package/src/host/daemon/api/engines.js +1 -50
  78. package/src/host/daemon/api/exec.js +1 -1
  79. package/src/host/daemon/api/mcps.js +32 -0
  80. package/src/host/daemon/api/routines.js +1 -1
  81. package/src/host/daemon/api/runtimes.js +4 -3
  82. package/src/host/daemon/api/sessions-search.js +24 -140
  83. package/src/host/daemon/api/sessions.js +12 -30
  84. package/src/host/daemon/api/shared.js +2 -1
  85. package/src/host/daemon/api/telegram.js +1 -11
  86. package/src/host/daemon/api/tools.js +6 -6
  87. package/src/host/daemon/api/transcribe.js +2 -2
  88. package/src/host/daemon/api/vars.js +137 -0
  89. package/src/host/daemon/api/voice.js +13 -290
  90. package/src/host/daemon/api.js +2 -0
  91. package/src/host/daemon/db.js +6 -6
  92. package/src/host/daemon/deck-exec.js +148 -0
  93. package/src/host/daemon/index.js +3 -3
  94. package/src/host/daemon/plugins/telegram/index.js +24 -687
  95. package/src/host/daemon/routines-scheduler.js +64 -0
  96. package/src/host/daemon/smoke.js +3 -2
  97. package/src/host/daemon/whisper-server.js +225 -0
  98. package/src/interfaces/cli/commands/agent.js +3 -2
  99. package/src/interfaces/cli/commands/command.js +2 -3
  100. package/src/interfaces/cli/commands/messages.js +6 -2
  101. package/src/interfaces/cli/commands/pair.js +5 -4
  102. package/src/interfaces/cli/commands/search.js +1 -1
  103. package/src/interfaces/cli/commands/sessions.js +3 -2
  104. package/src/interfaces/cli/commands/skills.js +36 -55
  105. package/src/interfaces/web/dist/assets/index-DdmSRtsz.css +1 -0
  106. package/src/interfaces/web/dist/assets/index-M4FspaCH.js +613 -0
  107. package/src/interfaces/web/dist/assets/index-M4FspaCH.js.map +1 -0
  108. package/src/interfaces/web/dist/index.html +2 -2
  109. package/src/interfaces/web/package-lock.json +182 -182
  110. package/src/interfaces/web/src/components/ModelCombobox.tsx +44 -8
  111. package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +1 -1
  112. package/src/interfaces/web/src/components/chat/AskAnswersCard.tsx +76 -0
  113. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  114. package/src/interfaces/web/src/components/chat/MessageList.tsx +23 -1
  115. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +3 -1
  116. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +4 -4
  117. package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +1 -1
  118. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +3 -2
  119. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +3 -2
  120. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +3 -2
  121. package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -1
  122. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +2 -1
  123. package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +93 -0
  124. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +449 -0
  125. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +2 -1
  126. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +2 -2
  127. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +5 -4
  128. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +3 -2
  129. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +3 -2
  130. package/src/interfaces/web/src/components/ui/chat-input.tsx +5 -4
  131. package/src/interfaces/web/src/components/ui/sidebar.tsx +3 -2
  132. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +2 -1
  133. package/src/interfaces/web/src/constants/index.ts +1 -1
  134. package/src/interfaces/web/src/i18n/en.ts +174 -7
  135. package/src/interfaces/web/src/i18n/es.ts +179 -15
  136. package/src/interfaces/web/src/lib/api/mcps.ts +25 -0
  137. package/src/interfaces/web/src/lib/api/vars.ts +38 -0
  138. package/src/interfaces/web/src/lib/api.ts +1 -0
  139. package/src/interfaces/web/src/screens/ProjectScreen.tsx +8 -31
  140. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +1 -1
  141. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +4 -3
  142. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +7 -6
  143. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +4 -3
  144. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +1 -1
  145. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +132 -1
  146. package/src/interfaces/web/src/screens/project/McpsTab.tsx +549 -104
  147. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +1 -1
  148. package/src/interfaces/web/src/screens/project/VarsTab.tsx +300 -0
  149. package/src/interfaces/web/src/types/daemon.ts +5 -0
  150. package/src/host/daemon/transcription.js +0 -538
  151. package/src/host/daemon/whisper-transcribe.py +0 -73
  152. package/src/interfaces/web/dist/assets/index-7dVT2O1S.css +0 -1
  153. package/src/interfaces/web/dist/assets/index-DWsE_8Nz.js +0 -602
  154. package/src/interfaces/web/dist/assets/index-DWsE_8Nz.js.map +0 -1
  155. /package/src/{host/daemon → core/apc}/projects-helpers.js +0 -0
  156. /package/src/{host/daemon/plugins → core/channels}/telegram/ask.js +0 -0
  157. /package/src/{host/daemon/plugins → core/channels}/telegram/media.js +0 -0
  158. /package/src/core/{tools → http-tools}/index.js +0 -0
  159. /package/{skills → src/core/runtime-skills}/apx-agency-agents/SKILL.md +0 -0
  160. /package/{skills → src/core/runtime-skills}/apx-agent/SKILL.md +0 -0
  161. /package/{skills → src/core/runtime-skills}/apx-mcp-builder/SKILL.md +0 -0
  162. /package/{skills → src/core/runtime-skills}/apx-project/SKILL.md +0 -0
  163. /package/{skills → src/core/runtime-skills}/apx-routine/SKILL.md +0 -0
  164. /package/{skills → src/core/runtime-skills}/apx-runtime/SKILL.md +0 -0
  165. /package/{skills → src/core/runtime-skills}/apx-sessions/SKILL.md +0 -0
  166. /package/{skills → src/core/runtime-skills}/apx-skill-builder/SKILL.md +0 -0
  167. /package/{skills → src/core/runtime-skills}/apx-task/SKILL.md +0 -0
  168. /package/{skills → src/core/runtime-skills}/apx-telegram/SKILL.md +0 -0
  169. /package/{skills → src/core/runtime-skills}/apx-voice/SKILL.md +0 -0
  170. /package/src/{host/daemon/compact.js → core/stores/conversations-compactor.js} +0 -0
  171. /package/src/{host/daemon → core/stores}/conversations.js +0 -0
  172. /package/src/{host/daemon → core/util}/thinking.js +0 -0
@@ -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") });
@@ -16,6 +16,7 @@ import { runSuperAgent } from "#core/agent/super-agent.js";
16
16
  import { appendSuperAgentErrorTrace } from "./shared.js";
17
17
  import { createWebConfirmAdapter } from "#core/confirmation/adapters/web.js";
18
18
  import { CHANNELS } from "#core/constants/channels.js";
19
+ import { CODE_MODES, DEFAULT_CODE_MODE } from "#core/constants/code-modes.js";
19
20
  import {
20
21
  listCodeSessions,
21
22
  getCodeSession,
@@ -23,181 +24,28 @@ import {
23
24
  updateCodeSession,
24
25
  removeCodeSession,
25
26
  appendTurn,
27
+ codeSessionHistory,
26
28
  } from "#core/stores/code-sessions.js";
29
+ import { makeTurnAccumulator } from "#core/agent/stream/turn-accumulator.js";
27
30
  import { captureBaseline, diffAgainstBaseline, initGitRepo } from "#core/git-baseline.js";
28
31
  import { loggerFor } from "#core/logging.js";
29
32
  import { readAgents } from "#core/apc/parser.js";
33
+ import { CODE_PLAN_TOOLS, CODE_BUILD_TOOLS } from "#core/agent/tools/names.js";
34
+ import { codeModeGuidance } from "#core/agent/prompts/modes/index.js";
30
35
 
31
36
  const log = loggerFor("code");
32
37
 
33
- // Read-only tool allowlist for PLAN mode: the agent can explore the repo but
34
- // not mutate it (no write/edit/shell/delegation/side-effects). Build mode gets
35
- // the unrestricted set ("*"). Names must match super-agent-tools/index.js.
36
- const PLAN_TOOLS = [
37
- "read_file",
38
- "list_files",
39
- "search_files",
40
- "grep",
41
- "glob",
42
- "list_projects",
43
- "list_agents",
44
- "list_mcps",
45
- "read_agent_memory",
46
- "read_self_memory",
47
- "search_sessions",
48
- "search_messages",
49
- "tail_messages",
50
- "list_skills",
51
- "load_skill",
52
- "list_tasks",
53
- "ask_questions",
54
- "fetch",
55
- "search",
56
- ];
57
-
38
+ // Mode-specific tool allow-lists and prompt fragments are owned by core/:
39
+ // - tool names + plan/build lists → #core/agent/tools/names.js
40
+ // - per-mode guidance text → #core/agent/prompts/modes/*.md
41
+ // This file just picks the right pair for the request.
58
42
  function modeGuidanceFor(mode) {
59
- if (mode === "plan") {
60
- return [
61
- "MODE: plan. Investigate the codebase (read/list/search/grep) and propose",
62
- "an approach with the EXACT changes you would make (files + diffs/snippets).",
63
- "Do NOT write or edit files and do NOT run mutating shell commands — your",
64
- "editing tools are disabled in this mode. End with a concise, ordered plan.",
65
- ].join(" ");
66
- }
67
- return [
68
- "MODE: build. Make the changes directly using your file and shell tools",
69
- "(read_file, write_file, edit_file, run_shell, …). Do not ask for",
70
- "confirmation and do not stop after one step — keep calling tools until the",
71
- "entire task is done, then briefly summarize what you changed and why.",
72
- "Prefer surgical edits over rewrites.",
73
- "When the user asks for a reusable script, snippet, or 'artifact' (something",
74
- "they want to keep and run later), put it under `artifacts/<name>` inside",
75
- "the project — it then shows up in the Artifacts tab. Don't drop reusable",
76
- "scripts at the project root.",
77
- "If a parameter you need is missing (API key, app id, target URL, …), call",
78
- "`ask_questions` ONCE with all your questions and stop — control returns",
79
- "to the user. Do not call ask_questions again in the same turn; you'll just",
80
- "get the same blank state back. Each question can be a string (free-text",
81
- "answer) OR an object {question, options:[{label, description}], multiSelect}",
82
- "for choices. Prefer 2–4 mutually-exclusive options when a question has a",
83
- "natural shortlist (yes/no, which-of-these, …); leave options empty for",
84
- "open-ended answers (API keys, names, free-form ideas).",
85
- "If the previous assistant turn already asked these same questions and the",
86
- "current user message is the compiled answers, DO NOT call ask_questions",
87
- "again — process the answers and proceed with the task.",
88
- ].join(" ");
89
- }
90
-
91
- // Build the [{role, content}] history the super-agent expects from the stored
92
- // rich transcript: flatten each turn's text parts. Tool parts are normally
93
- // internal, but ask_questions is special — without surfacing it the model
94
- // loses track that it ALREADY asked, sees the user's compiled-answer reply
95
- // as a fresh request, and asks again forever. We render a one-line synthetic
96
- // summary of each ask_questions call so the next turn's context shows
97
- // "I asked X, the user replied Y" naturally.
98
- function summarizeAskQuestionsPart(part) {
99
- const raw = part?.args?.questions;
100
- if (!Array.isArray(raw) || raw.length === 0) return null;
101
- const lines = raw
102
- .map((q) => {
103
- if (typeof q === "string") return `- ${q}`;
104
- if (!q || typeof q !== "object" || typeof q.question !== "string") return null;
105
- const opts = Array.isArray(q.options) ? q.options : [];
106
- const optStr = opts
107
- .map((o) => (typeof o === "string" ? o : (o && typeof o.label === "string" ? o.label : "")))
108
- .filter(Boolean)
109
- .join(", ");
110
- return optStr ? `- ${q.question} (opciones: ${optStr})` : `- ${q.question}`;
111
- })
112
- .filter(Boolean);
113
- if (lines.length === 0) return null;
114
- return `[ask_questions]\n${lines.join("\n")}`;
115
- }
116
-
117
- function historyFrom(session) {
118
- return (session.messages || []).map((m) => {
119
- const chunks = [];
120
- for (const p of m.parts || []) {
121
- if (!p) continue;
122
- if (p.kind === "text" && p.text) chunks.push(p.text);
123
- else if (p.kind === "tool" && p.tool === "ask_questions") {
124
- const summary = summarizeAskQuestionsPart(p);
125
- if (summary) chunks.push(summary);
126
- }
127
- }
128
- return { role: m.role, content: chunks.join("\n\n").trim() };
129
- });
43
+ return codeModeGuidance(mode);
130
44
  }
131
45
 
132
- // Accumulate stream events into the rich ChatPart shape so the persisted
133
- // assistant turn matches exactly what the UI rendered live. Mirrors the
134
- // front-end reducer in hooks/useChat.ts (applyStreamEvent).
135
- function makeTurnAccumulator() {
136
- const parts = [];
137
- const notes = [];
138
- let model = null;
139
- let usage = null;
140
- const findTool = (id) => parts.find((p) => p.kind === "tool" && p.id === id);
141
- return {
142
- apply(ev) {
143
- switch (ev?.type) {
144
- case "model_start":
145
- if (ev.model) model = ev.model;
146
- break;
147
- case "model_routed":
148
- if (ev.model) model = ev.model;
149
- if (ev.from_fallback) notes.push(`routing fell back → ${ev.model}`);
150
- break;
151
- case "engine_failed":
152
- notes.push(`engine ${ev.model || "?"} failed → ${ev.retry_with || "retry"}`);
153
- break;
154
- case "model_retry":
155
- notes.push(`retry (${ev.reason || "?"})`);
156
- break;
157
- case "tools_suppressed":
158
- notes.push(`tools suppressed: ${(ev.tools || []).join(", ")}`);
159
- break;
160
- case "assistant_text":
161
- if (ev.text) parts.push({ kind: "text", text: ev.text });
162
- break;
163
- case "tool_start":
164
- if (ev.trace)
165
- parts.push({
166
- kind: "tool",
167
- id: ev.trace.id,
168
- tool: ev.trace.tool,
169
- args: ev.trace.args,
170
- status: "running",
171
- });
172
- break;
173
- case "tool_deduped": {
174
- const t = ev.trace && findTool(ev.trace.id);
175
- if (t) t.status = "deduped";
176
- break;
177
- }
178
- case "tool_result": {
179
- const t = ev.trace && findTool(ev.trace.id);
180
- if (t) {
181
- t.result = ev.trace.result;
182
- const errored =
183
- ev.trace.result && typeof ev.trace.result === "object" && ev.trace.result.error;
184
- t.status = errored ? "error" : t.status === "deduped" ? "deduped" : "done";
185
- }
186
- break;
187
- }
188
- case "final":
189
- usage = ev.result?.usage ?? usage;
190
- if (!model) model = ev.result?.name || null;
191
- break;
192
- default:
193
- break;
194
- }
195
- },
196
- build() {
197
- return { parts, notes, model, usage };
198
- },
199
- };
200
- }
46
+ // History flattening + stream-event accumulator now live in core/ see
47
+ // codeSessionHistory() (transcript engine messages) and makeTurnAccumulator()
48
+ // (stream events persistable ChatParts) imported above.
201
49
 
202
50
  export function register(app, { projects, project, config, registries, plugins }) {
203
51
  const findProject = (req, res) => project(req, res);
@@ -290,8 +138,8 @@ export function register(app, { projects, project, config, registries, plugins }
290
138
  const { prompt } = req.body || {};
291
139
  if (!prompt) return res.status(400).json({ error: "prompt required" });
292
140
 
293
- const mode = session.mode === "plan" ? "plan" : "build";
294
- const previousMessages = historyFrom(session);
141
+ const mode = session.mode === CODE_MODES.PLAN ? CODE_MODES.PLAN : DEFAULT_CODE_MODE;
142
+ const previousMessages = codeSessionHistory(session);
295
143
 
296
144
  // If a project agent is selected, inject its system prompt as a suffix so
297
145
  // the super-agent's tool loop runs with the agent's personality/context.
@@ -340,7 +188,7 @@ export function register(app, { projects, project, config, registries, plugins }
340
188
  previousMessages,
341
189
  systemSuffix: agentSystemSuffix,
342
190
  overrideModel: session.model || undefined,
343
- allowedTools: mode === "plan" ? PLAN_TOOLS : "*",
191
+ allowedTools: mode === CODE_MODES.PLAN ? CODE_PLAN_TOOLS : CODE_BUILD_TOOLS,
344
192
  // Coding tasks are multi-step: give the loop a high safety ceiling so it
345
193
  // can chain 20-30+ tools (read → edit → run → verify …) and a real
346
194
  // output budget for substantial code / explanations per turn. The
@@ -351,7 +199,7 @@ export function register(app, { projects, project, config, registries, plugins }
351
199
  // Build mode = the model must keep calling tools until it calls `finish`.
352
200
  // Plan mode is read-only investigation that ends with a written plan, so
353
201
  // it keeps the normal "text ends the turn" behavior.
354
- completionContract: mode === "build",
202
+ completionContract: mode === CODE_MODES.BUILD,
355
203
  onEvent,
356
204
  requestConfirmation: createWebConfirmAdapter({ onEvent }),
357
205
  });
@@ -10,10 +10,9 @@ import {
10
10
  setDottedKey,
11
11
  unsetDottedKey,
12
12
  } from "../project-config.js";
13
+ import { apcProjectFile, apcProjectConfigFile } from "#core/apc/paths.js";
13
14
 
14
- function projectJsonPath(root) {
15
- return path.join(root, ".apc", "project.json");
16
- }
15
+ const projectJsonPath = apcProjectFile;
17
16
 
18
17
  function readProjectJson(root) {
19
18
  const p = projectJsonPath(root);
@@ -34,7 +33,7 @@ export function register(app, { projects, project }) {
34
33
  res.json({
35
34
  effective: p.config || {},
36
35
  project_only: readProjectConfig(p.path),
37
- project_config_path: path.join(p.path, ".apc", "config.json"),
36
+ project_config_path: apcProjectConfigFile(p.path),
38
37
  apc_project: readProjectJson(p.path),
39
38
  project_json_path: projectJsonPath(p.path),
40
39
  });
@@ -4,12 +4,10 @@
4
4
  // POST /projects/:pid/agents/:slug/compact
5
5
  // POST /projects/:pid/agents/:slug/conversations/:id/compact
6
6
  // POST /projects/:pid/send (agent-to-agent)
7
- import fs from "node:fs";
8
- import path from "node:path";
9
7
  import { readAgents } from "#core/apc/parser.js";
10
- import { callEngine } from "#core/engines/index.js";
11
- import { listConversations, readConversation } from "../conversations.js";
12
- import { compactConversation } from "../compact.js";
8
+ import { listConversations, readConversation } from "#core/stores/conversations.js";
9
+ import { compactConversation } from "#core/stores/conversations-compactor.js";
10
+ import { replyAsAgent } from "#core/agent/a2a/reply.js";
13
11
  import { nowIso } from "./shared.js";
14
12
 
15
13
  export function register(app, { project, config }) {
@@ -102,30 +100,11 @@ export function register(app, { project, config }) {
102
100
  let reply = null;
103
101
  if (deliver && toAgent.fields.Model) {
104
102
  try {
105
- const tf = toAgent.fields;
106
- const parts = [];
107
- if (tf.Description) parts.push(tf.Description);
108
- if (tf.Role) parts.push(`Role: ${tf.Role}`);
109
- if (tf.Language) parts.push(`Default language: ${tf.Language}`);
110
- parts.push(
111
- `You are ${toAgent.slug}. You just received a message from ${fromAgent.slug}. Reply concisely.`
112
- );
113
- const memPath = path.join(
114
- p.path,
115
- ".apc",
116
- "agents",
117
- toAgent.slug,
118
- "memory.md"
119
- );
120
- if (fs.existsSync(memPath))
121
- parts.push("## Memory\n" + fs.readFileSync(memPath, "utf8"));
122
-
123
- const result = await callEngine({
124
- modelId: toAgent.fields.Model,
125
- system: parts.join("\n\n"),
126
- messages: [
127
- { role: "user", content: `From ${fromAgent.slug}:\n\n${body}` },
128
- ],
103
+ const result = await replyAsAgent({
104
+ projectPath: p.path,
105
+ toAgent,
106
+ fromAgent,
107
+ body,
129
108
  config: p.config || config,
130
109
  });
131
110