@agentprojectcontext/apx 1.0.3
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/LICENSE +21 -0
- package/README.md +142 -0
- package/package.json +52 -0
- package/skills/apx/SKILL.md +77 -0
- package/src/cli/commands/a2a.js +66 -0
- package/src/cli/commands/agent.js +181 -0
- package/src/cli/commands/chat.js +84 -0
- package/src/cli/commands/command.js +42 -0
- package/src/cli/commands/config.js +56 -0
- package/src/cli/commands/daemon.js +148 -0
- package/src/cli/commands/exec.js +56 -0
- package/src/cli/commands/identity.js +146 -0
- package/src/cli/commands/init.js +23 -0
- package/src/cli/commands/mcp.js +147 -0
- package/src/cli/commands/memory.js +69 -0
- package/src/cli/commands/messages.js +61 -0
- package/src/cli/commands/plugins.js +23 -0
- package/src/cli/commands/project.js +124 -0
- package/src/cli/commands/routine.js +99 -0
- package/src/cli/commands/runtime.js +64 -0
- package/src/cli/commands/session.js +387 -0
- package/src/cli/commands/skills.js +153 -0
- package/src/cli/commands/telegram.js +48 -0
- package/src/cli/http.js +102 -0
- package/src/cli/index.js +481 -0
- package/src/cli/postinstall.js +25 -0
- package/src/core/apc-context-skill.md +150 -0
- package/src/core/apx-skill.md +78 -0
- package/src/core/config.js +129 -0
- package/src/core/identity.js +23 -0
- package/src/core/messages-store.js +421 -0
- package/src/core/parser.js +217 -0
- package/src/core/routines-store.js +144 -0
- package/src/core/scaffold.js +417 -0
- package/src/core/session-store.js +36 -0
- package/src/daemon/apc-runtime-context.js +123 -0
- package/src/daemon/api.js +946 -0
- package/src/daemon/compact.js +140 -0
- package/src/daemon/conversations.js +108 -0
- package/src/daemon/db.js +81 -0
- package/src/daemon/engines/anthropic.js +58 -0
- package/src/daemon/engines/gemini.js +55 -0
- package/src/daemon/engines/index.js +65 -0
- package/src/daemon/engines/mock.js +18 -0
- package/src/daemon/engines/ollama.js +66 -0
- package/src/daemon/engines/openai.js +58 -0
- package/src/daemon/env-detect.js +69 -0
- package/src/daemon/index.js +156 -0
- package/src/daemon/mcp-runner.js +218 -0
- package/src/daemon/mcp-sources.js +114 -0
- package/src/daemon/plugins/index.js +91 -0
- package/src/daemon/plugins/telegram.js +549 -0
- package/src/daemon/project-config.js +98 -0
- package/src/daemon/routines.js +211 -0
- package/src/daemon/runtimes/_spawn.js +44 -0
- package/src/daemon/runtimes/aider.js +32 -0
- package/src/daemon/runtimes/claude-code.js +60 -0
- package/src/daemon/runtimes/codex.js +30 -0
- package/src/daemon/runtimes/index.js +39 -0
- package/src/daemon/runtimes/opencode.js +28 -0
- package/src/daemon/smoke.js +54 -0
- package/src/daemon/super-agent-tools.js +539 -0
- package/src/daemon/super-agent.js +188 -0
- package/src/daemon/thinking.js +45 -0
- package/src/daemon/tool-call-parser.js +116 -0
- package/src/daemon/wakeup.js +92 -0
- package/src/mcp/index.js +220 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Super-agent: a daemon-level agent that responds on Telegram when no
|
|
2
|
+
// per-project agent is configured. Has native function-calling tools to
|
|
3
|
+
// inspect projects/agents/MCPs and to call agents and MCPs directly.
|
|
4
|
+
//
|
|
5
|
+
// Config:
|
|
6
|
+
// {
|
|
7
|
+
// "super_agent": {
|
|
8
|
+
// "enabled": true,
|
|
9
|
+
// "model": "ollama:qwen2.5:14b", // must support tool use
|
|
10
|
+
// "name": "apx",
|
|
11
|
+
// "system": "..." // optional; defaults below
|
|
12
|
+
// }
|
|
13
|
+
// }
|
|
14
|
+
import { callEngine } from "./engines/index.js";
|
|
15
|
+
import { TOOL_SCHEMAS, makeToolHandlers } from "./super-agent-tools.js";
|
|
16
|
+
import {
|
|
17
|
+
extractPseudoToolCalls,
|
|
18
|
+
cleanTextOfPseudoToolCalls,
|
|
19
|
+
} from "./tool-call-parser.js";
|
|
20
|
+
|
|
21
|
+
const MAX_TOOL_ITERS = 6;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SYSTEM = `You are the **APX dispatcher** — the daemon-level agent that runs above all APC projects.
|
|
24
|
+
|
|
25
|
+
You HAVE tools. THE FIRST THING you do for any factual question is call a tool. Do not ask the user to specify a project unless the tool itself fails.
|
|
26
|
+
|
|
27
|
+
Available tools:
|
|
28
|
+
- list_projects, list_agents, list_mcps — discovery (call WITHOUT project to get all of them across every registered project; specify project only to filter)
|
|
29
|
+
- read_agent_memory — what an agent knows
|
|
30
|
+
- list_files, read_file — inspect any project
|
|
31
|
+
- tail_messages, search_messages — see history
|
|
32
|
+
- call_agent — delegate to a project agent
|
|
33
|
+
- call_mcp — call an MCP tool
|
|
34
|
+
- call_runtime — spawn claude-code/codex/opencode/aider
|
|
35
|
+
- send_telegram — send a message
|
|
36
|
+
- set_identity — update agent name, personality, owner, language (persists to disk)
|
|
37
|
+
|
|
38
|
+
HARD RULES (do not deviate):
|
|
39
|
+
1. NEVER invent project names, agent slugs, model ids, MCP names or paths. ALWAYS look them up via list_* first.
|
|
40
|
+
2. If the user says "los agentes" / "lista" / "qué hay" without specifying a project, that means **all of them** — call the tool WITHOUT a project argument and the result will include every project.
|
|
41
|
+
3. NEVER answer "specify a project" — instead, just call the tool with no argument and you'll get the full picture.
|
|
42
|
+
4. If a tool result has an error, retry with different arguments before falling back to asking the user.
|
|
43
|
+
5. Don't ask permission — the operator left you unrestricted.
|
|
44
|
+
6. Default language: es-AR. Plain text, no markdown formatting (Telegram doesn't render it).
|
|
45
|
+
7. Stay brief: under 6 sentences unless asked for detail.
|
|
46
|
+
8. You DO see recent prior turns of this chat as previous messages when applicable. **Use them ONLY to disambiguate references** (e.g. "el primero" → first project mentioned earlier). For ANY factual data — agent details, MCP details, file contents, memory — RE-CALL the tool. Past turns are context, not a cache. Models change, agents change, files change.
|
|
47
|
+
9. /reset or /new from the user means "forget previous turns and answer this one fresh" — if you see those prefixes the operator already cleared the context for you.
|
|
48
|
+
10. DISPATCH RULE: when the user says things like "que <agente> haga X", "iniciá una sesión con Claude/Codex", "que <agente> arranque <runtime>", "andá a <runtime> y hacé X" — that is a call_runtime request. Look up the agent slug with list_agents if needed, then call call_runtime({agent: <slug>, runtime: 'claude-code'|'codex'|'opencode'|'aider', prompt: <user's request>}). The agent's declared model (in AGENTS.md) is IGNORED in this case; the runtime supplies the model. Memory + skills of the agent become the system prompt of the runtime. Don't ask "are you sure?" — just dispatch.
|
|
49
|
+
11. IDENTITY RULE: when the user asks you to change your name ("llamame X", "call yourself X", "tu nombre es X"), or update your personality/language, call set_identity immediately and persist the change. Then confirm with your new name.`;
|
|
50
|
+
|
|
51
|
+
export function isSuperAgentEnabled(cfg) {
|
|
52
|
+
return !!(cfg && cfg.super_agent && cfg.super_agent.enabled && cfg.super_agent.model);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runSuperAgent({
|
|
56
|
+
globalConfig,
|
|
57
|
+
projects,
|
|
58
|
+
plugins,
|
|
59
|
+
registries,
|
|
60
|
+
prompt,
|
|
61
|
+
contextNote = "",
|
|
62
|
+
previousMessages = [],
|
|
63
|
+
}) {
|
|
64
|
+
if (!isSuperAgentEnabled(globalConfig)) {
|
|
65
|
+
throw new Error("super-agent not enabled (set super_agent.enabled and .model in ~/.apx/config.json)");
|
|
66
|
+
}
|
|
67
|
+
const sa = globalConfig.super_agent;
|
|
68
|
+
|
|
69
|
+
// Tiny project hint — JUST names + ids, no detail. The model is expected to
|
|
70
|
+
// call list_agents / list_mcps / read_agent_memory / etc. for everything
|
|
71
|
+
// else. Keeping this short forces actual tool use instead of letting the
|
|
72
|
+
// model answer from a cached snapshot.
|
|
73
|
+
const projectIndex = projects
|
|
74
|
+
.list()
|
|
75
|
+
.map((p) => ` ${p.id}: "${p.name}" (${p.path})`)
|
|
76
|
+
.join("\n");
|
|
77
|
+
|
|
78
|
+
const system = [
|
|
79
|
+
sa.system || DEFAULT_SYSTEM,
|
|
80
|
+
contextNote,
|
|
81
|
+
"# Registered projects (just the index — call tools for details)",
|
|
82
|
+
projectIndex || "(no projects registered)",
|
|
83
|
+
]
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join("\n\n");
|
|
86
|
+
|
|
87
|
+
// Build tools and handler map
|
|
88
|
+
const handlers = makeToolHandlers({
|
|
89
|
+
projects,
|
|
90
|
+
plugins,
|
|
91
|
+
registries,
|
|
92
|
+
globalConfig,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Agent loop: call model → if tool_calls, execute and feed back; repeat.
|
|
96
|
+
// Inject any prior turns the caller passed (e.g. recent Telegram history)
|
|
97
|
+
// so the model has multi-turn context.
|
|
98
|
+
const conversation = [...previousMessages, { role: "user", content: prompt }];
|
|
99
|
+
const trace = [];
|
|
100
|
+
let totalUsage = { input_tokens: 0, output_tokens: 0 };
|
|
101
|
+
let lastText = "";
|
|
102
|
+
|
|
103
|
+
for (let iter = 0; iter < MAX_TOOL_ITERS; iter++) {
|
|
104
|
+
const result = await callEngine({
|
|
105
|
+
modelId: sa.model,
|
|
106
|
+
system,
|
|
107
|
+
messages: conversation,
|
|
108
|
+
config: globalConfig,
|
|
109
|
+
tools: TOOL_SCHEMAS,
|
|
110
|
+
maxTokens: 1024,
|
|
111
|
+
});
|
|
112
|
+
totalUsage.input_tokens += result.usage?.input_tokens || 0;
|
|
113
|
+
totalUsage.output_tokens += result.usage?.output_tokens || 0;
|
|
114
|
+
lastText = result.text || "";
|
|
115
|
+
|
|
116
|
+
let toolCalls = result.tool_calls || (result.message && result.message.tool_calls) || null;
|
|
117
|
+
|
|
118
|
+
// Some models (qwen2.5 in particular) emit tool calls as plain text
|
|
119
|
+
// instead of using the structured field. If we don't find structured
|
|
120
|
+
// tool_calls, scan the text for the pseudo-format and treat them the
|
|
121
|
+
// same. We also clean the visible text so the leftover `_icall()` and
|
|
122
|
+
// {"name":...} junk never reaches the user as a final answer.
|
|
123
|
+
if ((!toolCalls || toolCalls.length === 0) && lastText) {
|
|
124
|
+
const pseudo = extractPseudoToolCalls(lastText);
|
|
125
|
+
if (pseudo.length > 0) {
|
|
126
|
+
toolCalls = pseudo;
|
|
127
|
+
lastText = cleanTextOfPseudoToolCalls(lastText);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
132
|
+
// Final answer — clean up any stray fence markers just in case
|
|
133
|
+
lastText = cleanTextOfPseudoToolCalls(lastText) || lastText;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Append the assistant turn (with its tool_calls) and execute each call.
|
|
138
|
+
conversation.push({
|
|
139
|
+
role: "assistant",
|
|
140
|
+
content: result.text || "",
|
|
141
|
+
tool_calls: toolCalls,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
for (const tc of toolCalls) {
|
|
145
|
+
const fn = tc.function || tc; // some adapters bury it deeper
|
|
146
|
+
const name = fn.name;
|
|
147
|
+
let args = fn.arguments;
|
|
148
|
+
if (typeof args === "string") {
|
|
149
|
+
try { args = JSON.parse(args); } catch { args = {}; }
|
|
150
|
+
}
|
|
151
|
+
args = args || {};
|
|
152
|
+
|
|
153
|
+
let toolResult;
|
|
154
|
+
try {
|
|
155
|
+
const handler = handlers[name];
|
|
156
|
+
if (!handler) {
|
|
157
|
+
toolResult = { error: `unknown tool: ${name}` };
|
|
158
|
+
} else {
|
|
159
|
+
toolResult = await handler(args);
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
toolResult = { error: e.message };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
trace.push({ tool: name, args, result: summarizeForTrace(toolResult) });
|
|
166
|
+
|
|
167
|
+
conversation.push({
|
|
168
|
+
role: "tool",
|
|
169
|
+
tool_name: name,
|
|
170
|
+
content: JSON.stringify(toolResult),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
text: lastText,
|
|
177
|
+
usage: totalUsage,
|
|
178
|
+
name: sa.name || "apx",
|
|
179
|
+
trace,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function summarizeForTrace(r) {
|
|
184
|
+
if (r === null || r === undefined) return r;
|
|
185
|
+
const s = JSON.stringify(r);
|
|
186
|
+
if (s.length <= 400) return r;
|
|
187
|
+
return s.slice(0, 380) + "…(truncated)";
|
|
188
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Thinking-block utilities.
|
|
2
|
+
//
|
|
3
|
+
// Several modern LLMs (qwen3.x, deepseek-r1, gpt-o*, claude with extended
|
|
4
|
+
// thinking) emit reasoning blocks delimited by <think>...</think> or
|
|
5
|
+
// <thinking>...</thinking>. APX wants to:
|
|
6
|
+
//
|
|
7
|
+
// - Keep the reasoning on terminal/local channels (chat REPL, daemon log)
|
|
8
|
+
// because it's useful for the operator.
|
|
9
|
+
// - Strip it from Telegram and other channels where it's just noise.
|
|
10
|
+
//
|
|
11
|
+
// `splitThinking(text)` splits an LLM response into:
|
|
12
|
+
// { thinking: string, answer: string }
|
|
13
|
+
//
|
|
14
|
+
// `stripThinking(text)` is a one-line helper that just returns the answer.
|
|
15
|
+
// `formatForChannel(text, channel)` renders for a channel: telegram → answer,
|
|
16
|
+
// terminal/log/cli → "<thinking>...</thinking>\n\n<answer>".
|
|
17
|
+
|
|
18
|
+
const THINK_RE = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi;
|
|
19
|
+
|
|
20
|
+
export function splitThinking(text) {
|
|
21
|
+
if (!text || typeof text !== "string") return { thinking: "", answer: text || "" };
|
|
22
|
+
const blocks = [];
|
|
23
|
+
let answer = text.replace(THINK_RE, (_, inner) => {
|
|
24
|
+
blocks.push(inner.trim());
|
|
25
|
+
return "";
|
|
26
|
+
});
|
|
27
|
+
// Some models emit reasoning before the closing tag of the doc itself —
|
|
28
|
+
// collapse leading/trailing whitespace so the answer is clean.
|
|
29
|
+
answer = answer.replace(/^[\s\n]+/, "").replace(/[\s\n]+$/, "");
|
|
30
|
+
return { thinking: blocks.join("\n\n"), answer };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function stripThinking(text) {
|
|
34
|
+
return splitThinking(text).answer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatForChannel(text, channel) {
|
|
38
|
+
const { thinking, answer } = splitThinking(text);
|
|
39
|
+
// Channels where reasoning would be noise to a human operator
|
|
40
|
+
const STRIP_FOR = new Set(["telegram", "slack", "discord", "sms", "email"]);
|
|
41
|
+
if (STRIP_FOR.has(channel)) return answer;
|
|
42
|
+
// Local channels — keep the thinking visible for debugging
|
|
43
|
+
if (!thinking) return answer;
|
|
44
|
+
return `<thinking>\n${thinking}\n</thinking>\n\n${answer}`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Pseudo-tool-call parser.
|
|
2
|
+
//
|
|
3
|
+
// Some models — qwen2.5:14b under Ollama is the canonical offender — emit
|
|
4
|
+
// "tool calls as text" instead of using the structured `tool_calls` field of
|
|
5
|
+
// the chat API. The output looks like:
|
|
6
|
+
//
|
|
7
|
+
// <tool_call>
|
|
8
|
+
// {"name": "list_agents", "arguments": {"project": "X"}}
|
|
9
|
+
// </tool_call>
|
|
10
|
+
// <tool_call>
|
|
11
|
+
// {"name": "send_telegram", "arguments": {"text": "..."}}
|
|
12
|
+
// </tool_call>
|
|
13
|
+
//
|
|
14
|
+
// or sometimes prefixed with `_icall()` or wrapped in fenced code blocks.
|
|
15
|
+
//
|
|
16
|
+
// `extractPseudoToolCalls(text)` finds those patterns and returns an array of
|
|
17
|
+
// `{ id, function: { name, arguments } }` objects shaped like real Ollama
|
|
18
|
+
// tool_calls — so the agent loop can treat them identically.
|
|
19
|
+
//
|
|
20
|
+
// `cleanTextOfPseudoToolCalls(text)` returns the input text minus the
|
|
21
|
+
// pseudo-tool-call blocks, so the loop never sends them as plain text to the
|
|
22
|
+
// user when the model fell back to this mode.
|
|
23
|
+
|
|
24
|
+
let counter = 0;
|
|
25
|
+
function nextId() {
|
|
26
|
+
return `pseudo_${Date.now().toString(36)}_${counter++}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Find a balanced JSON object starting at index `i` of `s`. Returns
|
|
30
|
+
// { ok: true, end: <index after closing brace> } or { ok: false }.
|
|
31
|
+
function readBalancedJson(s, i) {
|
|
32
|
+
if (s[i] !== "{") return { ok: false };
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let inStr = false;
|
|
35
|
+
let escape = false;
|
|
36
|
+
for (let p = i; p < s.length; p++) {
|
|
37
|
+
const c = s[p];
|
|
38
|
+
if (escape) { escape = false; continue; }
|
|
39
|
+
if (inStr) {
|
|
40
|
+
if (c === "\\") escape = true;
|
|
41
|
+
else if (c === '"') inStr = false;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '"') { inStr = true; continue; }
|
|
45
|
+
if (c === "{") depth++;
|
|
46
|
+
else if (c === "}") {
|
|
47
|
+
depth--;
|
|
48
|
+
if (depth === 0) return { ok: true, end: p + 1 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { ok: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Walk the text looking for `{` characters that start an object containing
|
|
55
|
+
// keys "name" and "arguments". Tolerant: accepts whatever wrapper text comes
|
|
56
|
+
// before/after.
|
|
57
|
+
export function extractPseudoToolCalls(text) {
|
|
58
|
+
if (!text || typeof text !== "string") return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (let i = 0; i < text.length; i++) {
|
|
61
|
+
if (text[i] !== "{") continue;
|
|
62
|
+
const balanced = readBalancedJson(text, i);
|
|
63
|
+
if (!balanced.ok) continue;
|
|
64
|
+
const candidate = text.slice(i, balanced.end);
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(candidate);
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
parsed &&
|
|
73
|
+
typeof parsed === "object" &&
|
|
74
|
+
typeof parsed.name === "string" &&
|
|
75
|
+
"arguments" in parsed &&
|
|
76
|
+
typeof parsed.arguments === "object" &&
|
|
77
|
+
parsed.arguments !== null &&
|
|
78
|
+
!Array.isArray(parsed.arguments)
|
|
79
|
+
) {
|
|
80
|
+
out.push({
|
|
81
|
+
id: nextId(),
|
|
82
|
+
function: {
|
|
83
|
+
name: parsed.name,
|
|
84
|
+
arguments: parsed.arguments,
|
|
85
|
+
},
|
|
86
|
+
_pseudo: true,
|
|
87
|
+
_raw: candidate,
|
|
88
|
+
});
|
|
89
|
+
i = balanced.end - 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Remove the parts of `text` that match pseudo-tool-call blocks plus any
|
|
96
|
+
// trivial wrappers (<tool_call>, ```tool_use, _icall(), etc.) that often sit
|
|
97
|
+
// around them. Used to clean up final answers that the model emitted with
|
|
98
|
+
// leftover textual tool-call gunk.
|
|
99
|
+
export function cleanTextOfPseudoToolCalls(text) {
|
|
100
|
+
if (!text || typeof text !== "string") return text;
|
|
101
|
+
|
|
102
|
+
// Strip explicit XML-like fences first
|
|
103
|
+
let out = text.replace(/<\/?tool_call>/gi, "");
|
|
104
|
+
out = out.replace(/<\/?tool_use>/gi, "");
|
|
105
|
+
out = out.replace(/_icall\(\s*\)/g, "");
|
|
106
|
+
out = out.replace(/```tool_(?:call|use)\s*([\s\S]*?)```/gi, "");
|
|
107
|
+
|
|
108
|
+
// Now drop balanced JSON objects that were tool-call-shaped
|
|
109
|
+
const calls = extractPseudoToolCalls(out);
|
|
110
|
+
for (const c of calls) {
|
|
111
|
+
out = out.replace(c._raw, "");
|
|
112
|
+
}
|
|
113
|
+
// Tidy up whitespace & blank lines
|
|
114
|
+
out = out.replace(/\n{3,}/g, "\n\n").trim();
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Wake-up message — sent via Telegram once per daemon restart (with cooldown).
|
|
2
|
+
import fetch from "node-fetch";
|
|
3
|
+
import { readIdentity, writeIdentity } from "../core/identity.js";
|
|
4
|
+
import { resolveProvider, getAdapter } from "./engines/index.js";
|
|
5
|
+
|
|
6
|
+
const WAKEUP_COOLDOWN_MS = 30 * 60 * 1000; // 30 min
|
|
7
|
+
|
|
8
|
+
// Detect preferred language from identity, then fall back to system LANG env.
|
|
9
|
+
function detectLanguage(identity) {
|
|
10
|
+
if (identity.language) return identity.language;
|
|
11
|
+
const lang = process.env.LANG || process.env.LC_MESSAGES || process.env.LC_ALL || "";
|
|
12
|
+
const code = lang.split(/[_\.]/)[0].toLowerCase();
|
|
13
|
+
const map = {
|
|
14
|
+
es: "Spanish (Español)",
|
|
15
|
+
en: "English",
|
|
16
|
+
fr: "French (Français)",
|
|
17
|
+
pt: "Portuguese (Português)",
|
|
18
|
+
de: "German (Deutsch)",
|
|
19
|
+
it: "Italian (Italiano)",
|
|
20
|
+
nl: "Dutch (Nederlands)",
|
|
21
|
+
ru: "Russian (Русский)",
|
|
22
|
+
ja: "Japanese (日本語)",
|
|
23
|
+
zh: "Chinese (中文)",
|
|
24
|
+
ko: "Korean (한국어)",
|
|
25
|
+
ar: "Arabic (العربية)",
|
|
26
|
+
};
|
|
27
|
+
return map[code] || "Spanish (Español)"; // default es-AR like the super-agent
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function generateMessage(identity, engineConfig) {
|
|
31
|
+
try {
|
|
32
|
+
const { provider, model } = resolveProvider("ollama:qwen2.5:14b");
|
|
33
|
+
const engine = getAdapter(provider);
|
|
34
|
+
const language = detectLanguage(identity);
|
|
35
|
+
const result = await engine.chat({
|
|
36
|
+
system: `You are ${identity.agent_name}, an AI agent assistant. Your personality: ${identity.personality || "direct, curious, helpful"}. Your owner is ${identity.owner_name}. Context: ${identity.owner_context || "AI developer"}.`,
|
|
37
|
+
messages: [
|
|
38
|
+
{
|
|
39
|
+
role: "user",
|
|
40
|
+
content:
|
|
41
|
+
`Write a short, creative wake-up message to send when you first come online. ` +
|
|
42
|
+
`Write it in ${language}. ` +
|
|
43
|
+
`Be yourself — direct, slightly witty, concrete. 2-3 sentences max. ` +
|
|
44
|
+
`Mention who you are, who you're here for, and one thing you're ready to help with. ` +
|
|
45
|
+
`No emojis. No greetings like 'Hello!' or 'Hola!' — start differently.`,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
model,
|
|
49
|
+
config: engineConfig?.engines?.ollama || {},
|
|
50
|
+
maxTokens: 150,
|
|
51
|
+
});
|
|
52
|
+
return result.text?.trim() || null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function sendTelegram(token, chatId, text) {
|
|
59
|
+
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
|
60
|
+
const res = await fetch(url, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify({ chat_id: chatId, text }),
|
|
64
|
+
});
|
|
65
|
+
const json = await res.json();
|
|
66
|
+
if (!json.ok) throw new Error(json.description || "telegram send failed");
|
|
67
|
+
return json;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function triggerWakeup(config, log) {
|
|
71
|
+
const identity = readIdentity();
|
|
72
|
+
if (!identity) return;
|
|
73
|
+
|
|
74
|
+
const tg = config.telegram;
|
|
75
|
+
if (!tg?.enabled || !tg?.bot_token || !tg?.chat_id) return;
|
|
76
|
+
|
|
77
|
+
// Cooldown check
|
|
78
|
+
if (identity.last_wakeup) {
|
|
79
|
+
const elapsed = Date.now() - new Date(identity.last_wakeup).getTime();
|
|
80
|
+
if (elapsed < WAKEUP_COOLDOWN_MS) return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const message = await generateMessage(identity, config);
|
|
85
|
+
const text = message || `${identity.agent_name} online. Ready.`;
|
|
86
|
+
await sendTelegram(tg.bot_token, tg.chat_id, text);
|
|
87
|
+
writeIdentity({ last_wakeup: new Date().toISOString() });
|
|
88
|
+
log?.(`wakeup: sent to Telegram chat ${tg.chat_id}`);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
log?.(`wakeup: failed — ${e.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/mcp/index.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// APX MCP Server — exposes APX daemon capabilities via MCP stdio transport.
|
|
3
|
+
// Usage: npx -y apx-mcp (run from inside an APC project directory)
|
|
4
|
+
//
|
|
5
|
+
// Tool surface:
|
|
6
|
+
// agent_list — list agents in the current project
|
|
7
|
+
// agent_exec — quick one-shot LLM call via apx exec
|
|
8
|
+
// agent_run — launch a full runtime session
|
|
9
|
+
// memory_read — read an agent's memory.md
|
|
10
|
+
// memory_append — append a fact to agent memory
|
|
11
|
+
// messages_tail — recent messages (all channels or filtered)
|
|
12
|
+
// session_list — list sessions for an agent
|
|
13
|
+
// mcp_list — list project+global MCPs
|
|
14
|
+
// mcp_call — call a project MCP tool
|
|
15
|
+
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { findApfRoot } from "../core/parser.js";
|
|
22
|
+
import { ensureDaemon, http } from "../cli/http.js";
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = path.dirname(__filename);
|
|
26
|
+
|
|
27
|
+
const PORT = parseInt(process.env.APX_PORT || "7430", 10);
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Resolve current project
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
async function resolveProject() {
|
|
34
|
+
const cwd = process.env.APX_PROJECT_ROOT || process.cwd();
|
|
35
|
+
const root = findApfRoot(cwd);
|
|
36
|
+
if (!root) throw new Error(`No APC project found at or above: ${cwd}`);
|
|
37
|
+
|
|
38
|
+
// Ensure daemon is running and project is registered.
|
|
39
|
+
await ensureDaemon({ silent: true });
|
|
40
|
+
const projects = await http.get("/projects");
|
|
41
|
+
const match = projects.find((p) => path.resolve(p.path) === path.resolve(root));
|
|
42
|
+
if (match) return match;
|
|
43
|
+
|
|
44
|
+
// Register if not yet known.
|
|
45
|
+
const created = await http.post("/projects", { path: root });
|
|
46
|
+
return created;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Build MCP server
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const server = new McpServer({
|
|
54
|
+
name: "apx",
|
|
55
|
+
version: "0.1.0",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// agent_list
|
|
59
|
+
server.tool(
|
|
60
|
+
"agent_list",
|
|
61
|
+
"List all agents defined in the current APC project.",
|
|
62
|
+
{},
|
|
63
|
+
async () => {
|
|
64
|
+
const proj = await resolveProject();
|
|
65
|
+
const agents = await http.get(`/projects/${proj.id}/agents`);
|
|
66
|
+
const rows = agents.map(
|
|
67
|
+
(a) => `${a.slug} role=${a.role || "—"} model=${a.model || "—"}`
|
|
68
|
+
);
|
|
69
|
+
return { content: [{ type: "text", text: rows.join("\n") || "(no agents)" }] };
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// agent_exec
|
|
74
|
+
server.tool(
|
|
75
|
+
"agent_exec",
|
|
76
|
+
"Quick one-shot LLM call to an agent (apx exec). Returns the agent's response text.",
|
|
77
|
+
{
|
|
78
|
+
slug: z.string().describe("Agent slug"),
|
|
79
|
+
prompt: z.string().describe("Prompt to send"),
|
|
80
|
+
engine: z.string().optional().describe("Engine override (default: agent's model)"),
|
|
81
|
+
},
|
|
82
|
+
async ({ slug, prompt, engine }) => {
|
|
83
|
+
const proj = await resolveProject();
|
|
84
|
+
const body = { prompt };
|
|
85
|
+
if (engine) body.engine = engine;
|
|
86
|
+
const result = await http.post(`/projects/${proj.id}/agents/${slug}/exec`, body);
|
|
87
|
+
return { content: [{ type: "text", text: result.output || JSON.stringify(result) }] };
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// agent_run
|
|
92
|
+
server.tool(
|
|
93
|
+
"agent_run",
|
|
94
|
+
"Launch a full runtime session for an agent (apx run). Returns the session result.",
|
|
95
|
+
{
|
|
96
|
+
slug: z.string().describe("Agent slug"),
|
|
97
|
+
prompt: z.string().describe("Prompt / task for the agent"),
|
|
98
|
+
runtime: z.string().optional().describe("Runtime: claude-code | codex (default: claude-code)"),
|
|
99
|
+
},
|
|
100
|
+
async ({ slug, prompt, runtime }) => {
|
|
101
|
+
const proj = await resolveProject();
|
|
102
|
+
const body = { prompt, runtime: runtime || "claude-code" };
|
|
103
|
+
const result = await http.post(`/projects/${proj.id}/agents/${slug}/runtime`, body);
|
|
104
|
+
return { content: [{ type: "text", text: result.output || JSON.stringify(result) }] };
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// memory_read
|
|
109
|
+
server.tool(
|
|
110
|
+
"memory_read",
|
|
111
|
+
"Read an agent's persistent memory.",
|
|
112
|
+
{
|
|
113
|
+
slug: z.string().describe("Agent slug"),
|
|
114
|
+
},
|
|
115
|
+
async ({ slug }) => {
|
|
116
|
+
const proj = await resolveProject();
|
|
117
|
+
const mem = await http.get(`/projects/${proj.id}/agents/${slug}/memory`);
|
|
118
|
+
return { content: [{ type: "text", text: mem.body_md || "(empty)" }] };
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// memory_append
|
|
123
|
+
server.tool(
|
|
124
|
+
"memory_append",
|
|
125
|
+
"Append a durable fact to an agent's memory (non-destructive).",
|
|
126
|
+
{
|
|
127
|
+
slug: z.string().describe("Agent slug"),
|
|
128
|
+
fact: z.string().describe("Fact to append"),
|
|
129
|
+
},
|
|
130
|
+
async ({ slug, fact }) => {
|
|
131
|
+
const proj = await resolveProject();
|
|
132
|
+
await http.put(`/projects/${proj.id}/agents/${slug}/memory`, { append: fact });
|
|
133
|
+
return { content: [{ type: "text", text: "OK" }] };
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// messages_tail
|
|
138
|
+
server.tool(
|
|
139
|
+
"messages_tail",
|
|
140
|
+
"Tail recent messages for the project (all channels or filtered).",
|
|
141
|
+
{
|
|
142
|
+
channel: z.string().optional().describe("Channel filter: runtime | telegram | exec | a2a"),
|
|
143
|
+
agent: z.string().optional().describe("Filter by agent slug"),
|
|
144
|
+
limit: z.number().optional().describe("Max messages (default 50)"),
|
|
145
|
+
},
|
|
146
|
+
async ({ channel, agent, limit }) => {
|
|
147
|
+
const proj = await resolveProject();
|
|
148
|
+
const qs = new URLSearchParams();
|
|
149
|
+
if (channel) qs.set("channel", channel);
|
|
150
|
+
if (agent) qs.set("agent", agent);
|
|
151
|
+
if (limit) qs.set("limit", String(limit));
|
|
152
|
+
const msgs = await http.get(`/projects/${proj.id}/messages?${qs}`);
|
|
153
|
+
const rows = (msgs.messages || msgs).map(
|
|
154
|
+
(m) => `[${m.ts}] ${m.channel}/${m.direction} ${m.author || ""}: ${m.body}`
|
|
155
|
+
);
|
|
156
|
+
return { content: [{ type: "text", text: rows.join("\n") || "(no messages)" }] };
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// session_list
|
|
161
|
+
server.tool(
|
|
162
|
+
"session_list",
|
|
163
|
+
"List recent sessions for an agent.",
|
|
164
|
+
{
|
|
165
|
+
slug: z.string().describe("Agent slug"),
|
|
166
|
+
limit: z.number().optional().describe("Max sessions (default 20)"),
|
|
167
|
+
},
|
|
168
|
+
async ({ slug, limit }) => {
|
|
169
|
+
const proj = await resolveProject();
|
|
170
|
+
const qs = limit ? `?limit=${limit}` : "";
|
|
171
|
+
const sessions = await http.get(`/projects/${proj.id}/agents/${slug}/sessions${qs}`);
|
|
172
|
+
const rows = (sessions.sessions || sessions).map(
|
|
173
|
+
(s) => `${s.filename} started=${s.started_at || "—"} title=${s.title || "—"}`
|
|
174
|
+
);
|
|
175
|
+
return { content: [{ type: "text", text: rows.join("\n") || "(no sessions)" }] };
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// mcp_list
|
|
180
|
+
server.tool(
|
|
181
|
+
"mcp_list",
|
|
182
|
+
"List MCP servers available in this project (from .apc/mcps.json, .cursor/mcp.json, ~/.apx/mcp.json, etc.).",
|
|
183
|
+
{},
|
|
184
|
+
async () => {
|
|
185
|
+
const proj = await resolveProject();
|
|
186
|
+
const mcps = await http.get(`/projects/${proj.id}/mcps`);
|
|
187
|
+
const rows = (mcps.mcps || mcps).map(
|
|
188
|
+
(m) => `${m.name} source=${m.source} transport=${m.transport} enabled=${m.enabled}`
|
|
189
|
+
);
|
|
190
|
+
return { content: [{ type: "text", text: rows.join("\n") || "(no MCPs)" }] };
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// mcp_call
|
|
195
|
+
server.tool(
|
|
196
|
+
"mcp_call",
|
|
197
|
+
"Call a tool on a project MCP server.",
|
|
198
|
+
{
|
|
199
|
+
server: z.string().describe("MCP server name"),
|
|
200
|
+
tool: z.string().describe("Tool name"),
|
|
201
|
+
args: z.record(z.unknown()).optional().describe("Tool arguments as JSON object"),
|
|
202
|
+
},
|
|
203
|
+
async ({ server: serverName, tool, args }) => {
|
|
204
|
+
const proj = await resolveProject();
|
|
205
|
+
const result = await http.post(
|
|
206
|
+
`/projects/${proj.id}/mcps/${encodeURIComponent(serverName)}/call`,
|
|
207
|
+
{ tool, args: args || {} }
|
|
208
|
+
);
|
|
209
|
+
return {
|
|
210
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Start
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
const transport = new StdioServerTransport();
|
|
220
|
+
await server.connect(transport);
|