@agentprojectcontext/apx 1.50.1 → 1.51.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.50.1",
3
+ "version": "1.51.1",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -21,6 +21,7 @@ import { agentsMdFile } from "../apc/paths.js";
21
21
  import { readSelfMemoryForPrompt } from "./self-memory.js";
22
22
  import { buildSkillsHintBlock } from "./skills/catalog.js";
23
23
  import { CHANNELS } from "#core/constants/channels.js";
24
+ import { activeEmotionGuide, buildEmotionGuide } from "../voice/emotions.js";
24
25
 
25
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
27
  const PROMPTS_DIR = path.join(__dirname, "prompts");
@@ -99,13 +100,16 @@ export function buildChannelContextBlock(channel, meta = {}) {
99
100
  return renderPromptTemplate(loadPrompt(rel), meta);
100
101
  }
101
102
 
102
- export function buildVoiceModeBlock(active) {
103
+ export function buildVoiceModeBlock(active, emotionGuide = "") {
103
104
  if (!active) return "";
105
+ let base = "";
104
106
  try {
105
- return loadPrompt(VOICE_MODE_FILE);
107
+ base = loadPrompt(VOICE_MODE_FILE);
106
108
  } catch {
107
- return "";
109
+ base = "";
108
110
  }
111
+ if (!emotionGuide) return base;
112
+ return base ? `${base}\n\n${emotionGuide}` : emotionGuide;
109
113
  }
110
114
 
111
115
  // Pick the right segmenting discipline for the channel (and whether voice
@@ -287,7 +291,14 @@ export function buildSuperAgentSystem({
287
291
 
288
292
  const channelBlock = buildChannelContextBlock(channel, channelMeta);
289
293
  const extraContext = [channelBlock, contextNote].filter(Boolean).join("\n\n");
290
- const voiceBlock = buildVoiceModeBlock(voice);
294
+ // In voice mode, if the engine that will speak supports inline emotion tags
295
+ // (a per-engine config toggle), teach the agent the syntax. channelMeta
296
+ // .ttsProvider optionally forces which engine's capability to honor.
297
+ const emotion = voice ? activeEmotionGuide(globalConfig, channelMeta?.ttsProvider) : null;
298
+ const voiceBlock = buildVoiceModeBlock(
299
+ voice,
300
+ emotion ? buildEmotionGuide(emotion.tags) : ""
301
+ );
291
302
  const segmentDiscipline = buildSegmentDiscipline({ channel: channelLow, voice });
292
303
 
293
304
  return [
@@ -0,0 +1,108 @@
1
+ // Generic "emotion tags" capability for TTS engines.
2
+ //
3
+ // Some speech backends (e.g. a local Qwen3-TTS / QVox server, reached through
4
+ // the OpenAI-compatible `openai` adapter pointed at a custom base_url) accept
5
+ // inline [tag] markers in the text and switch the speaking emotion per segment
6
+ // while keeping the same base voice. QVox's tag set is the canonical default
7
+ // (see qwen3-tts-api `_app.py` TAG_INSTRUCTS).
8
+ //
9
+ // This capability is NOT hard-coded into any adapter. It's a per-engine config
10
+ // toggle (`voice.tts.<id>.emotions.enabled`) so it can be ADDED to whichever
11
+ // engine the user actually points at an emotion-aware backend — the custom
12
+ // OpenAI endpoint today, anything tomorrow. Two responsibilities live here:
13
+ // 1. activeEmotionGuide() — what the prompt builder injects (voice mode only)
14
+ // so the agent learns the tag syntax, but ONLY when the engine that will
15
+ // speak supports tags.
16
+ // 2. stripEmotionTags() — a safety net so that if a turn ends up on an engine
17
+ // WITHOUT tag support, stray markers are scrubbed and never read aloud.
18
+
19
+ import { resolveMode, resolveChainOrder, isCustomId, CUSTOM_PREFIX } from "./engines/index.js";
20
+
21
+ // Canonical tag set (mirrors QVox TAG_INSTRUCTS keys, de-duplicated).
22
+ export const DEFAULT_EMOTION_TAGS = [
23
+ "happy", "sad", "excited", "angry", "calm",
24
+ "whisper", "shout", "laugh", "cry", "narrator", "neutral",
25
+ ];
26
+
27
+ const TAG_RE = /\[[a-zA-Z]{2,12}\]/g;
28
+
29
+ function ttsCfg(globalConfig) {
30
+ return globalConfig?.voice?.tts || {};
31
+ }
32
+
33
+ // Per-engine config block (built-in id → voice.tts.<id>; custom:<slug> →
34
+ // voice.tts.custom.<slug>).
35
+ function providerBlock(globalConfig, providerId) {
36
+ const tts = ttsCfg(globalConfig);
37
+ if (isCustomId(providerId)) return tts?.custom?.[providerId.slice(CUSTOM_PREFIX.length)] || {};
38
+ return tts?.[providerId] || {};
39
+ }
40
+
41
+ function enabledOf(tts, id) {
42
+ if (isCustomId(id)) return tts?.custom?.[id.slice(CUSTOM_PREFIX.length)]?.enabled !== false;
43
+ return tts?.[id]?.enabled !== false;
44
+ }
45
+
46
+ /**
47
+ * Emotion capability declared for one engine id. `enabled` is false when the
48
+ * block is missing or turned off; `tags` falls back to the canonical set.
49
+ */
50
+ export function emotionConfigFor(globalConfig, providerId) {
51
+ const e = providerBlock(globalConfig, providerId)?.emotions;
52
+ const enabled = !!e?.enabled;
53
+ const tags = Array.isArray(e?.tags) && e.tags.length
54
+ ? e.tags.map((s) => String(s).trim().toLowerCase()).filter(Boolean)
55
+ : DEFAULT_EMOTION_TAGS;
56
+ return { enabled, tags };
57
+ }
58
+
59
+ /**
60
+ * Best-effort, SYNCHRONOUS resolution of which engine will speak. Used at
61
+ * prompt-build time (which is sync and must not probe isAvailable()). Mirrors
62
+ * the intent of selectTtsEngine without the async availability probes:
63
+ * - explicit provider arg wins
64
+ * - single mode → voice.tts.provider
65
+ * - chain mode → the FIRST enabled engine in order (what selectTtsEngine
66
+ * would speak with). We deliberately do NOT prefer an emotion-capable
67
+ * engine here: the guide must reflect the engine that will actually speak,
68
+ * otherwise the agent emits tags a different engine never asked for.
69
+ */
70
+ export function resolveSpeakingProvider(globalConfig, provider) {
71
+ if (provider && provider !== "auto") return provider;
72
+ const cfg = ttsCfg(globalConfig);
73
+ const mode = resolveMode(cfg);
74
+ if (mode === "single" && cfg.provider && cfg.provider !== "auto") return cfg.provider;
75
+ const order = resolveChainOrder(cfg).filter(
76
+ (id) => id !== "mock" && enabledOf(cfg, id)
77
+ );
78
+ return order[0] || cfg.provider || undefined;
79
+ }
80
+
81
+ /**
82
+ * The emotion guide to inject for a voice-mode turn, or null when the engine
83
+ * that will speak does not support tags. `provider` optionally forces a
84
+ * specific engine (e.g. a tester override).
85
+ */
86
+ export function activeEmotionGuide(globalConfig, provider) {
87
+ const id = resolveSpeakingProvider(globalConfig, provider);
88
+ if (!id) return null;
89
+ const { enabled, tags } = emotionConfigFor(globalConfig, id);
90
+ return enabled ? { provider: id, tags } : null;
91
+ }
92
+
93
+ /** Markdown block teaching the inline-tag syntax. Appended to modes/voice.md. */
94
+ export function buildEmotionGuide(tags = DEFAULT_EMOTION_TAGS) {
95
+ const list = (Array.isArray(tags) && tags.length ? tags : DEFAULT_EMOTION_TAGS).join("] [");
96
+ return [
97
+ "## Emotion tags (spoken delivery)",
98
+ "The voice engine for this turn understands inline emotion tags. You MAY drop them into your spoken reply to color the delivery — a tag affects the words that follow it, until the next tag.",
99
+ `Available tags: [${list}]`,
100
+ 'Write the tag in square brackets right before the phrase it colors (e.g. "[excited] ¡Listo! [calm] Lo dejé anotado."). Use them sparingly — at most one or two per reply, only when the emotion genuinely helps. The tags are removed before synthesis (never spoken). Never invent tags outside the list above.',
101
+ ].join("\n");
102
+ }
103
+
104
+ /** Remove stray [tag] markers so a non-tag engine never reads them aloud. */
105
+ export function stripEmotionTags(text) {
106
+ if (typeof text !== "string") return text;
107
+ return text.replace(TAG_RE, "").replace(/[ \t]{2,}/g, " ").trim();
108
+ }
@@ -28,7 +28,28 @@ export const TTS_ENGINE_IDS = Object.keys(ADAPTERS);
28
28
 
29
29
  export const AUTO_PREFERENCE = ["piper", "elevenlabs", "openai", "gemini", "mock"];
30
30
 
31
+ // ── Custom providers ────────────────────────────────────────────────────────
32
+ // Users can add any number of OpenAI-compatible endpoints (e.g. a local QVox /
33
+ // Qwen3-TTS server). They live under voice.tts.custom.<slug> and surface with
34
+ // engine id "custom:<slug>". They're all backed by the openai adapter.
35
+ export const CUSTOM_PREFIX = "custom:";
36
+
37
+ export function isCustomId(id) {
38
+ return typeof id === "string" && id.startsWith(CUSTOM_PREFIX);
39
+ }
40
+ function slugOf(id) {
41
+ return isCustomId(id) ? id.slice(CUSTOM_PREFIX.length) : id;
42
+ }
43
+ function customEngineIds(ttsCfg) {
44
+ return Object.keys(ttsCfg?.custom || {}).map((slug) => CUSTOM_PREFIX + slug);
45
+ }
46
+ function knownIds(ttsCfg) {
47
+ return [...TTS_ENGINE_IDS, ...customEngineIds(ttsCfg)];
48
+ }
49
+
31
50
  export function getTtsAdapter(provider) {
51
+ // All custom providers are OpenAI-compatible → openai adapter.
52
+ if (isCustomId(provider)) return ADAPTERS.openai;
32
53
  const a = ADAPTERS[provider];
33
54
  if (!a) {
34
55
  throw new Error(
@@ -43,10 +64,13 @@ function ttsConfig(globalConfig) {
43
64
  }
44
65
 
45
66
  function providerConfig(globalConfig, provider) {
46
- return ttsConfig(globalConfig)?.[provider] || {};
67
+ const tts = ttsConfig(globalConfig);
68
+ if (isCustomId(provider)) return tts?.custom?.[slugOf(provider)] || {};
69
+ return tts?.[provider] || {};
47
70
  }
48
71
 
49
72
  function isEnabled(ttsCfg, id) {
73
+ if (isCustomId(id)) return ttsCfg?.custom?.[slugOf(id)]?.enabled !== false;
50
74
  return ttsCfg?.[id]?.enabled !== false;
51
75
  }
52
76
 
@@ -63,11 +87,14 @@ export function resolveMode(ttsCfg) {
63
87
  * so the UI can render + reorder every row; filtering happens at selection time.
64
88
  */
65
89
  export function resolveChainOrder(ttsCfg) {
66
- const custom = Array.isArray(ttsCfg?.order)
67
- ? ttsCfg.order.filter((id) => TTS_ENGINE_IDS.includes(id))
90
+ const known = knownIds(ttsCfg);
91
+ const ordered = Array.isArray(ttsCfg?.order)
92
+ ? ttsCfg.order.filter((id) => known.includes(id))
68
93
  : [];
69
- const rest = AUTO_PREFERENCE.filter((id) => !custom.includes(id));
70
- const full = [...custom, ...rest];
94
+ const rest = [...AUTO_PREFERENCE, ...customEngineIds(ttsCfg)].filter(
95
+ (id) => !ordered.includes(id)
96
+ );
97
+ const full = [...ordered, ...rest];
71
98
  // Guarantee mock is present as the ultimate fallback.
72
99
  if (!full.includes("mock")) full.push("mock");
73
100
  return full;
@@ -101,7 +128,7 @@ export async function selectTtsEngine({ globalConfig, provider }) {
101
128
  // 3. Chain mode: probe the (enabled) order, first available wins.
102
129
  for (const id of resolveChainOrder(ttsCfg)) {
103
130
  if (id !== "mock" && !isEnabled(ttsCfg, id)) continue;
104
- const adapter = ADAPTERS[id];
131
+ const adapter = getTtsAdapter(id);
105
132
  const cfg = providerConfig(globalConfig, id);
106
133
  try {
107
134
  if (await adapter.isAvailable(cfg, globalConfig?.engines)) {
@@ -124,20 +151,23 @@ export async function selectTtsEngine({ globalConfig, provider }) {
124
151
  export async function listAvailableTtsEngines(globalConfig) {
125
152
  const ttsCfg = ttsConfig(globalConfig);
126
153
  const out = [];
127
- for (const id of TTS_ENGINE_IDS) {
128
- const adapter = ADAPTERS[id];
154
+ for (const id of knownIds(ttsCfg)) {
155
+ const adapter = getTtsAdapter(id);
129
156
  const cfg = providerConfig(globalConfig, id);
130
157
  let available = false;
131
158
  try {
132
159
  available = await adapter.isAvailable(cfg, globalConfig?.engines);
133
160
  } catch { available = false; }
161
+ const custom = isCustomId(id);
134
162
  out.push({
135
163
  id,
136
164
  available,
137
165
  // `enabled` is a routing flag, not real config — exclude it from the
138
166
  // "configured" heuristic so toggling on/off doesn't fake-mark an engine.
139
- configured: Object.keys(cfg).filter((k) => k !== "enabled").length > 0,
167
+ // For custom engines `label` is descriptive metadata, not config either.
168
+ configured: Object.keys(cfg).filter((k) => k !== "enabled" && k !== "label").length > 0,
140
169
  enabled: isEnabled(ttsCfg, id),
170
+ ...(custom ? { custom: true, label: cfg.label || slugOf(id), note: cfg.base_url || "" } : {}),
141
171
  });
142
172
  }
143
173
  return out;
@@ -1,18 +1,29 @@
1
- // OpenAI TTS adapter (tts-1 / tts-1-hd).
1
+ // OpenAI TTS adapter (tts-1 / tts-1-hd) — and any OpenAI-compatible endpoint.
2
2
  // Docs: https://platform.openai.com/docs/api-reference/audio/createSpeech
3
3
  //
4
4
  // Reuses engines.openai.api_key from ~/.apx/config.json. Per-engine voice
5
5
  // config (~/.apx/config.json → voice.tts.openai) can override model/voice.
6
+ //
7
+ // Custom endpoint ("QVox custom"): set voice.tts.openai.base_url to a local
8
+ // OpenAI-compatible speech server (e.g. a Qwen3-TTS / QVox daemon at
9
+ // http://127.0.0.1:5111/v1). When base_url is set we additionally forward the
10
+ // non-OpenAI fields that server understands — `instruct` (the base voice, from
11
+ // the `style` arg), `language` and `temperature`. These extras are NEVER sent
12
+ // to stock OpenAI (only when base_url is present), so the standard path stays
13
+ // byte-for-byte compatible.
6
14
 
7
15
  import fs from "node:fs";
8
16
  import path from "node:path";
9
17
  import { randomUUID } from "node:crypto";
10
18
 
11
- const API_URL = "https://api.openai.com/v1/audio/speech";
19
+ const DEFAULT_API_URL = "https://api.openai.com/v1/audio/speech";
12
20
  const DEFAULT_MODEL = "tts-1";
13
21
  const DEFAULT_VOICE = "alloy"; // alloy|echo|fable|onyx|nova|shimmer
14
22
 
15
23
  function getKey(config, parentEnginesCfg) {
24
+ // A custom endpoint uses ONLY its own key (often none); never leak the stock
25
+ // OpenAI engine key / OPENAI_API_KEY env to a third-party server.
26
+ if (config.base_url) return config.api_key || "";
16
27
  return (
17
28
  config.api_key ||
18
29
  parentEnginesCfg?.openai?.api_key ||
@@ -21,6 +32,13 @@ function getKey(config, parentEnginesCfg) {
21
32
  );
22
33
  }
23
34
 
35
+ function endpoint(config) {
36
+ if (config.base_url) {
37
+ return config.base_url.replace(/\/+$/, "") + "/audio/speech";
38
+ }
39
+ return DEFAULT_API_URL;
40
+ }
41
+
24
42
  function mimeFor(format) {
25
43
  return {
26
44
  mp3: "audio/mpeg",
@@ -36,30 +54,48 @@ export default {
36
54
  id: "openai",
37
55
 
38
56
  async isAvailable(config = {}, parentEnginesCfg) {
39
- return Boolean(getKey(config, parentEnginesCfg));
57
+ // A custom endpoint is assumed reachable (it may be keyless/open like QVox);
58
+ // stock OpenAI needs a key.
59
+ return Boolean(config.base_url) || Boolean(getKey(config, parentEnginesCfg));
40
60
  },
41
61
 
42
- async synthesize({ text, voice, outDir, config = {}, format, signal, parentEnginesCfg }) {
62
+ async synthesize({ text, voice, language, style, outDir, config = {}, format, signal, parentEnginesCfg }) {
43
63
  if (!text) throw new Error("openai-tts: empty text");
64
+ const isCustom = Boolean(config.base_url);
44
65
  const key = getKey(config, parentEnginesCfg);
45
- if (!key) throw new Error("openai-tts: no api_key (set OPENAI_API_KEY or engines.openai.api_key)");
66
+ if (!isCustom && !key) {
67
+ throw new Error("openai-tts: no api_key (set OPENAI_API_KEY or engines.openai.api_key)");
68
+ }
46
69
 
47
- const model = config.model || DEFAULT_MODEL;
48
- const chosenVoice = voice || config.voice || DEFAULT_VOICE;
49
- const responseFormat = format || config.format || "mp3";
70
+ const url = endpoint(config);
71
+ const model = config.model || (isCustom ? undefined : DEFAULT_MODEL);
72
+ const chosenVoice = voice || config.voice || (isCustom ? undefined : DEFAULT_VOICE);
73
+ const responseFormat = format || config.format || (isCustom ? "wav" : "mp3");
74
+ const styleHint = String(style ?? config.style ?? "").trim();
75
+
76
+ const body = { input: text, response_format: responseFormat };
77
+ if (model) body.model = model;
78
+ if (chosenVoice) body.voice = chosenVoice;
79
+ if (isCustom) {
80
+ // QVox / Qwen3-TTS extras (ignored by stock OpenAI, so only sent here).
81
+ if (styleHint) body.instruct = styleHint;
82
+ if (language) body.language = language;
83
+ if (config.temperature != null) body.temperature = config.temperature;
84
+ } else if (styleHint && /gpt-4o.*tts/i.test(model || "")) {
85
+ // Stock OpenAI's newer TTS models accept a natural-language `instructions`.
86
+ body.instructions = styleHint;
87
+ }
88
+
89
+ const headers = { "content-type": "application/json" };
90
+ if (key) {
91
+ headers.authorization = `Bearer ${key}`;
92
+ if (isCustom) headers["x-api-key"] = key; // QVox accepts either header.
93
+ }
50
94
 
51
- const res = await fetch(API_URL, {
95
+ const res = await fetch(url, {
52
96
  method: "POST",
53
- headers: {
54
- authorization: `Bearer ${key}`,
55
- "content-type": "application/json",
56
- },
57
- body: JSON.stringify({
58
- model,
59
- voice: chosenVoice,
60
- input: text,
61
- response_format: responseFormat,
62
- }),
97
+ headers,
98
+ body: JSON.stringify(body),
63
99
  signal,
64
100
  });
65
101
  if (!res.ok) {
@@ -17,6 +17,7 @@ import {
17
17
  resolveMode,
18
18
  resolveChainOrder,
19
19
  } from "./engines/index.js";
20
+ import { emotionConfigFor, stripEmotionTags } from "./emotions.js";
20
21
 
21
22
  export const TTS_TMP_DIR = path.join(os.homedir(), ".apx", "tmp", "tts");
22
23
 
@@ -61,9 +62,15 @@ export async function synthesize({
61
62
  provider,
62
63
  });
63
64
 
65
+ // Safety net: if the engine that will speak does NOT support inline emotion
66
+ // tags, scrub any stray [tag] markers so they're never read aloud literally.
67
+ const speakText = emotionConfigFor(cfg, selectedProvider).enabled
68
+ ? text
69
+ : stripEmotionTags(text);
70
+
64
71
  const outDir = ensureTtsTmpDir();
65
72
  return adapter.synthesize({
66
- text,
73
+ text: speakText,
67
74
  voice,
68
75
  language,
69
76
  format,
@@ -72,7 +79,7 @@ export async function synthesize({
72
79
  config: engineConfig,
73
80
  parentEnginesCfg: cfg.engines,
74
81
  signal,
75
- }).then((r) => ({ ...r, provider: r.provider || selectedProvider }));
82
+ }).then((r) => ({ ...r, provider: selectedProvider || r.provider }));
76
83
  }
77
84
 
78
85
  /** List engines and whether they look usable right now. */
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import { readConfig } from "#core/config/index.js";
24
24
  import { synthesize } from "#core/voice/tts.js";
25
+ import { stripEmotionTags } from "#core/voice/emotions.js";
25
26
  import { transcribe } from "#core/voice/transcription.js";
26
27
  import { decodeAudioInput } from "#core/voice/audio-decode.js";
27
28
  import { runSuperAgent, isSuperAgentEnabled } from "#core/agent/super-agent.js";
@@ -174,6 +175,12 @@ export function register(app, { projects, plugins, registries }) {
174
175
  replyText = userText;
175
176
  }
176
177
 
178
+ // Emotion tags ([excited], [whisper], …) are a TTS-only signal: keep them
179
+ // for synthesis (engines that support them split on them), but strip them
180
+ // from everything the user reads — the chat bubble, history, RAG.
181
+ const audioText = replyText;
182
+ replyText = stripEmotionTags(replyText);
183
+
177
184
  // Persist the turn to the cross-channel store (feeds RAG index,
178
185
  // search_messages, and the "active threads" block). channelCtx.channel is
179
186
  // the resolved surface ("deck"/"desktop"). Best-effort.
@@ -190,10 +197,11 @@ export function register(app, { projects, plugins, registries }) {
190
197
  if (replyText) {
191
198
  try {
192
199
  tts = await synthesize({
193
- text: replyText,
200
+ text: audioText,
194
201
  voice: body.voice,
195
202
  language: body.language,
196
203
  provider: body.provider,
204
+ style: body.style,
197
205
  format: body.format_out,
198
206
  globalConfig: cfg,
199
207
  });