@agentprojectcontext/apx 1.33.1 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -3
- 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 +2 -1
- 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/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/tools/handlers/add-project.js +5 -2
- package/src/core/agent/tools/handlers/call-runtime.js +3 -2
- 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 +138 -0
- package/src/core/agent/tools/registry-bridge.js +6 -14
- package/src/core/agent/tools/registry.js +68 -65
- 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 +3 -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 +95 -0
- package/src/core/runtime-skills/apx-mcp/SKILL.md +116 -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/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 +3 -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/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 +36 -55
- package/src/interfaces/web/dist/assets/index-DdmSRtsz.css +1 -0
- package/src/interfaces/web/dist/assets/index-M4FspaCH.js +613 -0
- package/src/interfaces/web/dist/assets/index-M4FspaCH.js.map +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 +16 -3
- 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 +5 -4
- 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/i18n/en.ts +174 -7
- package/src/interfaces/web/src/i18n/es.ts +179 -15
- package/src/interfaces/web/src/lib/api/mcps.ts +25 -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/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 +5 -0
- 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/{skills → src/core/runtime-skills}/apx-agency-agents/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-agent/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-mcp-builder/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-project/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-routine/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-runtime/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-sessions/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-skill-builder/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-task/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-telegram/SKILL.md +0 -0
- /package/{skills → src/core/runtime-skills}/apx-voice/SKILL.md +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,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
globalVarsPath,
|
|
3
|
+
projectVarsPath,
|
|
4
|
+
readGlobalVars,
|
|
5
|
+
writeGlobalVars,
|
|
6
|
+
readProjectVars,
|
|
7
|
+
writeProjectVars,
|
|
8
|
+
loadAllVars,
|
|
9
|
+
setVar,
|
|
10
|
+
deleteVar,
|
|
11
|
+
maskValue,
|
|
12
|
+
} from "./sources.js";
|
|
13
|
+
|
|
14
|
+
export { interpolate, findRefs, MissingVarError } from "./interpolate.js";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ${var.NAME} interpolation engine.
|
|
2
|
+
//
|
|
3
|
+
// Used at MCP boot (and any other site that opts in) so files committed to
|
|
4
|
+
// the repo can hold references like
|
|
5
|
+
// "Authorization": "Bearer ${var.ASANA_TOKEN}"
|
|
6
|
+
// while the real value lives in ~/.apx/vars.json or
|
|
7
|
+
// <storagePath>/vars.json (see ./sources.js).
|
|
8
|
+
//
|
|
9
|
+
// Semantics:
|
|
10
|
+
// - Only top-level strings inside the input object/array are walked
|
|
11
|
+
// recursively. Numbers/booleans/null pass through.
|
|
12
|
+
// - A `${var.NAME}` token where NAME is missing throws a MissingVarError
|
|
13
|
+
// listing every missing name (so the UI can show a single useful message
|
|
14
|
+
// instead of "first failure wins").
|
|
15
|
+
// - Names match [A-Z0-9_] / [a-z0-9_] / dot — we don't enforce a charset
|
|
16
|
+
// beyond "no whitespace and no closing brace". Stay liberal here, strict
|
|
17
|
+
// at the UI.
|
|
18
|
+
|
|
19
|
+
const VAR_RE = /\$\{var\.([^}\s]+)\}/g;
|
|
20
|
+
|
|
21
|
+
export class MissingVarError extends Error {
|
|
22
|
+
constructor(missing) {
|
|
23
|
+
super(
|
|
24
|
+
`Undefined variable${missing.length > 1 ? "s" : ""}: ${missing
|
|
25
|
+
.map((n) => `\${var.${n}}`)
|
|
26
|
+
.join(", ")}`
|
|
27
|
+
);
|
|
28
|
+
this.name = "MissingVarError";
|
|
29
|
+
this.missing = missing;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Collect every `${var.NAME}` reference found in `value` (deep walk).
|
|
34
|
+
// Returns an array of unique names in encounter order.
|
|
35
|
+
export function findRefs(value) {
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const walk = (v) => {
|
|
38
|
+
if (typeof v === "string") {
|
|
39
|
+
let m;
|
|
40
|
+
VAR_RE.lastIndex = 0;
|
|
41
|
+
while ((m = VAR_RE.exec(v)) !== null) seen.add(m[1]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(v)) {
|
|
45
|
+
for (const x of v) walk(x);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (v && typeof v === "object") {
|
|
49
|
+
for (const x of Object.values(v)) walk(x);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(value);
|
|
53
|
+
return Array.from(seen);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Replace every `${var.NAME}` inside `value` using the `vars` lookup. Missing
|
|
57
|
+
// names accumulate and surface as a single MissingVarError at the end so
|
|
58
|
+
// callers can show "missing: TOKEN_A, TOKEN_B" in one shot.
|
|
59
|
+
export function interpolate(value, vars) {
|
|
60
|
+
const missing = new Set();
|
|
61
|
+
|
|
62
|
+
const replaceString = (s) => {
|
|
63
|
+
return s.replace(VAR_RE, (_, name) => {
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(vars, name)) {
|
|
65
|
+
return String(vars[name]);
|
|
66
|
+
}
|
|
67
|
+
missing.add(name);
|
|
68
|
+
return _;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const walk = (v) => {
|
|
73
|
+
if (typeof v === "string") return replaceString(v);
|
|
74
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
75
|
+
if (v && typeof v === "object") {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [k, x] of Object.entries(v)) out[k] = walk(x);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
return v;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = walk(value);
|
|
84
|
+
if (missing.size) throw new MissingVarError(Array.from(missing));
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Variable storage for APX. Two scopes:
|
|
2
|
+
// global — ~/.apx/vars.json (chmod 0600)
|
|
3
|
+
// project — <storagePath>/vars.json (chmod 0600)
|
|
4
|
+
// i.e. ~/.apx/projects/<apxId>/vars.json
|
|
5
|
+
//
|
|
6
|
+
// Both files live outside the project repo so values never get committed.
|
|
7
|
+
// The .apc/ files committed to the repo only reference vars by name
|
|
8
|
+
// (e.g. `${var.ASANA_TOKEN}`) — actual values live here.
|
|
9
|
+
//
|
|
10
|
+
// Each file is a flat object: { "NAME": "value", ... }. Names are
|
|
11
|
+
// uppercase letters / digits / underscore by convention (we don't enforce it
|
|
12
|
+
// — anything safe to interpolate works).
|
|
13
|
+
//
|
|
14
|
+
// project wins over global when the same name exists in both.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
const APX_HOME = path.join(os.homedir(), ".apx");
|
|
21
|
+
const GLOBAL_VARS_FILE = path.join(APX_HOME, "vars.json");
|
|
22
|
+
const PROJECT_VARS_FILENAME = "vars.json";
|
|
23
|
+
|
|
24
|
+
export function globalVarsPath() {
|
|
25
|
+
return GLOBAL_VARS_FILE;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function projectVarsPath(storagePath) {
|
|
29
|
+
if (!storagePath) return null;
|
|
30
|
+
return path.join(storagePath, PROJECT_VARS_FILENAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJsonSafe(absPath) {
|
|
34
|
+
if (!absPath || !fs.existsSync(absPath)) return {};
|
|
35
|
+
try {
|
|
36
|
+
const json = JSON.parse(fs.readFileSync(absPath, "utf8"));
|
|
37
|
+
return json && typeof json === "object" && !Array.isArray(json) ? json : {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeJsonSecure(absPath, obj) {
|
|
44
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
45
|
+
fs.writeFileSync(absPath, JSON.stringify(obj, null, 2) + "\n");
|
|
46
|
+
try {
|
|
47
|
+
fs.chmodSync(absPath, 0o600);
|
|
48
|
+
} catch {
|
|
49
|
+
// Best effort on non-POSIX filesystems.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readGlobalVars() {
|
|
54
|
+
return readJsonSafe(GLOBAL_VARS_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function writeGlobalVars(obj) {
|
|
58
|
+
writeJsonSecure(GLOBAL_VARS_FILE, obj);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readProjectVars(storagePath) {
|
|
62
|
+
return readJsonSafe(projectVarsPath(storagePath));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeProjectVars(storagePath, obj) {
|
|
66
|
+
if (!storagePath) throw new Error("writeProjectVars: storagePath required");
|
|
67
|
+
writeJsonSecure(projectVarsPath(storagePath), obj);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sanitizeMap(obj) {
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
73
|
+
out[k] = String(v)
|
|
74
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "")
|
|
75
|
+
.replace(/\u00A0/g, " ")
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Aggregate project + global with project winning.
|
|
82
|
+
// Returns { project, global, effective, sources } where sources[name] is
|
|
83
|
+
// "project" or "global" so callers know where each effective value came from.
|
|
84
|
+
// Values are sanitized at read time so legacy entries written before the
|
|
85
|
+
// save-time trim land also come out clean.
|
|
86
|
+
export function loadAllVars({ storagePath } = {}) {
|
|
87
|
+
const project = sanitizeMap(storagePath ? readProjectVars(storagePath) : {});
|
|
88
|
+
const global = sanitizeMap(readGlobalVars());
|
|
89
|
+
const effective = { ...global, ...project };
|
|
90
|
+
const sources = {};
|
|
91
|
+
for (const name of Object.keys(global)) sources[name] = "global";
|
|
92
|
+
for (const name of Object.keys(project)) sources[name] = "project";
|
|
93
|
+
return { project, global, effective, sources };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Strip leading/trailing whitespace + invisible chars (ZWSP, BOM, …). The #1
|
|
97
|
+
// reason a pasted token "doesn't work" is a stray newline picked up from the
|
|
98
|
+
// copy buffer; defaulting to trim removes that whole class of bugs while
|
|
99
|
+
// leaving real values untouched.
|
|
100
|
+
function sanitizeVarValue(raw) {
|
|
101
|
+
return String(raw)
|
|
102
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "")
|
|
103
|
+
.replace(/\u00A0/g, " ")
|
|
104
|
+
.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Convenience: set/unset on either scope. Returns the new full object.
|
|
108
|
+
export function setVar({ storagePath, scope, name, value }) {
|
|
109
|
+
const v = sanitizeVarValue(value);
|
|
110
|
+
if (scope === "project") {
|
|
111
|
+
if (!storagePath) throw new Error("project scope requires storagePath");
|
|
112
|
+
const obj = readProjectVars(storagePath);
|
|
113
|
+
obj[name] = v;
|
|
114
|
+
writeProjectVars(storagePath, obj);
|
|
115
|
+
return obj;
|
|
116
|
+
}
|
|
117
|
+
if (scope === "global") {
|
|
118
|
+
const obj = readGlobalVars();
|
|
119
|
+
obj[name] = v;
|
|
120
|
+
writeGlobalVars(obj);
|
|
121
|
+
return obj;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`unknown scope "${scope}"`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function deleteVar({ storagePath, scope, name }) {
|
|
127
|
+
if (scope === "project") {
|
|
128
|
+
if (!storagePath) throw new Error("project scope requires storagePath");
|
|
129
|
+
const obj = readProjectVars(storagePath);
|
|
130
|
+
if (!(name in obj)) return false;
|
|
131
|
+
delete obj[name];
|
|
132
|
+
writeProjectVars(storagePath, obj);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
if (scope === "global") {
|
|
136
|
+
const obj = readGlobalVars();
|
|
137
|
+
if (!(name in obj)) return false;
|
|
138
|
+
delete obj[name];
|
|
139
|
+
writeGlobalVars(obj);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`unknown scope "${scope}"`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Mask helper for read paths — never send the raw value to the UI by default.
|
|
146
|
+
export function maskValue(value) {
|
|
147
|
+
if (value == null) return "";
|
|
148
|
+
const s = String(value);
|
|
149
|
+
if (s.length <= 4) return "•".repeat(s.length);
|
|
150
|
+
return "•".repeat(Math.min(s.length - 4, 8)) + s.slice(-4);
|
|
151
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Decode the `audio` field from a voice/turn request into a filesystem path
|
|
2
|
+
// the STT layer can read. Three input shapes:
|
|
3
|
+
// - undefined / null → returns null (caller falls through to text-only)
|
|
4
|
+
// - filesystem path → returns it as-is (cleanup: false)
|
|
5
|
+
// - base64 (raw or `data:...;base64,...`) → writes to tmp, cleanup: true
|
|
6
|
+
//
|
|
7
|
+
// `cleanup: true` means the caller must `fs.unlinkSync(decoded.path)` when
|
|
8
|
+
// done. Pure helper, no daemon dependencies.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
const MAX_PATH_LEN = 1024;
|
|
15
|
+
|
|
16
|
+
export async function decodeAudioInput({ audio, format = "webm" }) {
|
|
17
|
+
if (!audio) return null;
|
|
18
|
+
// A short string that starts with "/" and exists on disk is treated as a
|
|
19
|
+
// path. Anything else is interpreted as base64 (optionally with a data:
|
|
20
|
+
// prefix).
|
|
21
|
+
if (
|
|
22
|
+
typeof audio === "string" &&
|
|
23
|
+
audio.length < MAX_PATH_LEN &&
|
|
24
|
+
audio.startsWith("/") &&
|
|
25
|
+
fs.existsSync(audio)
|
|
26
|
+
) {
|
|
27
|
+
return { path: audio, cleanup: false };
|
|
28
|
+
}
|
|
29
|
+
let b64 = audio;
|
|
30
|
+
const m = /^data:[^;]+;base64,(.+)$/.exec(b64);
|
|
31
|
+
if (m) b64 = m[1];
|
|
32
|
+
const buf = Buffer.from(b64, "base64");
|
|
33
|
+
if (!buf.length) throw new Error("decodeAudioInput: decoded audio is empty");
|
|
34
|
+
const ext = String(format || "webm").replace(/^\./, "");
|
|
35
|
+
const tmp = path.join(os.tmpdir(), `apx-voice-${Date.now()}-${randomUUID()}.${ext}`);
|
|
36
|
+
fs.writeFileSync(tmp, buf);
|
|
37
|
+
return { path: tmp, cleanup: true };
|
|
38
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Audio transcription client. Two backends, both pure (no subprocess lifecycle):
|
|
2
|
+
//
|
|
3
|
+
// - LOCAL: HTTP client that talks to the persistent whisper-server.py at
|
|
4
|
+
// localhost:WHISPER_LOCAL_PORT. The server itself is spun up/down by
|
|
5
|
+
// host/daemon/whisper-server.js — this file just assumes it is reachable.
|
|
6
|
+
// - OPENAI: Whisper-1 cloud API. Needs OPENAI_API_KEY or
|
|
7
|
+
// engines.openai.api_key in config.
|
|
8
|
+
//
|
|
9
|
+
// Provider selection in ~/.apx/config.json:
|
|
10
|
+
// "transcription": {
|
|
11
|
+
// "provider": "auto" | "local" | "openai",
|
|
12
|
+
// "local": { model, device, compute_type, language, beam_size, idle_minutes }
|
|
13
|
+
// }
|
|
14
|
+
// "auto" tries local first, falls back to OpenAI if a key is configured.
|
|
15
|
+
//
|
|
16
|
+
// The split rule: anything that boots/teardown a process lives in host/daemon.
|
|
17
|
+
// Anything that sends bytes over HTTP and parses JSON lives here.
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { logInfo, logWarn } from "#core/logging.js";
|
|
21
|
+
|
|
22
|
+
/** Port the host-side whisper-server.py listens on. Single source of truth. */
|
|
23
|
+
export const WHISPER_LOCAL_PORT = 18765;
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_LOCAL = {
|
|
26
|
+
model: "small",
|
|
27
|
+
device: "cpu",
|
|
28
|
+
compute_type: "int8",
|
|
29
|
+
language: "auto",
|
|
30
|
+
beam_size: 5,
|
|
31
|
+
idle_minutes: 10,
|
|
32
|
+
// Long audio (Telegram voice notes > 10 min) can take several minutes on
|
|
33
|
+
// CPU. 20 minutes covers ~60-minute notes on a small int8 model.
|
|
34
|
+
timeout_ms: 20 * 60_000,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the effective transcription language. Priority:
|
|
39
|
+
* explicit local config → config.user.language → "auto" (whisper detects).
|
|
40
|
+
*/
|
|
41
|
+
export function resolveTranscriptionLanguage(localCfg, userLang) {
|
|
42
|
+
if (localCfg.language && localCfg.language !== "auto") return localCfg.language;
|
|
43
|
+
if (userLang) return userLang;
|
|
44
|
+
return "auto";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getConfig() {
|
|
48
|
+
try {
|
|
49
|
+
const { readConfig } = await import("#core/config/index.js");
|
|
50
|
+
const cfg = readConfig() || {};
|
|
51
|
+
const t = cfg.transcription || {};
|
|
52
|
+
const openaiKey = cfg.engines?.openai?.api_key || process.env.OPENAI_API_KEY || "";
|
|
53
|
+
const userLang = cfg.user?.language || "";
|
|
54
|
+
const localBase = { ...DEFAULT_LOCAL, ...(t.local || {}) };
|
|
55
|
+
localBase.language = resolveTranscriptionLanguage(localBase, userLang);
|
|
56
|
+
return {
|
|
57
|
+
provider: t.provider || "auto",
|
|
58
|
+
local: localBase,
|
|
59
|
+
openaiKey,
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return {
|
|
63
|
+
provider: "auto",
|
|
64
|
+
local: { ...DEFAULT_LOCAL },
|
|
65
|
+
openaiKey: process.env.OPENAI_API_KEY || "",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Call the local whisper-server.py over HTTP. Does NOT spawn or check the
|
|
72
|
+
* subprocess — that's host/daemon/whisper-server.js's job. If the server is
|
|
73
|
+
* down, this throws a clear "ECONNREFUSED" the caller can surface.
|
|
74
|
+
*/
|
|
75
|
+
export async function transcribeViaLocalServer(filePath, opts) {
|
|
76
|
+
const language = (opts.language || DEFAULT_LOCAL.language) === "auto"
|
|
77
|
+
? null
|
|
78
|
+
: (opts.language || null);
|
|
79
|
+
|
|
80
|
+
const timeoutMs = Number(opts.timeout_ms) > 0
|
|
81
|
+
? Number(opts.timeout_ms)
|
|
82
|
+
: DEFAULT_LOCAL.timeout_ms;
|
|
83
|
+
|
|
84
|
+
const body = JSON.stringify({
|
|
85
|
+
audio_path: filePath,
|
|
86
|
+
language,
|
|
87
|
+
beam_size: opts.beam_size || DEFAULT_LOCAL.beam_size,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Long transcriptions on CPU sometimes trip undici keep-alive on the
|
|
91
|
+
// outbound socket — retry once on generic "fetch failed".
|
|
92
|
+
const maxAttempts = 2;
|
|
93
|
+
let lastErr = null;
|
|
94
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
95
|
+
const t0 = Date.now();
|
|
96
|
+
try {
|
|
97
|
+
logInfo("whisper", `transcribeViaLocalServer attempt ${attempt}/${maxAttempts}`, {
|
|
98
|
+
file: path.basename(filePath),
|
|
99
|
+
language: language || "auto",
|
|
100
|
+
timeout_ms: timeoutMs,
|
|
101
|
+
});
|
|
102
|
+
const res = await fetch(`http://127.0.0.1:${WHISPER_LOCAL_PORT}/transcribe`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "content-type": "application/json", "connection": "close" },
|
|
105
|
+
body,
|
|
106
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
107
|
+
});
|
|
108
|
+
const json = await res.json();
|
|
109
|
+
if (!json.ok) throw new Error(json.error || "transcription failed");
|
|
110
|
+
logInfo("whisper", `transcribeViaLocalServer ok in ${Date.now() - t0}ms`, {
|
|
111
|
+
chars: (json.text || "").length,
|
|
112
|
+
language: json.language,
|
|
113
|
+
duration: json.duration,
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
backend: "local",
|
|
118
|
+
text: json.text || "",
|
|
119
|
+
language: json.language || null,
|
|
120
|
+
language_probability: json.language_probability ?? null,
|
|
121
|
+
duration: json.duration ?? null,
|
|
122
|
+
model: json.model,
|
|
123
|
+
compute_type: json.compute_type,
|
|
124
|
+
};
|
|
125
|
+
} catch (e) {
|
|
126
|
+
lastErr = e;
|
|
127
|
+
const isRetriable = /fetch failed|ECONNRESET|socket hang up|terminated/i.test(e.message || "");
|
|
128
|
+
const dt = Date.now() - t0;
|
|
129
|
+
logWarn("whisper", `transcribeViaLocalServer attempt ${attempt} failed in ${dt}ms`, {
|
|
130
|
+
error: e.message,
|
|
131
|
+
retriable: isRetriable,
|
|
132
|
+
will_retry: isRetriable && attempt < maxAttempts,
|
|
133
|
+
});
|
|
134
|
+
if (!isRetriable || attempt >= maxAttempts) break;
|
|
135
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw lastErr || new Error("transcribeViaLocalServer: unknown failure");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** OpenAI Whisper-1 cloud API. Needs an api_key. */
|
|
142
|
+
export async function transcribeOpenAI(filePath, apiKey) {
|
|
143
|
+
if (!apiKey) throw new Error("openai transcription: no api_key");
|
|
144
|
+
const buf = fs.readFileSync(filePath);
|
|
145
|
+
const ext = path.extname(filePath).slice(1).toLowerCase() || "webm";
|
|
146
|
+
const fileType = ext === "ogg" || ext === "oga" ? "audio/ogg"
|
|
147
|
+
: ext === "mp3" ? "audio/mpeg"
|
|
148
|
+
: ext === "m4a" ? "audio/mp4"
|
|
149
|
+
: ext === "wav" ? "audio/wav"
|
|
150
|
+
: ext === "webm" ? "audio/webm"
|
|
151
|
+
: "application/octet-stream";
|
|
152
|
+
|
|
153
|
+
const form = new FormData();
|
|
154
|
+
form.append("model", "whisper-1");
|
|
155
|
+
form.append("file", new Blob([buf], { type: fileType }), path.basename(filePath));
|
|
156
|
+
|
|
157
|
+
const res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
160
|
+
body: form,
|
|
161
|
+
signal: AbortSignal.timeout(60_000),
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) {
|
|
164
|
+
const errBody = await res.text().catch(() => "");
|
|
165
|
+
throw new Error(`openai whisper ${res.status}: ${errBody.slice(0, 240)}`);
|
|
166
|
+
}
|
|
167
|
+
const json = await res.json();
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
backend: "openai",
|
|
171
|
+
text: json.text || "",
|
|
172
|
+
language: json.language || null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Transcribe a file. Provider chosen by config:
|
|
178
|
+
* - "openai": cloud only
|
|
179
|
+
* - "local": whisper-server only (no fallback)
|
|
180
|
+
* - "auto": local first, OpenAI fallback if api_key present
|
|
181
|
+
*/
|
|
182
|
+
export async function transcribe(filePath, overrides = {}) {
|
|
183
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
184
|
+
throw new Error(`transcribe: file not found: ${filePath}`);
|
|
185
|
+
}
|
|
186
|
+
const cfg = await getConfig();
|
|
187
|
+
const provider = overrides.provider || cfg.provider;
|
|
188
|
+
const localOpts = { ...cfg.local, ...overrides };
|
|
189
|
+
|
|
190
|
+
if (provider === "openai") {
|
|
191
|
+
return transcribeOpenAI(filePath, cfg.openaiKey);
|
|
192
|
+
}
|
|
193
|
+
if (provider === "local") {
|
|
194
|
+
return transcribeViaLocalServer(filePath, localOpts);
|
|
195
|
+
}
|
|
196
|
+
// auto: local first, fall back to openai if a key is configured
|
|
197
|
+
try {
|
|
198
|
+
return await transcribeViaLocalServer(filePath, localOpts);
|
|
199
|
+
} catch (localErr) {
|
|
200
|
+
if (cfg.openaiKey) {
|
|
201
|
+
return transcribeOpenAI(filePath, cfg.openaiKey);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`local transcription failed: ${localErr.message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Transcribe raw audio bytes. Saves to a temp file, transcribes, cleans up.
|
|
209
|
+
* @param {Buffer} buf
|
|
210
|
+
* @param {string} format extension hint ("webm" | "ogg" | "wav" | "mp3")
|
|
211
|
+
*/
|
|
212
|
+
export async function transcribeBuffer(buf, format = "webm", overrides = {}) {
|
|
213
|
+
if (!buf || !buf.length) throw new Error("transcribeBuffer: empty buffer");
|
|
214
|
+
const ext = format.replace(/^\./, "") || "webm";
|
|
215
|
+
const tmpFile = path.join(
|
|
216
|
+
(await import("node:os")).default.tmpdir(),
|
|
217
|
+
`apx-audio-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
|
|
218
|
+
);
|
|
219
|
+
try {
|
|
220
|
+
fs.writeFileSync(tmpFile, buf);
|
|
221
|
+
return await transcribe(tmpFile, overrides);
|
|
222
|
+
} finally {
|
|
223
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -9,88 +9,11 @@ import { readConfig, writeConfig } from "#core/config/index.js";
|
|
|
9
9
|
import { resolveAgentName } from "#core/identity/index.js";
|
|
10
10
|
import { setDottedKey, unsetDottedKey } from "../project-config.js";
|
|
11
11
|
import { PERMISSION_MODES, DEFAULT_PERMISSION_MODE } from "#core/constants/permissions.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"engines.openrouter.api_key",
|
|
18
|
-
"engines.gemini.api_key",
|
|
19
|
-
"voice.tts.elevenlabs.api_key",
|
|
20
|
-
"voice.tts.openai.api_key",
|
|
21
|
-
"voice.tts.gemini.api_key",
|
|
22
|
-
"memory.embeddings.openai.api_key",
|
|
23
|
-
"memory.embeddings.gemini.api_key",
|
|
24
|
-
"telegram.channels.*.bot_token",
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
function getDotted(obj, dotted) {
|
|
28
|
-
const parts = dotted.split(".");
|
|
29
|
-
let cur = obj;
|
|
30
|
-
for (const p of parts) {
|
|
31
|
-
if (cur == null) return undefined;
|
|
32
|
-
cur = cur[p];
|
|
33
|
-
}
|
|
34
|
-
return cur;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function secretMarker(value) {
|
|
38
|
-
if (typeof value !== "string" || !value.length) return value;
|
|
39
|
-
const suffix = value.slice(-5);
|
|
40
|
-
return `*** set *** (...${suffix})`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function isSecretMarker(value) {
|
|
44
|
-
return typeof value === "string" && value.startsWith("*** set ***");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Returns a deep copy with `*** set ***` for every present secret value.
|
|
48
|
-
function redact(cfg) {
|
|
49
|
-
const out = JSON.parse(JSON.stringify(cfg || {}));
|
|
50
|
-
const mark = (val) => (typeof val === "string" && val.length ? secretMarker(val) : val);
|
|
51
|
-
|
|
52
|
-
// Engine api keys + voice tts keys
|
|
53
|
-
for (const path of SECRET_PATHS) {
|
|
54
|
-
if (path.includes("*")) continue;
|
|
55
|
-
const parts = path.split(".");
|
|
56
|
-
let cur = out;
|
|
57
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
58
|
-
if (!cur[parts[i]] || typeof cur[parts[i]] !== "object") { cur = null; break; }
|
|
59
|
-
cur = cur[parts[i]];
|
|
60
|
-
}
|
|
61
|
-
if (cur && cur[parts[parts.length - 1]]) {
|
|
62
|
-
cur[parts[parts.length - 1]] = mark(cur[parts[parts.length - 1]]);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
// Telegram channels — array, redact bot_token per item (keep the suffix so
|
|
66
|
-
// the UI can show which token is set, e.g. "*** set *** (...AB12)").
|
|
67
|
-
const channels = out?.telegram?.channels;
|
|
68
|
-
if (Array.isArray(channels)) {
|
|
69
|
-
for (const ch of channels) {
|
|
70
|
-
if (ch && typeof ch.bot_token === "string" && ch.bot_token.length) {
|
|
71
|
-
ch.bot_token = mark(ch.bot_token);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return out;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function mergeRedactedChannels(nextChannels, priorChannels) {
|
|
79
|
-
if (!Array.isArray(nextChannels)) return nextChannels;
|
|
80
|
-
const priorByName = new Map(
|
|
81
|
-
(Array.isArray(priorChannels) ? priorChannels : [])
|
|
82
|
-
.filter((c) => c && typeof c.name === "string")
|
|
83
|
-
.map((c) => [c.name, c])
|
|
84
|
-
);
|
|
85
|
-
return nextChannels.map((channel) => {
|
|
86
|
-
if (!channel || typeof channel !== "object") return channel;
|
|
87
|
-
const prior = priorByName.get(channel.name);
|
|
88
|
-
if (prior?.bot_token && (channel.bot_token === undefined || isSecretMarker(channel.bot_token))) {
|
|
89
|
-
return { ...channel, bot_token: prior.bot_token };
|
|
90
|
-
}
|
|
91
|
-
return channel;
|
|
92
|
-
});
|
|
93
|
-
}
|
|
12
|
+
import {
|
|
13
|
+
redactConfig as redact,
|
|
14
|
+
isSecretMarker,
|
|
15
|
+
mergeRedactedChannels,
|
|
16
|
+
} from "#core/config/redact.js";
|
|
94
17
|
|
|
95
18
|
export function register(app, { config, scheduler, plugins }) {
|
|
96
19
|
app.get("/admin/config", (_req, res) => {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { readAgents, readVaultAgents, readVaultAgent } from "#core/apc/parser.js";
|
|
10
|
+
import { apcAgentFile, apcDir, apcMemoryFile } from "#core/apc/paths.js";
|
|
10
11
|
import {
|
|
11
12
|
writeAgentFile,
|
|
12
13
|
writeVaultAgentFile,
|
|
@@ -202,7 +203,7 @@ export function register(app, { projects, project }) {
|
|
|
202
203
|
const p = project(req, res);
|
|
203
204
|
if (!p) return;
|
|
204
205
|
const slug = req.params.slug;
|
|
205
|
-
const file =
|
|
206
|
+
const file = apcAgentFile(p.path, slug);
|
|
206
207
|
const runtimeDir = path.dirname(agentMemoryPath(p, slug));
|
|
207
208
|
const legacyDir = path.dirname(legacyAgentMemoryPath(p.path, slug));
|
|
208
209
|
if (!fs.existsSync(file) && !fs.existsSync(runtimeDir) && !fs.existsSync(legacyDir))
|
|
@@ -222,7 +223,7 @@ export function register(app, { projects, project }) {
|
|
|
222
223
|
app.get("/projects/:pid/memory", (req, res) => {
|
|
223
224
|
const p = project(req, res);
|
|
224
225
|
if (!p) return;
|
|
225
|
-
const memPath =
|
|
226
|
+
const memPath = apcMemoryFile(p.path);
|
|
226
227
|
const body = fs.existsSync(memPath) ? fs.readFileSync(memPath, "utf8") : "";
|
|
227
228
|
res.json({ body, path: memPath });
|
|
228
229
|
});
|
|
@@ -233,9 +234,8 @@ export function register(app, { projects, project }) {
|
|
|
233
234
|
const { body } = req.body || {};
|
|
234
235
|
if (typeof body !== "string")
|
|
235
236
|
return res.status(400).json({ error: "body must be string" });
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const memPath = path.join(apcDir, "memory.md");
|
|
237
|
+
fs.mkdirSync(apcDir(p.path), { recursive: true });
|
|
238
|
+
const memPath = apcMemoryFile(p.path);
|
|
239
239
|
fs.writeFileSync(memPath, body);
|
|
240
240
|
try { projects.rebuild(p.id); } catch {}
|
|
241
241
|
res.json({ ok: true, bytes: Buffer.byteLength(body, "utf8") });
|