@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.
- package/package.json +1 -1
- package/skills/apx/SKILL.md +49 -61
- package/src/core/agent/a2a/reply.js +48 -0
- package/src/core/agent/build-agent-system.js +136 -59
- package/src/core/agent/channels/voice-context.js +98 -0
- package/src/core/agent/memory.js +2 -1
- package/src/core/agent/prompt-builder.js +178 -124
- package/src/core/agent/prompts/channels/code.md +12 -10
- package/src/core/agent/prompts/channels/desktop.md +5 -32
- package/src/core/agent/prompts/channels/telegram.md +4 -15
- package/src/core/agent/prompts/channels/web_code.md +11 -11
- package/src/core/agent/prompts/core/agent-base.md +24 -0
- package/src/core/agent/prompts/core/project-agent.md +11 -0
- package/src/core/agent/prompts/core/super-agent.md +21 -0
- package/src/core/agent/prompts/discipline/action.md +10 -0
- package/src/core/agent/prompts/discipline/single-segment.md +6 -0
- package/src/core/agent/prompts/discipline/two-segment.md +11 -0
- package/src/core/agent/prompts/modes/code-build.md +1 -0
- package/src/core/agent/prompts/modes/code-plan.md +1 -0
- package/src/core/agent/prompts/modes/index.js +28 -0
- package/src/core/agent/self-memory.js +43 -1
- package/src/core/agent/skills/index-store.js +307 -0
- package/src/core/agent/skills/index.js +15 -1
- package/src/core/agent/skills/inspector.js +317 -0
- package/src/core/agent/skills/loader.js +22 -18
- package/src/core/agent/stream/turn-accumulator.js +73 -0
- package/src/core/agent/suggestions.js +37 -0
- package/src/core/agent/super-agent.js +7 -1
- package/src/core/agent/tools/handlers/_git.js +50 -0
- package/src/core/agent/tools/handlers/add-project.js +5 -2
- package/src/core/agent/tools/handlers/call-runtime.js +3 -2
- package/src/core/agent/tools/handlers/git-diff.js +44 -0
- package/src/core/agent/tools/handlers/git-log.js +38 -0
- package/src/core/agent/tools/handlers/git-show.js +34 -0
- package/src/core/agent/tools/handlers/git-status.js +61 -0
- package/src/core/agent/tools/handlers/transcribe-audio.js +1 -1
- package/src/core/agent/tools/helpers.js +2 -2
- package/src/core/agent/tools/names.js +169 -0
- package/src/core/agent/tools/registry-bridge.js +6 -14
- package/src/core/agent/tools/registry.js +103 -69
- package/src/core/apc/context-copy.js +27 -0
- package/src/core/apc/notes.js +19 -0
- package/src/core/apc/parser.js +12 -5
- package/src/core/apc/paths.js +87 -0
- package/src/core/apc/scaffold.js +82 -76
- package/src/core/apc/skill-sync.js +10 -0
- package/src/{host/daemon/plugins → core/channels}/telegram/dispatch.js +38 -16
- package/src/core/config/index.js +24 -2
- package/src/core/config/redact.js +95 -0
- package/src/core/constants/channels.js +2 -0
- package/src/core/constants/code-modes.js +10 -0
- package/src/core/constants/index.js +1 -0
- package/src/core/deck/manifest.js +186 -0
- package/src/core/engines/catalog.js +83 -0
- package/src/core/{tools → http-tools}/browser.js +0 -1
- package/src/core/{tools → http-tools}/fetch.js +0 -1
- package/src/core/{tools → http-tools}/glob.js +0 -1
- package/src/core/{tools → http-tools}/grep.js +0 -1
- package/src/core/{tools → http-tools}/registry.js +0 -1
- package/src/core/{tools → http-tools}/search.js +0 -1
- package/src/core/i18n/en.js +9 -0
- package/src/core/i18n/es.js +12 -0
- package/src/core/i18n/index.js +54 -0
- package/src/core/i18n/pt.js +9 -0
- package/src/core/identity/telegram.js +2 -1
- package/src/core/mcp/runner.js +272 -14
- package/src/core/mcp/sources.js +3 -2
- package/src/core/routines/index.js +16 -0
- package/src/{host/daemon/routines.js → core/routines/runner.js} +36 -103
- package/src/core/runtime-skills/apc-context/SKILL.md +159 -0
- package/src/core/runtime-skills/apx/SKILL.md +83 -0
- package/src/core/runtime-skills/apx-agency-agents/SKILL.md +125 -0
- package/src/core/runtime-skills/apx-agent/SKILL.md +97 -0
- package/src/core/runtime-skills/apx-mcp/SKILL.md +111 -0
- package/src/core/runtime-skills/apx-mcp-builder/SKILL.md +169 -0
- package/{skills → src/core/runtime-skills}/apx-project/SKILL.md +20 -29
- package/src/core/runtime-skills/apx-routine/SKILL.md +127 -0
- package/src/core/runtime-skills/apx-runtime/SKILL.md +99 -0
- package/src/core/runtime-skills/apx-sessions/SKILL.md +232 -0
- package/src/core/runtime-skills/apx-skill-builder/SKILL.md +129 -0
- package/{skills → src/core/runtime-skills}/apx-task/SKILL.md +18 -21
- package/src/core/runtime-skills/apx-telegram/SKILL.md +120 -0
- package/src/core/runtime-skills/apx-voice/SKILL.md +117 -0
- package/src/core/runtime-skills/{claude-code.md → claude-code/SKILL.md} +1 -0
- package/src/core/runtime-skills/{codex-cli.md → codex-cli/SKILL.md} +1 -0
- package/src/core/runtime-skills/{opencode-cli.md → opencode-cli/SKILL.md} +1 -0
- package/src/core/runtime-skills/{openrouter.md → openrouter/SKILL.md} +1 -0
- package/src/{host/daemon/env-detect.js → core/runtimes/detect.js} +1 -1
- package/src/core/stores/code-sessions.js +50 -2
- package/src/core/stores/routine-memory.js +1 -1
- package/src/core/stores/sessions-search.js +121 -0
- package/src/core/stores/sessions.js +38 -0
- package/src/core/vars/index.js +14 -0
- package/src/core/vars/interpolate.js +86 -0
- package/src/core/vars/sources.js +151 -0
- package/src/core/voice/audio-decode.js +38 -0
- package/src/core/voice/transcription.js +225 -0
- package/src/host/daemon/api/admin-config.js +5 -82
- package/src/host/daemon/api/agents.js +5 -5
- package/src/host/daemon/api/code.js +17 -169
- package/src/host/daemon/api/config.js +3 -4
- package/src/host/daemon/api/conversations.js +8 -29
- package/src/host/daemon/api/deck.js +37 -404
- package/src/host/daemon/api/engines.js +1 -80
- package/src/host/daemon/api/exec.js +1 -1
- package/src/host/daemon/api/mcps.js +32 -0
- package/src/host/daemon/api/routines.js +1 -1
- package/src/host/daemon/api/runtimes.js +4 -3
- package/src/host/daemon/api/sessions-search.js +24 -140
- package/src/host/daemon/api/sessions.js +12 -30
- package/src/host/daemon/api/shared.js +2 -1
- package/src/host/daemon/api/skills.js +140 -6
- package/src/host/daemon/api/super-agent.js +56 -1
- package/src/host/daemon/api/telegram.js +1 -11
- package/src/host/daemon/api/tools.js +6 -6
- package/src/host/daemon/api/transcribe.js +2 -2
- package/src/host/daemon/api/vars.js +137 -0
- package/src/host/daemon/api/voice.js +13 -290
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/db.js +6 -6
- package/src/host/daemon/deck-exec.js +148 -0
- package/src/host/daemon/index.js +20 -3
- package/src/host/daemon/plugins/telegram/index.js +9 -9
- package/src/host/daemon/routines-scheduler.js +64 -0
- package/src/host/daemon/smoke.js +3 -2
- package/src/host/daemon/whisper-server.js +225 -0
- package/src/interfaces/cli/branding.js +53 -0
- package/src/interfaces/cli/commands/agent.js +3 -2
- package/src/interfaces/cli/commands/command.js +2 -3
- package/src/interfaces/cli/commands/messages.js +6 -2
- package/src/interfaces/cli/commands/pair.js +5 -4
- package/src/interfaces/cli/commands/search.js +1 -1
- package/src/interfaces/cli/commands/sessions.js +3 -2
- package/src/interfaces/cli/commands/skills.js +290 -55
- package/src/interfaces/cli/index.js +84 -2
- package/src/interfaces/web/dist/assets/index-C0fm31dY.js +618 -0
- package/src/interfaces/web/dist/assets/index-C0fm31dY.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-UcAqlBO6.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +182 -182
- package/src/interfaces/web/src/components/ModelCombobox.tsx +2 -1
- package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +1 -1
- package/src/interfaces/web/src/components/chat/AskAnswersCard.tsx +76 -0
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +37 -4
- package/src/interfaces/web/src/components/chat/MessageList.tsx +23 -1
- package/src/interfaces/web/src/components/chat/ModelPicker.tsx +3 -1
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +4 -4
- package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +1 -1
- package/src/interfaces/web/src/components/code/CodeFileTree.tsx +3 -2
- package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +3 -2
- package/src/interfaces/web/src/components/code/CodeTerminal.tsx +3 -2
- package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -1
- package/src/interfaces/web/src/components/deck/WidgetRow.tsx +2 -1
- package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +93 -0
- package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +449 -0
- package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +2 -1
- package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +2 -2
- package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +73 -4
- package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +222 -0
- package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +3 -2
- package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +3 -2
- package/src/interfaces/web/src/components/ui/chat-input.tsx +5 -4
- package/src/interfaces/web/src/components/ui/sidebar.tsx +3 -2
- package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +2 -1
- package/src/interfaces/web/src/constants/index.ts +1 -1
- package/src/interfaces/web/src/hooks/useChat.ts +19 -0
- package/src/interfaces/web/src/i18n/en.ts +175 -7
- package/src/interfaces/web/src/i18n/es.ts +180 -15
- package/src/interfaces/web/src/lib/api/mcps.ts +25 -0
- package/src/interfaces/web/src/lib/api/skills.ts +70 -0
- package/src/interfaces/web/src/lib/api/vars.ts +38 -0
- package/src/interfaces/web/src/lib/api.ts +1 -0
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +8 -31
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +6 -2
- package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +1 -1
- package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +4 -3
- package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +7 -6
- package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +4 -3
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +1 -1
- package/src/interfaces/web/src/screens/project/ConfigTab.tsx +132 -1
- package/src/interfaces/web/src/screens/project/McpsTab.tsx +549 -104
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +1 -1
- package/src/interfaces/web/src/screens/project/VarsTab.tsx +300 -0
- package/src/interfaces/web/src/types/daemon.ts +15 -0
- package/skills/apx-agency-agents/SKILL.md +0 -141
- package/skills/apx-agent/SKILL.md +0 -100
- package/skills/apx-mcp-builder/SKILL.md +0 -183
- package/skills/apx-routine/SKILL.md +0 -140
- package/skills/apx-runtime/SKILL.md +0 -117
- package/skills/apx-sessions/SKILL.md +0 -281
- package/skills/apx-skill-builder/SKILL.md +0 -153
- package/skills/apx-telegram/SKILL.md +0 -131
- package/skills/apx-voice/SKILL.md +0 -137
- package/src/core/agent/prompts/action-discipline.md +0 -24
- package/src/core/agent/prompts/super-agent-base.md +0 -42
- package/src/host/daemon/transcription.js +0 -538
- package/src/host/daemon/whisper-transcribe.py +0 -73
- package/src/interfaces/web/dist/assets/index-Aaiw8BZN.css +0 -1
- package/src/interfaces/web/dist/assets/index-DPqtjDjh.js +0 -602
- package/src/interfaces/web/dist/assets/index-DPqtjDjh.js.map +0 -1
- /package/src/{host/daemon → core/apc}/projects-helpers.js +0 -0
- /package/src/{host/daemon/plugins → core/channels}/telegram/ask.js +0 -0
- /package/src/{host/daemon/plugins → core/channels}/telegram/helpers.js +0 -0
- /package/src/{host/daemon/plugins → core/channels}/telegram/media.js +0 -0
- /package/src/core/{tools → http-tools}/index.js +0 -0
- /package/src/{host/daemon/compact.js → core/stores/conversations-compactor.js} +0 -0
- /package/src/{host/daemon → core/stores}/conversations.js +0 -0
- /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: 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 "
|
|
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 ===
|
|
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 ===
|
|
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 (
|
|
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
|
+
}
|