@agentprojectcontext/apx 1.33.1 → 1.35.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 (208) 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 +136 -59
  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 +178 -124
  8. package/src/core/agent/prompts/channels/code.md +12 -10
  9. package/src/core/agent/prompts/channels/desktop.md +5 -32
  10. package/src/core/agent/prompts/channels/telegram.md +4 -15
  11. package/src/core/agent/prompts/channels/web_code.md +11 -11
  12. package/src/core/agent/prompts/core/agent-base.md +24 -0
  13. package/src/core/agent/prompts/core/project-agent.md +11 -0
  14. package/src/core/agent/prompts/core/super-agent.md +21 -0
  15. package/src/core/agent/prompts/discipline/action.md +10 -0
  16. package/src/core/agent/prompts/discipline/single-segment.md +6 -0
  17. package/src/core/agent/prompts/discipline/two-segment.md +11 -0
  18. package/src/core/agent/prompts/modes/code-build.md +1 -0
  19. package/src/core/agent/prompts/modes/code-plan.md +1 -0
  20. package/src/core/agent/prompts/modes/index.js +28 -0
  21. package/src/core/agent/self-memory.js +43 -1
  22. package/src/core/agent/skills/index-store.js +307 -0
  23. package/src/core/agent/skills/index.js +15 -1
  24. package/src/core/agent/skills/inspector.js +317 -0
  25. package/src/core/agent/skills/loader.js +22 -18
  26. package/src/core/agent/stream/turn-accumulator.js +73 -0
  27. package/src/core/agent/suggestions.js +37 -0
  28. package/src/core/agent/super-agent.js +7 -1
  29. package/src/core/agent/tools/handlers/_git.js +50 -0
  30. package/src/core/agent/tools/handlers/add-project.js +5 -2
  31. package/src/core/agent/tools/handlers/call-runtime.js +3 -2
  32. package/src/core/agent/tools/handlers/git-diff.js +44 -0
  33. package/src/core/agent/tools/handlers/git-log.js +38 -0
  34. package/src/core/agent/tools/handlers/git-show.js +34 -0
  35. package/src/core/agent/tools/handlers/git-status.js +61 -0
  36. package/src/core/agent/tools/handlers/transcribe-audio.js +1 -1
  37. package/src/core/agent/tools/helpers.js +2 -2
  38. package/src/core/agent/tools/names.js +169 -0
  39. package/src/core/agent/tools/registry-bridge.js +6 -14
  40. package/src/core/agent/tools/registry.js +103 -69
  41. package/src/core/apc/context-copy.js +27 -0
  42. package/src/core/apc/notes.js +19 -0
  43. package/src/core/apc/parser.js +12 -5
  44. package/src/core/apc/paths.js +87 -0
  45. package/src/core/apc/scaffold.js +82 -76
  46. package/src/core/apc/skill-sync.js +10 -0
  47. package/src/{host/daemon/plugins → core/channels}/telegram/dispatch.js +38 -16
  48. package/src/core/config/index.js +24 -2
  49. package/src/core/config/redact.js +95 -0
  50. package/src/core/constants/channels.js +2 -0
  51. package/src/core/constants/code-modes.js +10 -0
  52. package/src/core/constants/index.js +1 -0
  53. package/src/core/deck/manifest.js +186 -0
  54. package/src/core/engines/catalog.js +83 -0
  55. package/src/core/{tools → http-tools}/browser.js +0 -1
  56. package/src/core/{tools → http-tools}/fetch.js +0 -1
  57. package/src/core/{tools → http-tools}/glob.js +0 -1
  58. package/src/core/{tools → http-tools}/grep.js +0 -1
  59. package/src/core/{tools → http-tools}/registry.js +0 -1
  60. package/src/core/{tools → http-tools}/search.js +0 -1
  61. package/src/core/i18n/en.js +9 -0
  62. package/src/core/i18n/es.js +12 -0
  63. package/src/core/i18n/index.js +54 -0
  64. package/src/core/i18n/pt.js +9 -0
  65. package/src/core/identity/telegram.js +2 -1
  66. package/src/core/mcp/runner.js +272 -14
  67. package/src/core/mcp/sources.js +3 -2
  68. package/src/core/routines/index.js +16 -0
  69. package/src/{host/daemon/routines.js → core/routines/runner.js} +36 -103
  70. package/src/core/runtime-skills/apc-context/SKILL.md +159 -0
  71. package/src/core/runtime-skills/apx/SKILL.md +83 -0
  72. package/src/core/runtime-skills/apx-agency-agents/SKILL.md +125 -0
  73. package/src/core/runtime-skills/apx-agent/SKILL.md +97 -0
  74. package/src/core/runtime-skills/apx-mcp/SKILL.md +111 -0
  75. package/src/core/runtime-skills/apx-mcp-builder/SKILL.md +169 -0
  76. package/{skills → src/core/runtime-skills}/apx-project/SKILL.md +20 -29
  77. package/src/core/runtime-skills/apx-routine/SKILL.md +127 -0
  78. package/src/core/runtime-skills/apx-runtime/SKILL.md +99 -0
  79. package/src/core/runtime-skills/apx-sessions/SKILL.md +232 -0
  80. package/src/core/runtime-skills/apx-skill-builder/SKILL.md +129 -0
  81. package/{skills → src/core/runtime-skills}/apx-task/SKILL.md +18 -21
  82. package/src/core/runtime-skills/apx-telegram/SKILL.md +120 -0
  83. package/src/core/runtime-skills/apx-voice/SKILL.md +117 -0
  84. package/src/core/runtime-skills/{claude-code.md → claude-code/SKILL.md} +1 -0
  85. package/src/core/runtime-skills/{codex-cli.md → codex-cli/SKILL.md} +1 -0
  86. package/src/core/runtime-skills/{opencode-cli.md → opencode-cli/SKILL.md} +1 -0
  87. package/src/core/runtime-skills/{openrouter.md → openrouter/SKILL.md} +1 -0
  88. package/src/{host/daemon/env-detect.js → core/runtimes/detect.js} +1 -1
  89. package/src/core/stores/code-sessions.js +50 -2
  90. package/src/core/stores/routine-memory.js +1 -1
  91. package/src/core/stores/sessions-search.js +121 -0
  92. package/src/core/stores/sessions.js +38 -0
  93. package/src/core/vars/index.js +14 -0
  94. package/src/core/vars/interpolate.js +86 -0
  95. package/src/core/vars/sources.js +151 -0
  96. package/src/core/voice/audio-decode.js +38 -0
  97. package/src/core/voice/transcription.js +225 -0
  98. package/src/host/daemon/api/admin-config.js +5 -82
  99. package/src/host/daemon/api/agents.js +5 -5
  100. package/src/host/daemon/api/code.js +17 -169
  101. package/src/host/daemon/api/config.js +3 -4
  102. package/src/host/daemon/api/conversations.js +8 -29
  103. package/src/host/daemon/api/deck.js +37 -404
  104. package/src/host/daemon/api/engines.js +1 -80
  105. package/src/host/daemon/api/exec.js +1 -1
  106. package/src/host/daemon/api/mcps.js +32 -0
  107. package/src/host/daemon/api/routines.js +1 -1
  108. package/src/host/daemon/api/runtimes.js +4 -3
  109. package/src/host/daemon/api/sessions-search.js +24 -140
  110. package/src/host/daemon/api/sessions.js +12 -30
  111. package/src/host/daemon/api/shared.js +2 -1
  112. package/src/host/daemon/api/skills.js +140 -6
  113. package/src/host/daemon/api/super-agent.js +56 -1
  114. package/src/host/daemon/api/telegram.js +1 -11
  115. package/src/host/daemon/api/tools.js +6 -6
  116. package/src/host/daemon/api/transcribe.js +2 -2
  117. package/src/host/daemon/api/vars.js +137 -0
  118. package/src/host/daemon/api/voice.js +13 -290
  119. package/src/host/daemon/api.js +2 -0
  120. package/src/host/daemon/db.js +6 -6
  121. package/src/host/daemon/deck-exec.js +148 -0
  122. package/src/host/daemon/index.js +20 -3
  123. package/src/host/daemon/plugins/telegram/index.js +9 -9
  124. package/src/host/daemon/routines-scheduler.js +64 -0
  125. package/src/host/daemon/smoke.js +3 -2
  126. package/src/host/daemon/whisper-server.js +225 -0
  127. package/src/interfaces/cli/branding.js +53 -0
  128. package/src/interfaces/cli/commands/agent.js +3 -2
  129. package/src/interfaces/cli/commands/command.js +2 -3
  130. package/src/interfaces/cli/commands/messages.js +6 -2
  131. package/src/interfaces/cli/commands/pair.js +5 -4
  132. package/src/interfaces/cli/commands/search.js +1 -1
  133. package/src/interfaces/cli/commands/sessions.js +3 -2
  134. package/src/interfaces/cli/commands/skills.js +290 -55
  135. package/src/interfaces/cli/index.js +84 -2
  136. package/src/interfaces/web/dist/assets/index-C0fm31dY.js +618 -0
  137. package/src/interfaces/web/dist/assets/index-C0fm31dY.js.map +1 -0
  138. package/src/interfaces/web/dist/assets/index-UcAqlBO6.css +1 -0
  139. package/src/interfaces/web/dist/index.html +2 -2
  140. package/src/interfaces/web/package-lock.json +182 -182
  141. package/src/interfaces/web/src/components/ModelCombobox.tsx +2 -1
  142. package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +1 -1
  143. package/src/interfaces/web/src/components/chat/AskAnswersCard.tsx +76 -0
  144. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +37 -4
  145. package/src/interfaces/web/src/components/chat/MessageList.tsx +23 -1
  146. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +3 -1
  147. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +4 -4
  148. package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +1 -1
  149. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +3 -2
  150. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +3 -2
  151. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +3 -2
  152. package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -1
  153. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +2 -1
  154. package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +93 -0
  155. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +449 -0
  156. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +2 -1
  157. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +2 -2
  158. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +73 -4
  159. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +222 -0
  160. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +3 -2
  161. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +3 -2
  162. package/src/interfaces/web/src/components/ui/chat-input.tsx +5 -4
  163. package/src/interfaces/web/src/components/ui/sidebar.tsx +3 -2
  164. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +2 -1
  165. package/src/interfaces/web/src/constants/index.ts +1 -1
  166. package/src/interfaces/web/src/hooks/useChat.ts +19 -0
  167. package/src/interfaces/web/src/i18n/en.ts +175 -7
  168. package/src/interfaces/web/src/i18n/es.ts +180 -15
  169. package/src/interfaces/web/src/lib/api/mcps.ts +25 -0
  170. package/src/interfaces/web/src/lib/api/skills.ts +70 -0
  171. package/src/interfaces/web/src/lib/api/vars.ts +38 -0
  172. package/src/interfaces/web/src/lib/api.ts +1 -0
  173. package/src/interfaces/web/src/screens/ProjectScreen.tsx +8 -31
  174. package/src/interfaces/web/src/screens/SettingsScreen.tsx +6 -2
  175. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +1 -1
  176. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +4 -3
  177. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +7 -6
  178. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +4 -3
  179. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +1 -1
  180. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +132 -1
  181. package/src/interfaces/web/src/screens/project/McpsTab.tsx +549 -104
  182. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +1 -1
  183. package/src/interfaces/web/src/screens/project/VarsTab.tsx +300 -0
  184. package/src/interfaces/web/src/types/daemon.ts +15 -0
  185. package/skills/apx-agency-agents/SKILL.md +0 -141
  186. package/skills/apx-agent/SKILL.md +0 -100
  187. package/skills/apx-mcp-builder/SKILL.md +0 -183
  188. package/skills/apx-routine/SKILL.md +0 -140
  189. package/skills/apx-runtime/SKILL.md +0 -117
  190. package/skills/apx-sessions/SKILL.md +0 -281
  191. package/skills/apx-skill-builder/SKILL.md +0 -153
  192. package/skills/apx-telegram/SKILL.md +0 -131
  193. package/skills/apx-voice/SKILL.md +0 -137
  194. package/src/core/agent/prompts/action-discipline.md +0 -24
  195. package/src/core/agent/prompts/super-agent-base.md +0 -42
  196. package/src/host/daemon/transcription.js +0 -538
  197. package/src/host/daemon/whisper-transcribe.py +0 -73
  198. package/src/interfaces/web/dist/assets/index-Aaiw8BZN.css +0 -1
  199. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js +0 -602
  200. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js.map +0 -1
  201. /package/src/{host/daemon → core/apc}/projects-helpers.js +0 -0
  202. /package/src/{host/daemon/plugins → core/channels}/telegram/ask.js +0 -0
  203. /package/src/{host/daemon/plugins → core/channels}/telegram/helpers.js +0 -0
  204. /package/src/{host/daemon/plugins → core/channels}/telegram/media.js +0 -0
  205. /package/src/core/{tools → http-tools}/index.js +0 -0
  206. /package/src/{host/daemon/compact.js → core/stores/conversations-compactor.js} +0 -0
  207. /package/src/{host/daemon → core/stores}/conversations.js +0 -0
  208. /package/src/{host/daemon → core/util}/thinking.js +0 -0
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: apx-voice
3
+ scope: optional
4
+ description: APX TTS — Piper (local), ElevenLabs/OpenAI/Gemini (cloud), unified /voice/turn channel, `apx voice` CLI. Load when the user wants to speak with APX, configure a voice engine, or troubleshoot silent output.
5
+ ---
6
+
7
+ # apx-voice
8
+
9
+ TTS facade in `core/voice/` with five engines. STT lives separately in `host/daemon/transcription.js` (Whisper). The "voice channel" combines both for mic→agent→speaker.
10
+
11
+ ## Engines
12
+
13
+ | id | Local? | Needs key? | Notes |
14
+ |---|---|---|---|
15
+ | `piper` | yes | no | Local, offline. Requires `piper` CLI + `.onnx` model. es_AR-daniela-high recommended. |
16
+ | `elevenlabs` | no | yes | Excellent. Free tier 10k chars/mo. `eleven_multilingual_v2`. |
17
+ | `openai` | no | yes | Reuses `engines.openai.api_key`. `tts-1`. |
18
+ | `gemini` | no | yes | Returns raw L16 PCM — APX wraps in WAV automatically. |
19
+ | `mock` | yes | no | Silent WAV; placeholder for tests. |
20
+
21
+ `auto` probes: piper → elevenlabs → openai → gemini → mock.
22
+
23
+ ## Concrete CLI calls
24
+
25
+ ```bash
26
+ apx voice providers # what's configured + available
27
+ apx voice say "Hello from APX" --provider piper
28
+ apx voice say "Hello from APX" --provider gemini --voice Aoede
29
+ apx voice say "..." --no-play # generate WAV, don't play
30
+
31
+ apx voice listen # mic → STT, records until silence (sox) or Ctrl+C
32
+ apx voice listen --seconds 5 # fixed-duration capture
33
+ apx voice listen --provider <id> # override STT provider
34
+ ```
35
+
36
+ Playback uses system binaries (`afplay`, `paplay`, `aplay`, `play`, `ffplay`). If none found, you get the file path and no playback.
37
+
38
+ ## Configuration
39
+
40
+ `~/.apx/config.json → voice.tts.<engine>`:
41
+
42
+ ```json
43
+ {
44
+ "voice": {
45
+ "tts": {
46
+ "provider": "gemini",
47
+ "piper": { "bin": "piper", "model": "/Users/.../es_AR-daniela-high.onnx" },
48
+ "elevenlabs": { "api_key": "...", "model": "eleven_multilingual_v2", "voice_id": "..." },
49
+ "openai": { "api_key": "...", "model": "tts-1", "voice": "alloy", "format": "mp3" },
50
+ "gemini": { "api_key": "...", "model": "gemini-2.5-flash-preview-tts", "voice": "Aoede" }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ `apx config set voice.tts.provider <name>` to switch.
57
+
58
+ ## Quick setup: Piper local (recommended, no internet)
59
+
60
+ ```bash
61
+ # 1. Install binary (macOS arm64)
62
+ curl -L https://github.com/rhasspy/piper/releases/latest/download/piper_macos_aarch64.tar.gz \
63
+ -o /tmp/piper.tar.gz
64
+ sudo tar xzf /tmp/piper.tar.gz -C /usr/local/bin --strip-components=1
65
+
66
+ # 2. Voice model (es_AR, "daniela")
67
+ mkdir -p ~/.apx/voices && cd ~/.apx/voices
68
+ curl -LO https://huggingface.co/rhasspy/piper-voices/resolve/main/es/es_AR/daniela/high/es_AR-daniela-high.onnx
69
+ curl -LO https://huggingface.co/rhasspy/piper-voices/resolve/main/es/es_AR/daniela/high/es_AR-daniela-high.onnx.json
70
+
71
+ # 3. Configure + test
72
+ apx config set voice.tts.provider piper
73
+ apx config set voice.tts.piper.model "$HOME/.apx/voices/es_AR-daniela-high.onnx"
74
+ apx voice say "hola, soy APX" --provider piper
75
+ ```
76
+
77
+ ## Quick setup: Gemini cloud
78
+
79
+ ```bash
80
+ apx config set voice.tts.provider gemini
81
+ apx config set voice.tts.gemini.api_key '<GEMINI_KEY>'
82
+ apx config set engines.gemini.api_key '<GEMINI_KEY>' # reuse for LLM router
83
+ apx voice say "Hello from APX" --provider gemini
84
+ ```
85
+
86
+ ## Unified voice channel
87
+
88
+ `POST /voice/turn` is one round-trip: send audio (or text), get back `{ user_text, reply_text, reply_audio_path }`. STT in, agent loop, TTS out. For overlay and future "voice room" clients.
89
+
90
+ ```bash
91
+ curl -X POST http://127.0.0.1:7430/voice/turn \
92
+ -H "Authorization: Bearer $(cat ~/.apx/daemon.token)" \
93
+ -H "Content-Type: application/json" \
94
+ -d '{"text":"Hello APX","channel":"voice"}'
95
+ ```
96
+
97
+ Telegram voice messages and overlay mascot still have their own STT pipelines — they don't go through `/voice/turn` yet.
98
+
99
+ ## Anti-examples
100
+
101
+ - DON'T trust `apx voice providers` saying "mock available" as green light — mock is silence. Configure a real provider.
102
+ - DON'T set `voice.tts.provider` to a provider with no key. It falls through `auto` to the next, but that's not what you asked.
103
+ - DON'T expect Gemini TTS to return MP3 — it returns raw L16 PCM; APX wraps in WAV. Files are `.wav`, mime `audio/wav`. Convert with ffmpeg if you need MP3.
104
+
105
+ ## Troubleshooting silent output
106
+
107
+ 1. `apx voice providers` — what's actually available?
108
+ 2. `apx voice say "test" --provider <engine> --no-play` — file exists?
109
+ 3. `file <path>` — valid container? Gemini output should be `RIFF WAVE Microsoft PCM`.
110
+ 4. `afplay <path>` — does the OS player open it?
111
+ 5. If 3 fails for Gemini, you may be on APX before the PCM-wrap fix (commit `ba5c416`+).
112
+
113
+ ## Don't
114
+
115
+ - Paste base64 audio into chat. Use file paths or `send_voice` / `send_audio`.
116
+ - Switch providers mid-routine without testing — quality varies a lot across Piper voices and cloud engines.
117
+ - Expect TTS streaming yet — `apx voice say` returns a complete file. `/tts/stream` is open work.
@@ -1,4 +1,5 @@
1
1
  ---
2
+ scope: internal
2
3
  name: claude-code
3
4
  description: "Activate ONLY when the user explicitly mentions Claude Code, Claude CLI, claude command, Anthropic Claude Code, installing Claude Code, using Claude Code, or APX runtime claude-code. Do not activate for generic Claude model discussion."
4
5
  homepage: https://docs.anthropic.com/en/docs/claude-code
@@ -1,4 +1,5 @@
1
1
  ---
2
+ scope: internal
2
3
  name: codex-cli
3
4
  description: "Activate ONLY when the user explicitly mentions Codex CLI, OpenAI Codex, @openai/codex, codex command, codex exec, installing Codex, using Codex, ~/.codex, or APX runtime codex."
4
5
  homepage: https://developers.openai.com/codex
@@ -1,4 +1,5 @@
1
1
  ---
2
+ scope: internal
2
3
  name: opencode-cli
3
4
  description: "Activate ONLY when the user explicitly mentions OpenCode, opencode command, installing OpenCode, using OpenCode, OpenCode provider setup, or APX runtime opencode."
4
5
  homepage: https://opencode.ai/docs
@@ -1,4 +1,5 @@
1
1
  ---
2
+ scope: internal
2
3
  name: openrouter
3
4
  description: "Activate ONLY when the user explicitly mentions OpenRouter, OPENROUTER_API_KEY, OpenRouter models, installing OpenRouter provider config, or using OpenRouter with APX, OpenCode, LiteLLM, or an OpenAI-compatible client."
4
5
  homepage: https://openrouter.ai/docs
@@ -1,7 +1,7 @@
1
1
  // Best-effort detection of installed agent CLIs and LLM runners.
2
2
  // We just probe the binary with `--version` (or equivalent) and don't fail if
3
3
  // it isn't there — caller decides what to do with absence.
4
- import { runProcess } from "./runtimes/_spawn.js";
4
+ import { runProcess } from "#host/daemon/runtimes/_spawn.js";
5
5
 
6
6
  const PROBES = [
7
7
  // Coding-agent CLIs (runtimes/)
@@ -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
+ }