@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/package.json +52 -0
  4. package/skills/apx/SKILL.md +77 -0
  5. package/src/cli/commands/a2a.js +66 -0
  6. package/src/cli/commands/agent.js +181 -0
  7. package/src/cli/commands/chat.js +84 -0
  8. package/src/cli/commands/command.js +42 -0
  9. package/src/cli/commands/config.js +56 -0
  10. package/src/cli/commands/daemon.js +148 -0
  11. package/src/cli/commands/exec.js +56 -0
  12. package/src/cli/commands/identity.js +146 -0
  13. package/src/cli/commands/init.js +23 -0
  14. package/src/cli/commands/mcp.js +147 -0
  15. package/src/cli/commands/memory.js +69 -0
  16. package/src/cli/commands/messages.js +61 -0
  17. package/src/cli/commands/plugins.js +23 -0
  18. package/src/cli/commands/project.js +124 -0
  19. package/src/cli/commands/routine.js +99 -0
  20. package/src/cli/commands/runtime.js +64 -0
  21. package/src/cli/commands/session.js +387 -0
  22. package/src/cli/commands/skills.js +153 -0
  23. package/src/cli/commands/telegram.js +48 -0
  24. package/src/cli/http.js +102 -0
  25. package/src/cli/index.js +481 -0
  26. package/src/cli/postinstall.js +25 -0
  27. package/src/core/apc-context-skill.md +150 -0
  28. package/src/core/apx-skill.md +78 -0
  29. package/src/core/config.js +129 -0
  30. package/src/core/identity.js +23 -0
  31. package/src/core/messages-store.js +421 -0
  32. package/src/core/parser.js +217 -0
  33. package/src/core/routines-store.js +144 -0
  34. package/src/core/scaffold.js +417 -0
  35. package/src/core/session-store.js +36 -0
  36. package/src/daemon/apc-runtime-context.js +123 -0
  37. package/src/daemon/api.js +946 -0
  38. package/src/daemon/compact.js +140 -0
  39. package/src/daemon/conversations.js +108 -0
  40. package/src/daemon/db.js +81 -0
  41. package/src/daemon/engines/anthropic.js +58 -0
  42. package/src/daemon/engines/gemini.js +55 -0
  43. package/src/daemon/engines/index.js +65 -0
  44. package/src/daemon/engines/mock.js +18 -0
  45. package/src/daemon/engines/ollama.js +66 -0
  46. package/src/daemon/engines/openai.js +58 -0
  47. package/src/daemon/env-detect.js +69 -0
  48. package/src/daemon/index.js +156 -0
  49. package/src/daemon/mcp-runner.js +218 -0
  50. package/src/daemon/mcp-sources.js +114 -0
  51. package/src/daemon/plugins/index.js +91 -0
  52. package/src/daemon/plugins/telegram.js +549 -0
  53. package/src/daemon/project-config.js +98 -0
  54. package/src/daemon/routines.js +211 -0
  55. package/src/daemon/runtimes/_spawn.js +44 -0
  56. package/src/daemon/runtimes/aider.js +32 -0
  57. package/src/daemon/runtimes/claude-code.js +60 -0
  58. package/src/daemon/runtimes/codex.js +30 -0
  59. package/src/daemon/runtimes/index.js +39 -0
  60. package/src/daemon/runtimes/opencode.js +28 -0
  61. package/src/daemon/smoke.js +54 -0
  62. package/src/daemon/super-agent-tools.js +539 -0
  63. package/src/daemon/super-agent.js +188 -0
  64. package/src/daemon/thinking.js +45 -0
  65. package/src/daemon/tool-call-parser.js +116 -0
  66. package/src/daemon/wakeup.js +92 -0
  67. package/src/mcp/index.js +220 -0
@@ -0,0 +1,140 @@
1
+ // Conversation compaction: collapses a long conversation into a dense summary
2
+ // turn, preserving the last N turns for immediate context.
3
+ //
4
+ // On disk, a compacted file looks like:
5
+ //
6
+ // ---
7
+ // ...frontmatter...
8
+ // status: compacted
9
+ // compacted_at: 2026-05-08T12:00:00Z
10
+ // compacted_turns: 47
11
+ // ---
12
+ //
13
+ // ## compact — 2026-05-08T12:00:00Z
14
+ // [Compacted 47 turns on 2026-05-08T12:00:00Z]
15
+ //
16
+ // <dense summary here>
17
+ //
18
+ // ## user — ... ← last KEEP_LAST turns kept verbatim
19
+ // ...
20
+ //
21
+ // When the chat endpoint reads a compacted conversation it injects the compact
22
+ // block into the system prompt (not into messages[]), so the model has context
23
+ // without burning tokens on old exchanges.
24
+
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { parseConversation } from "./conversations.js";
28
+ import { callEngine } from "./engines/index.js";
29
+
30
+ const KEEP_LAST = 6;
31
+
32
+ const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
33
+
34
+ const COMPACT_SYSTEM =
35
+ "You summarize conversations for AI agent context continuity. " +
36
+ "Be dense and factual — another AI will read this to continue the work.";
37
+
38
+ const COMPACT_PROMPT = `Summarize this conversation for future context.
39
+
40
+ Cover:
41
+ - Main task or goal being worked on
42
+ - Key decisions made and why
43
+ - Files, code, commands modified (exact paths where relevant)
44
+ - Current state: what's done, what's pending or unresolved
45
+ - Errors encountered and how they were resolved
46
+
47
+ Style: dense and factual. No pleasantries. No meta-commentary. Just the facts.
48
+
49
+ ---
50
+
51
+ `;
52
+
53
+ // Resolve the most-recent conversation file for an agent, or the one explicitly
54
+ // named. Returns the full filepath.
55
+ function resolveConvFile(projectRoot, agentSlug, filename) {
56
+ const dir = path.join(projectRoot, ".apc", "agents", agentSlug, "conversations");
57
+ if (!fs.existsSync(dir)) throw new Error(`no conversations dir for agent "${agentSlug}"`);
58
+
59
+ if (filename) {
60
+ const f = filename.endsWith(".md") ? filename : `${filename}.md`;
61
+ const p = path.join(dir, f);
62
+ if (!fs.existsSync(p)) throw new Error(`conversation not found: ${f}`);
63
+ return p;
64
+ }
65
+
66
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
67
+ if (!files.length) throw new Error(`no conversations found for agent "${agentSlug}"`);
68
+ return path.join(dir, files[files.length - 1]);
69
+ }
70
+
71
+ // Rebuild frontmatter string from a plain object.
72
+ function serializeFm(obj) {
73
+ return Object.entries(obj)
74
+ .map(([k, v]) => `${k}: ${v ?? ""}`)
75
+ .join("\n");
76
+ }
77
+
78
+ export async function compactConversation({
79
+ projectRoot,
80
+ agentSlug,
81
+ filename,
82
+ modelId,
83
+ config,
84
+ }) {
85
+ const filepath = resolveConvFile(projectRoot, agentSlug, filename);
86
+ const raw = fs.readFileSync(filepath, "utf8");
87
+ const { fm, turns } = parseConversation(raw);
88
+
89
+ // Exclude any existing compact markers from the turn count / transcript.
90
+ const realTurns = turns.filter((t) => t.role !== "compact");
91
+ if (realTurns.length === 0) throw new Error("nothing to compact — no user/assistant turns");
92
+
93
+ // Build a readable transcript for the model.
94
+ const transcript = realTurns
95
+ .map((t) => `[${t.role.toUpperCase()}]\n${t.content}`)
96
+ .join("\n\n---\n\n");
97
+
98
+ const result = await callEngine({
99
+ modelId,
100
+ system: COMPACT_SYSTEM,
101
+ messages: [{ role: "user", content: COMPACT_PROMPT + transcript }],
102
+ config,
103
+ });
104
+
105
+ const summary = result.text.trim();
106
+ const ts = nowIso();
107
+ const turnCount = realTurns.length;
108
+
109
+ // Keep the last N real turns verbatim for immediate context.
110
+ const recentTurns = realTurns.slice(-KEEP_LAST);
111
+
112
+ const updatedFm = {
113
+ ...fm,
114
+ status: "compacted",
115
+ compacted_at: ts,
116
+ compacted_turns: turnCount,
117
+ last_turn: ts,
118
+ };
119
+
120
+ const compactBlock =
121
+ `## compact — ${ts}\n` +
122
+ `[Compacted ${turnCount} turns on ${ts}]\n\n` +
123
+ `${summary}\n\n`;
124
+
125
+ const recentBlocks = recentTurns
126
+ .map((t) => `## ${t.role} — ${t.ts}\n${t.content}\n\n`)
127
+ .join("");
128
+
129
+ const newContent = `---\n${serializeFm(updatedFm)}\n---\n\n${compactBlock}${recentBlocks}`;
130
+ fs.writeFileSync(filepath, newContent);
131
+
132
+ return {
133
+ filename: path.basename(filepath),
134
+ compacted_turns: turnCount,
135
+ kept_turns: recentTurns.length,
136
+ model: modelId,
137
+ summary,
138
+ usage: result.usage,
139
+ };
140
+ }
@@ -0,0 +1,108 @@
1
+ // Conversation storage: append-only markdown at .apc/agents/<slug>/conversations/
2
+ // with SQLite mirror for fast querying. Filesystem is source of truth.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
8
+
9
+ export function generateConversationId(projectRoot, agentSlug) {
10
+ const today = new Date().toISOString().slice(0, 10);
11
+ const dir = path.join(projectRoot, ".apc", "agents", agentSlug, "conversations");
12
+ let next = 1;
13
+ if (fs.existsSync(dir)) {
14
+ for (const f of fs.readdirSync(dir)) {
15
+ const m = f.match(new RegExp(`^${today}-(\\d{2,})\\.md$`));
16
+ if (m) {
17
+ const n = parseInt(m[1], 10);
18
+ if (n + 1 > next) next = n + 1;
19
+ }
20
+ }
21
+ }
22
+ return `${today}-${String(next).padStart(2, "0")}`;
23
+ }
24
+
25
+ export function conversationPath(projectRoot, agentSlug, idOrFilename) {
26
+ const filename = idOrFilename.endsWith(".md") ? idOrFilename : `${idOrFilename}.md`;
27
+ return path.join(projectRoot, ".apc", "agents", agentSlug, "conversations", filename);
28
+ }
29
+
30
+ export function startConversation({ projectRoot, agentSlug, engine, system }) {
31
+ const dir = path.join(projectRoot, ".apc", "agents", agentSlug, "conversations");
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const id = generateConversationId(projectRoot, agentSlug);
34
+ const file = path.join(dir, `${id}.md`);
35
+ const started = nowIso();
36
+ const fm =
37
+ `---\n` +
38
+ `id: ${id}\n` +
39
+ `agent: ${agentSlug}\n` +
40
+ `engine: ${engine}\n` +
41
+ `started: ${started}\n` +
42
+ `last_turn: \n` +
43
+ `status: open\n` +
44
+ `---\n\n` +
45
+ (system ? `## system — ${started}\n${system}\n\n` : "");
46
+ fs.writeFileSync(file, fm);
47
+ return { id, filename: `${id}.md`, path: file, started };
48
+ }
49
+
50
+ export function appendTurn({ filePath, role, content }) {
51
+ const ts = nowIso();
52
+ const block = `## ${role} — ${ts}\n${content}\n\n`;
53
+ fs.appendFileSync(filePath, block);
54
+ // Update last_turn in frontmatter (in-place)
55
+ let text = fs.readFileSync(filePath, "utf8");
56
+ text = text.replace(/^last_turn:.*$/m, `last_turn: ${ts}`);
57
+ fs.writeFileSync(filePath, text);
58
+ return { ts };
59
+ }
60
+
61
+ // Parse a conversation file into structured turns. Tolerant — anything that
62
+ // doesn't look like a turn header is ignored.
63
+ export function parseConversation(text) {
64
+ const fmEnd = text.indexOf("\n---", 4);
65
+ const fm = {};
66
+ let body = text;
67
+ if (text.startsWith("---\n") && fmEnd !== -1) {
68
+ for (const line of text.slice(4, fmEnd).split("\n")) {
69
+ const m = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
70
+ if (m) fm[m[1]] = m[2].trim();
71
+ }
72
+ body = text.slice(fmEnd + 4);
73
+ }
74
+ const turns = [];
75
+ const re = /^##\s+(user|assistant|system|tool|compact)\s+—\s+(\S+)\s*\n([\s\S]*?)(?=\n##\s+(?:user|assistant|system|tool|compact)\s+—\s|\n*$)/gm;
76
+ let m;
77
+ while ((m = re.exec(body)) !== null) {
78
+ turns.push({
79
+ role: m[1],
80
+ ts: m[2],
81
+ content: m[3].trim(),
82
+ });
83
+ }
84
+ return { fm, turns };
85
+ }
86
+
87
+ export function readConversation(projectRoot, agentSlug, idOrFilename) {
88
+ const p = conversationPath(projectRoot, agentSlug, idOrFilename);
89
+ if (!fs.existsSync(p)) return null;
90
+ return { ...parseConversation(fs.readFileSync(p, "utf8")), path: p };
91
+ }
92
+
93
+ export function listConversations(projectRoot, agentSlug) {
94
+ const dir = path.join(projectRoot, ".apc", "agents", agentSlug, "conversations");
95
+ if (!fs.existsSync(dir)) return [];
96
+ return fs
97
+ .readdirSync(dir)
98
+ .filter((f) => f.endsWith(".md"))
99
+ .sort()
100
+ .reverse()
101
+ .map((f) => ({ filename: f, id: f.replace(/\.md$/, "") }));
102
+ }
103
+
104
+ export function setStatus(filePath, status) {
105
+ let text = fs.readFileSync(filePath, "utf8");
106
+ text = text.replace(/^status:.*$/m, `status: ${status}`);
107
+ fs.writeFileSync(filePath, text);
108
+ }
@@ -0,0 +1,81 @@
1
+ // ProjectManager: in-memory registry of open projects.
2
+ // Projects are identified by path; no SQLite — filesystem is the source of truth.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { appendMessageToFs } from "../core/messages-store.js";
6
+ import { effectiveConfig } from "./project-config.js";
7
+ import { readAgents } from "../core/parser.js";
8
+
9
+ export class ProjectManager {
10
+ constructor(globalConfig = {}) {
11
+ this.byId = new Map(); // id -> { id, path, config, logMessage }
12
+ this.byPath = new Map(); // absolute path -> entry
13
+ this._nextId = 1;
14
+ this.globalConfig = globalConfig;
15
+ }
16
+
17
+ setGlobalConfig(cfg) {
18
+ this.globalConfig = cfg;
19
+ for (const entry of this.byId.values()) {
20
+ entry.config = effectiveConfig(this.globalConfig, entry.path);
21
+ }
22
+ }
23
+
24
+ register(projectPath) {
25
+ const abs = path.resolve(projectPath);
26
+ if (this.byPath.has(abs)) return this.byPath.get(abs);
27
+ const projectJson = path.join(abs, ".apc", "project.json");
28
+ if (!fs.existsSync(projectJson)) {
29
+ throw new Error(`not an APC project: ${abs}`);
30
+ }
31
+ // Ensure directories exist for projects initialized before they were added.
32
+ fs.mkdirSync(path.join(abs, ".apc", "commands"), { recursive: true });
33
+ fs.mkdirSync(path.join(abs, ".apc", "messages"), { recursive: true });
34
+
35
+ const entry = {
36
+ id: this._nextId++,
37
+ path: abs,
38
+ config: effectiveConfig(this.globalConfig, abs),
39
+ };
40
+ entry.logMessage = (payload) => appendMessageToFs({ projectRoot: abs, ...payload });
41
+ this.byId.set(entry.id, entry);
42
+ this.byPath.set(abs, entry);
43
+ return entry;
44
+ }
45
+
46
+ get(id) {
47
+ return this.byId.get(Number(id)) || null;
48
+ }
49
+
50
+ getByPath(p) {
51
+ return this.byPath.get(path.resolve(p)) || null;
52
+ }
53
+
54
+ list() {
55
+ return Array.from(this.byId.values()).map((e) => {
56
+ let name = path.basename(e.path);
57
+ try {
58
+ const meta = JSON.parse(
59
+ fs.readFileSync(path.join(e.path, ".apc", "project.json"), "utf8")
60
+ );
61
+ if (meta.name) name = meta.name;
62
+ } catch {}
63
+ return { id: e.id, path: e.path, name, agents: readAgents(e.path).length };
64
+ });
65
+ }
66
+
67
+ unregister(id) {
68
+ const entry = this.byId.get(Number(id));
69
+ if (!entry) return false;
70
+ this.byId.delete(entry.id);
71
+ this.byPath.delete(entry.path);
72
+ return true;
73
+ }
74
+
75
+ rebuild(id) {
76
+ const entry = this.get(id);
77
+ if (!entry) throw new Error(`unknown project id ${id}`);
78
+ entry.config = effectiveConfig(this.globalConfig, entry.path);
79
+ return { projectRoot: entry.path, agents: readAgents(entry.path).length };
80
+ }
81
+ }
@@ -0,0 +1,58 @@
1
+ // Anthropic Messages API adapter (https://docs.anthropic.com/en/api/messages).
2
+ // No SDK dependency — direct fetch, keeps the daemon lean.
3
+
4
+ const API_BASE = "https://api.anthropic.com/v1/messages";
5
+ const API_VERSION = "2023-06-01";
6
+
7
+ function getKey(config) {
8
+ return config.api_key || process.env.ANTHROPIC_API_KEY || "";
9
+ }
10
+
11
+ export default {
12
+ id: "anthropic",
13
+
14
+ async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {} }) {
15
+ const key = getKey(config);
16
+ if (!key) throw new Error("anthropic: no api_key (set ANTHROPIC_API_KEY or engines.anthropic.api_key)");
17
+ if (!model) throw new Error("anthropic: model required");
18
+
19
+ const body = {
20
+ model,
21
+ max_tokens: maxTokens,
22
+ temperature,
23
+ messages: messages.map((m) => ({
24
+ role: m.role === "assistant" ? "assistant" : "user",
25
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
26
+ })),
27
+ };
28
+ if (system) body.system = system;
29
+
30
+ const res = await fetch(API_BASE, {
31
+ method: "POST",
32
+ headers: {
33
+ "content-type": "application/json",
34
+ "x-api-key": key,
35
+ "anthropic-version": API_VERSION,
36
+ },
37
+ body: JSON.stringify(body),
38
+ });
39
+ const json = await res.json();
40
+ if (!res.ok) {
41
+ throw new Error(
42
+ `anthropic ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
43
+ );
44
+ }
45
+ const text = (json.content || [])
46
+ .filter((b) => b.type === "text")
47
+ .map((b) => b.text)
48
+ .join("");
49
+ return {
50
+ text,
51
+ usage: {
52
+ input_tokens: json.usage?.input_tokens || 0,
53
+ output_tokens: json.usage?.output_tokens || 0,
54
+ },
55
+ raw: json,
56
+ };
57
+ },
58
+ };
@@ -0,0 +1,55 @@
1
+ // Google Gemini adapter (https://ai.google.dev/api/generate-content).
2
+ // Direct fetch, no SDK.
3
+
4
+ const API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
5
+
6
+ function getKey(config) {
7
+ return config.api_key || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
8
+ }
9
+
10
+ export default {
11
+ id: "gemini",
12
+
13
+ async chat({ system, messages, model, temperature = 0.7, maxTokens = 1024, config = {} }) {
14
+ const key = getKey(config);
15
+ if (!key) throw new Error("gemini: no api_key (set GEMINI_API_KEY or engines.gemini.api_key)");
16
+ if (!model) throw new Error("gemini: model required");
17
+
18
+ // Gemini's API splits roles into 'user' and 'model'. System goes in
19
+ // systemInstruction at the top level.
20
+ const contents = messages.map((m) => ({
21
+ role: m.role === "assistant" ? "model" : "user",
22
+ parts: [{ text: typeof m.content === "string" ? m.content : JSON.stringify(m.content) }],
23
+ }));
24
+
25
+ const body = {
26
+ contents,
27
+ generationConfig: { temperature, maxOutputTokens: maxTokens },
28
+ };
29
+ if (system) body.systemInstruction = { parts: [{ text: system }] };
30
+
31
+ const url = `${API_BASE}/${encodeURIComponent(model)}:generateContent?key=${key}`;
32
+ const res = await fetch(url, {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify(body),
36
+ });
37
+ const json = await res.json();
38
+ if (!res.ok) {
39
+ throw new Error(
40
+ `gemini ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
41
+ );
42
+ }
43
+ const text = (json.candidates?.[0]?.content?.parts || [])
44
+ .map((p) => p.text)
45
+ .join("");
46
+ return {
47
+ text,
48
+ usage: {
49
+ input_tokens: json.usageMetadata?.promptTokenCount || 0,
50
+ output_tokens: json.usageMetadata?.candidatesTokenCount || 0,
51
+ },
52
+ raw: json,
53
+ };
54
+ },
55
+ };
@@ -0,0 +1,65 @@
1
+ // Engine adapter registry. Maps a model id → provider → adapter.
2
+ //
3
+ // Model id grammar (in agents.<slug>.model):
4
+ // "<provider>:<model>" explicit, e.g. "ollama:llama3.2", "anthropic:claude-haiku-4-5"
5
+ // "<model>" inferred: claude-* → anthropic, gpt-* → openai, gemini-* → gemini
6
+ //
7
+ // Each adapter exports a default object:
8
+ // { id, chat({system, messages, model, temperature, maxTokens}) → {text, usage, raw} }
9
+ //
10
+ // API keys come from ~/.apx/config.json `engines.<provider>.api_key` or env vars
11
+ // (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY).
12
+ // Ollama needs no key, just a base_url (default http://localhost:11434).
13
+
14
+ import anthropic from "./anthropic.js";
15
+ import openai from "./openai.js";
16
+ import ollama from "./ollama.js";
17
+ import gemini from "./gemini.js";
18
+ import mock from "./mock.js";
19
+
20
+ const ADAPTERS = { anthropic, openai, ollama, gemini, mock };
21
+
22
+ export function resolveProvider(modelId) {
23
+ if (typeof modelId !== "string" || !modelId) {
24
+ throw new Error("model id is empty");
25
+ }
26
+ if (modelId.includes(":")) {
27
+ const [provider, ...rest] = modelId.split(":");
28
+ return { provider: provider.toLowerCase(), model: rest.join(":") };
29
+ }
30
+ if (/^claude/i.test(modelId)) return { provider: "anthropic", model: modelId };
31
+ if (/^gpt|^o1|^o3|^o4/i.test(modelId)) return { provider: "openai", model: modelId };
32
+ if (/^gemini/i.test(modelId)) return { provider: "gemini", model: modelId };
33
+ if (modelId === "mock") return { provider: "mock", model: "mock" };
34
+ throw new Error(
35
+ `cannot infer provider for model "${modelId}" — use explicit "<provider>:<model>" form`
36
+ );
37
+ }
38
+
39
+ export function getAdapter(provider) {
40
+ const a = ADAPTERS[provider];
41
+ if (!a) {
42
+ throw new Error(
43
+ `unknown engine provider "${provider}". Known: ${Object.keys(ADAPTERS).join(", ")}`
44
+ );
45
+ }
46
+ return a;
47
+ }
48
+
49
+ export async function callEngine({ modelId, system, messages, config, temperature, maxTokens, tools }) {
50
+ const { provider, model } = resolveProvider(modelId);
51
+ const adapter = getAdapter(provider);
52
+ const providerCfg =
53
+ (config && config.engines && config.engines[provider]) || {};
54
+ return adapter.chat({
55
+ system,
56
+ messages,
57
+ model,
58
+ temperature,
59
+ maxTokens,
60
+ tools,
61
+ config: providerCfg,
62
+ });
63
+ }
64
+
65
+ export const ENGINE_IDS = Object.keys(ADAPTERS);
@@ -0,0 +1,18 @@
1
+ // Mock engine for tests and offline development. No network. Echoes back the
2
+ // last user message with a small transformation so it's distinguishable from
3
+ // the input. Use model "mock" or "mock:anything".
4
+
5
+ export default {
6
+ id: "mock",
7
+
8
+ async chat({ system, messages, model = "mock" }) {
9
+ const last = [...messages].reverse().find((m) => m.role === "user");
10
+ const userText = last?.content || "";
11
+ const sysHint = system ? ` (system: ${system.slice(0, 40)}…)` : "";
12
+ return {
13
+ text: `[mock:${model}] received: ${userText}${sysHint}`,
14
+ usage: { input_tokens: userText.length, output_tokens: 32 },
15
+ raw: { model, mock: true },
16
+ };
17
+ },
18
+ };
@@ -0,0 +1,66 @@
1
+ // Ollama adapter (https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-chat-completion).
2
+ // Local-only. No API key. Default base_url http://localhost:11434.
3
+
4
+ function baseUrl(config) {
5
+ return config.base_url || process.env.OLLAMA_HOST || "http://localhost:11434";
6
+ }
7
+
8
+ export default {
9
+ id: "ollama",
10
+
11
+ async chat({ system, messages, model, temperature = 0.7, maxTokens = 1024, tools, config = {} }) {
12
+ if (!model) throw new Error("ollama: model required");
13
+
14
+ // The caller can pass `messages` as either:
15
+ // [{role, content}] — usual shape
16
+ // [{role, content, tool_calls?}, {role: "tool", tool_call_id?, content}, ...]
17
+ // We forward those fields straight through so the agent loop works.
18
+ const fullMessages = [];
19
+ if (system) fullMessages.push({ role: "system", content: system });
20
+ for (const m of messages) {
21
+ const out = { role: m.role };
22
+ if (m.content !== undefined) {
23
+ out.content =
24
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content);
25
+ } else {
26
+ out.content = "";
27
+ }
28
+ if (m.tool_calls) out.tool_calls = m.tool_calls;
29
+ if (m.tool_name) out.tool_name = m.tool_name; // Ollama uses this field on role:"tool"
30
+ fullMessages.push(out);
31
+ }
32
+
33
+ const body = {
34
+ model,
35
+ messages: fullMessages,
36
+ stream: false,
37
+ options: { temperature, num_predict: maxTokens },
38
+ };
39
+ if (Array.isArray(tools) && tools.length > 0) {
40
+ body.tools = tools;
41
+ }
42
+
43
+ const url = `${baseUrl(config).replace(/\/$/, "")}/api/chat`;
44
+ const res = await fetch(url, {
45
+ method: "POST",
46
+ headers: { "content-type": "application/json" },
47
+ body: JSON.stringify(body),
48
+ });
49
+ if (!res.ok) {
50
+ const text = await res.text();
51
+ throw new Error(`ollama ${res.status}: ${text}`);
52
+ }
53
+ const json = await res.json();
54
+ const message = json.message || {};
55
+ return {
56
+ text: message.content || "",
57
+ tool_calls: message.tool_calls || null,
58
+ message,
59
+ usage: {
60
+ input_tokens: json.prompt_eval_count || 0,
61
+ output_tokens: json.eval_count || 0,
62
+ },
63
+ raw: json,
64
+ };
65
+ },
66
+ };
@@ -0,0 +1,58 @@
1
+ // OpenAI Chat Completions adapter (https://platform.openai.com/docs/api-reference/chat).
2
+ // Direct fetch, no SDK.
3
+
4
+ const API_BASE = "https://api.openai.com/v1/chat/completions";
5
+
6
+ function getKey(config) {
7
+ return config.api_key || process.env.OPENAI_API_KEY || "";
8
+ }
9
+
10
+ export default {
11
+ id: "openai",
12
+
13
+ async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {} }) {
14
+ const key = getKey(config);
15
+ if (!key) throw new Error("openai: no api_key (set OPENAI_API_KEY or engines.openai.api_key)");
16
+ if (!model) throw new Error("openai: model required");
17
+
18
+ const fullMessages = [];
19
+ if (system) fullMessages.push({ role: "system", content: system });
20
+ for (const m of messages) {
21
+ fullMessages.push({
22
+ role: m.role,
23
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
24
+ });
25
+ }
26
+
27
+ const body = {
28
+ model,
29
+ messages: fullMessages,
30
+ temperature,
31
+ max_tokens: maxTokens,
32
+ };
33
+
34
+ const res = await fetch(API_BASE, {
35
+ method: "POST",
36
+ headers: {
37
+ "content-type": "application/json",
38
+ authorization: `Bearer ${key}`,
39
+ },
40
+ body: JSON.stringify(body),
41
+ });
42
+ const json = await res.json();
43
+ if (!res.ok) {
44
+ throw new Error(
45
+ `openai ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
46
+ );
47
+ }
48
+ const text = json.choices?.[0]?.message?.content || "";
49
+ return {
50
+ text,
51
+ usage: {
52
+ input_tokens: json.usage?.prompt_tokens || 0,
53
+ output_tokens: json.usage?.completion_tokens || 0,
54
+ },
55
+ raw: json,
56
+ };
57
+ },
58
+ };