@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
@@ -13,6 +13,7 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { nowIso } from "../util/time.js";
15
15
  import { shortId as makeShortId } from "../util/ids.js";
16
+ import { CODE_MODES, DEFAULT_CODE_MODE } from "../constants/code-modes.js";
16
17
 
17
18
  function sessionsDir(storagePath) {
18
19
  return path.join(storagePath, "code-sessions");
@@ -91,7 +92,7 @@ export function createCodeSession(storagePath, fields = {}) {
91
92
  createdAt: ts,
92
93
  updatedAt: ts,
93
94
  model: fields.model || null,
94
- mode: fields.mode === "plan" ? "plan" : "build",
95
+ mode: fields.mode === CODE_MODES.PLAN ? CODE_MODES.PLAN : DEFAULT_CODE_MODE,
95
96
  agentSlug: fields.agentSlug || null,
96
97
  git: fields.git && typeof fields.git === "object" ? fields.git : null,
97
98
  messages: [],
@@ -109,7 +110,7 @@ export function updateCodeSession(storagePath, id, patch = {}) {
109
110
  if (!session) return null;
110
111
  if (patch.title != null) session.title = String(patch.title).trim() || session.title;
111
112
  if (patch.model !== undefined) session.model = patch.model || null;
112
- if (patch.mode === "plan" || patch.mode === "build") session.mode = patch.mode;
113
+ if (patch.mode === CODE_MODES.PLAN || patch.mode === CODE_MODES.BUILD) session.mode = patch.mode;
113
114
  if (patch.agentSlug !== undefined) session.agentSlug = patch.agentSlug || null;
114
115
  if (patch.git !== undefined) session.git = patch.git;
115
116
  session.updatedAt = nowIso();
@@ -147,3 +148,50 @@ export function appendTurn(storagePath, id, turn) {
147
148
  writeJson(sessionFile(storagePath, id), session);
148
149
  return session;
149
150
  }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Transcript → engine history
154
+ // ---------------------------------------------------------------------------
155
+
156
+ // One-line summary of an ask_questions tool call. Without it the next turn's
157
+ // history shows only "user answered X" with no record that the model had
158
+ // asked something — which makes the model ask again forever.
159
+ function summarizeAskQuestionsPart(part) {
160
+ const raw = part?.args?.questions;
161
+ if (!Array.isArray(raw) || raw.length === 0) return null;
162
+ const lines = raw
163
+ .map((q) => {
164
+ if (typeof q === "string") return `- ${q}`;
165
+ if (!q || typeof q !== "object" || typeof q.question !== "string") return null;
166
+ const opts = Array.isArray(q.options) ? q.options : [];
167
+ const optStr = opts
168
+ .map((o) => (typeof o === "string" ? o : (o && typeof o.label === "string" ? o.label : "")))
169
+ .filter(Boolean)
170
+ .join(", ");
171
+ return optStr ? `- ${q.question} (opciones: ${optStr})` : `- ${q.question}`;
172
+ })
173
+ .filter(Boolean);
174
+ if (lines.length === 0) return null;
175
+ return `[ask_questions]\n${lines.join("\n")}`;
176
+ }
177
+
178
+ /**
179
+ * Flatten a stored rich transcript into the [{role, content}] history the
180
+ * super-agent loop expects. Text parts are concatenated; tool parts are
181
+ * normally internal, except ask_questions which is surfaced as a one-line
182
+ * summary so the model doesn't lose track of what it already asked.
183
+ */
184
+ export function codeSessionHistory(session) {
185
+ return (session?.messages || []).map((m) => {
186
+ const chunks = [];
187
+ for (const p of m.parts || []) {
188
+ if (!p) continue;
189
+ if (p.kind === "text" && p.text) chunks.push(p.text);
190
+ else if (p.kind === "tool" && p.tool === "ask_questions") {
191
+ const summary = summarizeAskQuestionsPart(p);
192
+ if (summary) chunks.push(summary);
193
+ }
194
+ }
195
+ return { role: m.role, content: chunks.join("\n\n").trim() };
196
+ });
197
+ }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Path: <projectStoragePath>/routines/<routineId>/memory.md
4
4
  //
5
- // The routine handler (host/daemon/routines.js) creates the file on first read
5
+ // The routine handler (core/routines/runner.js) creates the file on first read
6
6
  // and injects a bounded slice into the super-agent prompt via
7
7
  // channelMeta.routineMemory. The routine can write back with future tooling;
8
8
  // today we only read.
@@ -0,0 +1,121 @@
1
+ // Cross-agent, cross-conversation session search + locator.
2
+ // Walks the on-disk session and conversation files for each project and
3
+ // returns matches with a small excerpt window. Used by the HTTP adapter and
4
+ // (planned) CLI session find.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { apcAgentsDir } from "../apc/paths.js";
8
+
9
+ const EXCERPT_CHARS = 300;
10
+ const EXCERPT_LINES_BEFORE = 1;
11
+ const EXCERPT_LINES_AFTER = 3;
12
+
13
+ function scanFile(filePath, needle) {
14
+ try {
15
+ const text = fs.readFileSync(filePath, "utf8");
16
+ if (!text.toLowerCase().includes(needle)) return null;
17
+ const lines = text.split("\n");
18
+ const matchLine = lines.findIndex((l) => l.toLowerCase().includes(needle));
19
+ const excerpt = lines
20
+ .slice(Math.max(0, matchLine - EXCERPT_LINES_BEFORE), matchLine + EXCERPT_LINES_AFTER)
21
+ .join("\n");
22
+ return excerpt.slice(0, EXCERPT_CHARS);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Search for `needle` across one project's session + conversation files.
30
+ *
31
+ * @param project { id, path, storagePath } record from ProjectManager
32
+ * @param needle lowercase query string
33
+ * @param remaining max matches to add (search short-circuits when reached)
34
+ * @returns matches array (may be empty)
35
+ */
36
+ export function searchProjectSessions(project, needle, remaining) {
37
+ const matches = [];
38
+ if (!project || remaining <= 0) return matches;
39
+
40
+ // 1) Legacy session files in the repo (.apc/agents/<slug>/sessions/)
41
+ const sessionAgentsDir = apcAgentsDir(project.path);
42
+ if (fs.existsSync(sessionAgentsDir)) {
43
+ for (const slug of fs.readdirSync(sessionAgentsDir)) {
44
+ const sessionsDir = path.join(sessionAgentsDir, slug, "sessions");
45
+ if (!fs.existsSync(sessionsDir)) continue;
46
+ for (const f of fs.readdirSync(sessionsDir).filter((x) => x.endsWith(".md"))) {
47
+ const filePath = path.join(sessionsDir, f);
48
+ const excerpt = scanFile(filePath, needle);
49
+ if (excerpt != null) {
50
+ matches.push({
51
+ type: "session",
52
+ project: project.id,
53
+ agent: slug,
54
+ filename: f,
55
+ path: filePath,
56
+ excerpt,
57
+ });
58
+ if (matches.length >= remaining) return matches;
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ // 2) Conversation files in daemon storage (~/.apx/projects/<id>/agents/<slug>/conversations/)
65
+ const convAgentsDir = path.join(project.storagePath, "agents");
66
+ if (fs.existsSync(convAgentsDir)) {
67
+ for (const slug of fs.readdirSync(convAgentsDir)) {
68
+ const convDir = path.join(convAgentsDir, slug, "conversations");
69
+ if (!fs.existsSync(convDir)) continue;
70
+ for (const f of fs.readdirSync(convDir).filter((x) => x.endsWith(".md"))) {
71
+ const filePath = path.join(convDir, f);
72
+ const excerpt = scanFile(filePath, needle);
73
+ if (excerpt != null) {
74
+ matches.push({
75
+ type: "conversation",
76
+ project: project.id,
77
+ agent: slug,
78
+ filename: f,
79
+ path: filePath,
80
+ excerpt,
81
+ });
82
+ if (matches.length >= remaining) return matches;
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ return matches;
89
+ }
90
+
91
+ /** Run searchProjectSessions across an array of projects, capping at `limit`. */
92
+ export function searchSessions(projectList, query, limit) {
93
+ const needle = String(query || "").toLowerCase();
94
+ const matches = [];
95
+ for (const p of projectList) {
96
+ if (!p) continue;
97
+ const remaining = limit - matches.length;
98
+ if (remaining <= 0) break;
99
+ matches.push(...searchProjectSessions(p, needle, remaining));
100
+ }
101
+ return matches.slice(0, limit);
102
+ }
103
+
104
+ /**
105
+ * Find the conversation file (under daemon storage) for a given session id,
106
+ * scanning a list of candidate projects. Returns { project, agentSlug, filename }
107
+ * or null. `id` is taken as bare or with .md suffix.
108
+ */
109
+ export function findSessionFile(projectList, id) {
110
+ const filename = id.endsWith(".md") ? id : `${id}.md`;
111
+ for (const p of projectList) {
112
+ if (!p) continue;
113
+ const agentsDir = path.join(p.storagePath, "agents");
114
+ if (!fs.existsSync(agentsDir)) continue;
115
+ for (const slug of fs.readdirSync(agentsDir)) {
116
+ const f = path.join(agentsDir, slug, "conversations", filename);
117
+ if (fs.existsSync(f)) return { project: p, agentSlug: slug, filename };
118
+ }
119
+ }
120
+ return null;
121
+ }
@@ -4,6 +4,11 @@
4
4
 
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { nowIso } from "../util/time.js";
8
+
9
+ export function agentSessionsDir(storageRoot, agentSlug) {
10
+ return path.join(storageRoot, "agents", agentSlug, "sessions");
11
+ }
7
12
 
8
13
  export function generateSessionId(storageRoot, agentSlug) {
9
14
  const today = new Date().toISOString().slice(0, 10);
@@ -34,3 +39,36 @@ export function readSessionFrontmatter(filePath) {
34
39
  }
35
40
  return { fm, body: text.slice(end + 4).replace(/^\n+/, "") };
36
41
  }
42
+
43
+ function slugifyTitle(title) {
44
+ return (
45
+ String(title || "")
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, "-")
48
+ .replace(/^-|-$/g, "") || "session"
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Create a new dated session file under
54
+ * `<storageRoot>/agents/<agentSlug>/sessions/YYYY-MM-DD-<titleSlug>.md`,
55
+ * with collision suffix (`-2`, `-3`, …) and standard frontmatter.
56
+ * Returns { filename, path, started }.
57
+ */
58
+ export function createAgentSessionFile(storageRoot, agentSlug, { title, body = "" }) {
59
+ if (!title) throw new Error("createAgentSessionFile: title required");
60
+ const dir = agentSessionsDir(storageRoot, agentSlug);
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ const titleSlug = slugifyTitle(title);
63
+ const today = new Date().toISOString().slice(0, 10);
64
+ let candidate = path.join(dir, `${today}-${titleSlug}.md`);
65
+ let n = 2;
66
+ while (fs.existsSync(candidate)) {
67
+ candidate = path.join(dir, `${today}-${titleSlug}-${n}.md`);
68
+ n++;
69
+ }
70
+ const started = nowIso();
71
+ const content = `---\ntitle: ${title}\nstarted: ${started}\n---\n\n# ${title}\n\n${body}\n`;
72
+ fs.writeFileSync(candidate, content);
73
+ return { filename: path.basename(candidate), path: candidate, started };
74
+ }
@@ -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
+ }