@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,78 @@
|
|
|
1
|
+
# APX — Agent Project Framework
|
|
2
|
+
|
|
3
|
+
The daemon runs on `127.0.0.1:7430` and auto-starts on first `apx` call.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Coordinate with other agents
|
|
8
|
+
|
|
9
|
+
**First: can you spawn a subagent natively in this IDE?**
|
|
10
|
+
|
|
11
|
+
If yes — do that. No APX needed. Claude Code, Cursor, and other IDEs can spawn subagents directly using your current context.
|
|
12
|
+
|
|
13
|
+
Use `apx run` only when:
|
|
14
|
+
- The user explicitly asks to run the agent in a specific external runtime ("run this in Codex", "run the QA agent outside this session")
|
|
15
|
+
- You need to run an agent in a runtime different from the one you're in
|
|
16
|
+
- You're orchestrating from outside any IDE (e.g. a script, Telegram bot, CI)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Run agent in an external runtime — full isolated session
|
|
20
|
+
apx run <slug> --runtime claude-code "<prompt>"
|
|
21
|
+
apx run <slug> --runtime codex "<prompt>"
|
|
22
|
+
apx run <slug> --runtime opencode "<prompt>"
|
|
23
|
+
|
|
24
|
+
# Example: run the qa agent in codex with a specific task
|
|
25
|
+
apx run qa --runtime codex "run the full test suite and report failures"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The output is the agent's full stdout. If it printed `APC_RESULT: <value>`, that value is captured as structured output.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Quick one-shot LLM call (no external CLI needed, uses ~/.apx/config.json engine key)
|
|
32
|
+
apx exec <slug> "<prompt>"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## MCP tools
|
|
36
|
+
|
|
37
|
+
MCPs declared in `.apc/mcps.json` are proxied through the APX daemon. Use `apx mcp` only for MCPs registered there — not for MCPs that are already running locally in your IDE session.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
apx mcp list # MCPs registered in .apc/mcps.json
|
|
41
|
+
apx mcp tools <server> # tools a server exposes
|
|
42
|
+
apx mcp run <server> <tool> '<json>' # call a tool
|
|
43
|
+
|
|
44
|
+
# Example:
|
|
45
|
+
apx mcp tools filesystem
|
|
46
|
+
apx mcp run filesystem read_file '{"path": "README.md"}'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Memory
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
apx memory <slug> # read agent's memory.md
|
|
53
|
+
apx memory <slug> --append "<fact>" # append a durable note
|
|
54
|
+
apx memory <slug> --replace < file.md # replace entire memory from stdin
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Sessions
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
apx session new <slug> --title "What you did" # create session file
|
|
61
|
+
apx session list <slug> # list sessions
|
|
62
|
+
apx session check # exits 1 if session already active
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Observe activity
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
apx messages tail # last 50 messages, all channels
|
|
69
|
+
apx messages tail --channel runtime # only agent invocations
|
|
70
|
+
apx messages tail --agent <slug> -n 20
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## APC_RESULT
|
|
74
|
+
|
|
75
|
+
Print on the last meaningful line of your output so the invoker captures it:
|
|
76
|
+
```
|
|
77
|
+
APC_RESULT: <one-line summary or value>
|
|
78
|
+
```
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Global APX config under ~/.apx/config.json. Cross-platform.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const APX_HOME = path.join(os.homedir(), ".apx");
|
|
7
|
+
export const CONFIG_PATH = path.join(APX_HOME, "config.json");
|
|
8
|
+
export const PID_PATH = path.join(APX_HOME, "daemon.pid");
|
|
9
|
+
export const LOG_PATH = path.join(APX_HOME, "daemon.log");
|
|
10
|
+
export const TELEGRAM_STATE_PATH = path.join(APX_HOME, "telegram-state.json");
|
|
11
|
+
// Global channel messages (telegram, direct, whatsapp, …) live here,
|
|
12
|
+
// separated from any project. Structure: ~/.apx/messages/<channel>/YYYY-MM-DD.jsonl
|
|
13
|
+
export const GLOBAL_MESSAGES_DIR = path.join(APX_HOME, "messages");
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
port: 7430,
|
|
17
|
+
host: "127.0.0.1",
|
|
18
|
+
log_level: "info",
|
|
19
|
+
projects: [],
|
|
20
|
+
telegram: {
|
|
21
|
+
enabled: false,
|
|
22
|
+
bot_token: "",
|
|
23
|
+
chat_id: "",
|
|
24
|
+
poll_interval_ms: 1500,
|
|
25
|
+
route_to_agent: "", // slug of the agent that auto-replies (single-channel mode)
|
|
26
|
+
respond_with_engine: true, // false → just log, never auto-reply
|
|
27
|
+
channels: [], // multi-channel mode; each item: {name, bot_token, chat_id, route_to_agent, project, respond_with_engine}
|
|
28
|
+
},
|
|
29
|
+
super_agent: {
|
|
30
|
+
enabled: false,
|
|
31
|
+
name: "apx",
|
|
32
|
+
model: "", // e.g. "ollama:llama3.2:3b"
|
|
33
|
+
system: "", // optional override; defaults baked into super-agent.js
|
|
34
|
+
},
|
|
35
|
+
engines: {
|
|
36
|
+
anthropic: { api_key: "" },
|
|
37
|
+
openai: { api_key: "" },
|
|
38
|
+
gemini: { api_key: "" },
|
|
39
|
+
ollama: { base_url: "http://localhost:11434" },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function ensureHome() {
|
|
44
|
+
fs.mkdirSync(APX_HOME, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readConfig() {
|
|
48
|
+
ensureHome();
|
|
49
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
50
|
+
writeConfig(DEFAULT_CONFIG);
|
|
51
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
52
|
+
}
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new Error(`invalid ${CONFIG_PATH}: ${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
return mergeDefaults(raw);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function writeConfig(cfg) {
|
|
63
|
+
ensureHome();
|
|
64
|
+
const tmp = `${CONFIG_PATH}.tmp`;
|
|
65
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
|
|
66
|
+
fs.renameSync(tmp, CONFIG_PATH);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function mergeDefaults(cfg) {
|
|
70
|
+
return {
|
|
71
|
+
...DEFAULT_CONFIG,
|
|
72
|
+
...cfg,
|
|
73
|
+
telegram: {
|
|
74
|
+
...DEFAULT_CONFIG.telegram,
|
|
75
|
+
...(cfg.telegram || {}),
|
|
76
|
+
channels: Array.isArray(cfg.telegram?.channels) ? cfg.telegram.channels : [],
|
|
77
|
+
},
|
|
78
|
+
super_agent: { ...DEFAULT_CONFIG.super_agent, ...(cfg.super_agent || {}) },
|
|
79
|
+
engines: {
|
|
80
|
+
...DEFAULT_CONFIG.engines,
|
|
81
|
+
...(cfg.engines || {}),
|
|
82
|
+
anthropic: { ...DEFAULT_CONFIG.engines.anthropic, ...(cfg.engines?.anthropic || {}) },
|
|
83
|
+
openai: { ...DEFAULT_CONFIG.engines.openai, ...(cfg.engines?.openai || {}) },
|
|
84
|
+
gemini: { ...DEFAULT_CONFIG.engines.gemini, ...(cfg.engines?.gemini || {}) },
|
|
85
|
+
ollama: { ...DEFAULT_CONFIG.engines.ollama, ...(cfg.engines?.ollama || {}) },
|
|
86
|
+
},
|
|
87
|
+
projects: Array.isArray(cfg.projects) ? cfg.projects : [],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function effectivePort(cfg) {
|
|
92
|
+
const env = process.env.APX_PORT;
|
|
93
|
+
if (env && /^\d+$/.test(env)) return parseInt(env, 10);
|
|
94
|
+
return cfg.port || DEFAULT_CONFIG.port;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function effectiveHost(cfg) {
|
|
98
|
+
return process.env.APX_HOST || cfg.host || DEFAULT_CONFIG.host;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function addProject(cfg, projectPath) {
|
|
102
|
+
const abs = path.resolve(projectPath);
|
|
103
|
+
if (!fs.existsSync(path.join(abs, "AGENTS.md"))) {
|
|
104
|
+
throw new Error(`not an APC project: ${abs} (no AGENTS.md)`);
|
|
105
|
+
}
|
|
106
|
+
if (!fs.existsSync(path.join(abs, ".apc", "project.json"))) {
|
|
107
|
+
throw new Error(`not an APC project: ${abs} (no .apc/project.json)`);
|
|
108
|
+
}
|
|
109
|
+
const exists = cfg.projects.find((p) => path.resolve(p.path) === abs);
|
|
110
|
+
if (exists) return { added: false, project: exists };
|
|
111
|
+
|
|
112
|
+
const entry = { path: abs };
|
|
113
|
+
cfg.projects.push(entry);
|
|
114
|
+
writeConfig(cfg);
|
|
115
|
+
return { added: true, project: entry };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function removeProject(cfg, idOrPath) {
|
|
119
|
+
const before = cfg.projects.length;
|
|
120
|
+
if (typeof idOrPath === "number" || /^\d+$/.test(String(idOrPath))) {
|
|
121
|
+
const idx = parseInt(idOrPath, 10) - 1;
|
|
122
|
+
if (idx >= 0 && idx < cfg.projects.length) cfg.projects.splice(idx, 1);
|
|
123
|
+
} else {
|
|
124
|
+
const abs = path.resolve(String(idOrPath));
|
|
125
|
+
cfg.projects = cfg.projects.filter((p) => path.resolve(p.path) !== abs);
|
|
126
|
+
}
|
|
127
|
+
if (cfg.projects.length !== before) writeConfig(cfg);
|
|
128
|
+
return { removed: before - cfg.projects.length };
|
|
129
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
export const IDENTITY_PATH = path.join(os.homedir(), ".apx", "identity.json");
|
|
6
|
+
|
|
7
|
+
export function readIdentity() {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf8"));
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeIdentity(fields) {
|
|
16
|
+
const existing = readIdentity() || {};
|
|
17
|
+
const now = new Date().toISOString();
|
|
18
|
+
const updated = { ...existing, ...fields, updated: now };
|
|
19
|
+
if (!updated.created) updated.created = now;
|
|
20
|
+
fs.mkdirSync(path.dirname(IDENTITY_PATH), { recursive: true });
|
|
21
|
+
fs.writeFileSync(IDENTITY_PATH, JSON.stringify(updated, null, 2) + "\n");
|
|
22
|
+
return updated;
|
|
23
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Messages store: filesystem source-of-truth + SQLite cache mirror.
|
|
2
|
+
//
|
|
3
|
+
// On disk (project-specific — runtime, a2a, exec):
|
|
4
|
+
// <project>/.apc/messages/YYYY-MM-DD.jsonl
|
|
5
|
+
//
|
|
6
|
+
// On disk (global cross-project channels — telegram, direct, whatsapp, …):
|
|
7
|
+
// ~/.apx/messages/<channel>/YYYY-MM-DD.jsonl
|
|
8
|
+
//
|
|
9
|
+
// Each line:
|
|
10
|
+
// {"ts":"...","channel":"...","direction":"in|out","author":"...","body":"...","meta":{...}}
|
|
11
|
+
//
|
|
12
|
+
// Why JSONL: same shape as Claude Code's ~/.claude/projects/<id>.jsonl.
|
|
13
|
+
// Streamable, structured, no markdown parsing fragility.
|
|
14
|
+
//
|
|
15
|
+
// Daemon writes go through `appendMessage` (project) or `appendGlobalMessage`
|
|
16
|
+
// (cross-project channel). `rebuildMessagesFromFs` is idempotent — wipes the
|
|
17
|
+
// SQL cache then reads every project day file in order.
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { GLOBAL_MESSAGES_DIR } from "./config.js";
|
|
22
|
+
|
|
23
|
+
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
24
|
+
|
|
25
|
+
function dayPathJsonl(projectRoot, ts) {
|
|
26
|
+
const day = (ts || nowIso()).slice(0, 10);
|
|
27
|
+
return path.join(projectRoot, ".apc", "messages", `${day}.jsonl`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dayPathMd(projectRoot, ts) {
|
|
31
|
+
const day = (ts || nowIso()).slice(0, 10);
|
|
32
|
+
return path.join(projectRoot, ".apc", "messages", `${day}.md`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function appendMessageToFs({ projectRoot, channel, direction, author, body, meta = {}, ts, agent_slug, session_id, external_id }) {
|
|
36
|
+
ts = ts || nowIso();
|
|
37
|
+
const file = dayPathJsonl(projectRoot, ts);
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
39
|
+
|
|
40
|
+
// Compose meta from explicit fields plus the bag
|
|
41
|
+
const fullMeta = {
|
|
42
|
+
...(agent_slug ? { agent: agent_slug } : {}),
|
|
43
|
+
...(session_id ? { session_id } : {}),
|
|
44
|
+
...(external_id ? { external_id } : {}),
|
|
45
|
+
...meta,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const record = {
|
|
49
|
+
ts,
|
|
50
|
+
channel,
|
|
51
|
+
direction,
|
|
52
|
+
author: author || null,
|
|
53
|
+
body: body || "",
|
|
54
|
+
...(Object.keys(fullMeta).length ? { meta: fullMeta } : {}),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
fs.appendFileSync(file, JSON.stringify(record) + "\n");
|
|
58
|
+
return { ts, file };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Insert a row into the SQL cache. Used by both appendMessage and rebuild.
|
|
62
|
+
export function insertMessageRow(db, m) {
|
|
63
|
+
let agent_id = null;
|
|
64
|
+
if (m.agent_slug) {
|
|
65
|
+
const a = db.prepare("SELECT id FROM agents WHERE slug = ?").get(m.agent_slug);
|
|
66
|
+
if (a) agent_id = a.id;
|
|
67
|
+
}
|
|
68
|
+
return db
|
|
69
|
+
.prepare(
|
|
70
|
+
`INSERT INTO messages (agent_id, session_id, channel, direction, external_id, author, body, meta_json, ts)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
72
|
+
)
|
|
73
|
+
.run(
|
|
74
|
+
agent_id,
|
|
75
|
+
m.session_id || null,
|
|
76
|
+
m.channel,
|
|
77
|
+
m.direction,
|
|
78
|
+
m.external_id || null,
|
|
79
|
+
m.author || null,
|
|
80
|
+
m.body || "",
|
|
81
|
+
JSON.stringify(m.meta || {}),
|
|
82
|
+
m.ts
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Single entry point used by everywhere the daemon writes a message.
|
|
87
|
+
export function appendMessage({ projectRoot, db, channel, direction, author, body, meta = {}, ts, agent_slug, session_id, external_id }) {
|
|
88
|
+
const written = appendMessageToFs({
|
|
89
|
+
projectRoot,
|
|
90
|
+
channel,
|
|
91
|
+
direction,
|
|
92
|
+
author,
|
|
93
|
+
body,
|
|
94
|
+
meta,
|
|
95
|
+
ts,
|
|
96
|
+
agent_slug,
|
|
97
|
+
session_id,
|
|
98
|
+
external_id,
|
|
99
|
+
});
|
|
100
|
+
insertMessageRow(db, {
|
|
101
|
+
channel,
|
|
102
|
+
direction,
|
|
103
|
+
author,
|
|
104
|
+
body,
|
|
105
|
+
meta,
|
|
106
|
+
ts: written.ts,
|
|
107
|
+
agent_slug,
|
|
108
|
+
session_id,
|
|
109
|
+
external_id,
|
|
110
|
+
});
|
|
111
|
+
return written;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Parse one .jsonl day file into [{...}, ...]
|
|
115
|
+
export function parseDayJsonl(text) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const line of text.split("\n")) {
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
if (!trimmed) continue;
|
|
120
|
+
let obj;
|
|
121
|
+
try { obj = JSON.parse(trimmed); } catch { continue; }
|
|
122
|
+
if (!obj || typeof obj !== "object") continue;
|
|
123
|
+
const meta = obj.meta || {};
|
|
124
|
+
out.push({
|
|
125
|
+
ts: obj.ts,
|
|
126
|
+
channel: obj.channel,
|
|
127
|
+
direction: obj.direction,
|
|
128
|
+
author: obj.author,
|
|
129
|
+
body: obj.body || "",
|
|
130
|
+
meta,
|
|
131
|
+
agent_slug: meta.agent,
|
|
132
|
+
session_id: typeof meta.apc_session_id === "number" ? meta.apc_session_id : null,
|
|
133
|
+
external_id: meta.external_id,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Parse the legacy .md format (kept so rebuild still picks up files written
|
|
140
|
+
// by older versions of the daemon).
|
|
141
|
+
export function parseDayFile(text) {
|
|
142
|
+
const out = [];
|
|
143
|
+
const blocks = text.split(/\n(?=## \d{4}-\d{2}-\d{2}T)/);
|
|
144
|
+
for (const block of blocks) {
|
|
145
|
+
const m = block.match(/^## (\S+)\s+(\S+)\s+(in|out)\s+(.*?)\n([\s\S]*)$/);
|
|
146
|
+
if (!m) continue;
|
|
147
|
+
const ts = m[1];
|
|
148
|
+
const channel = m[2];
|
|
149
|
+
const direction = m[3];
|
|
150
|
+
const author = m[4].trim();
|
|
151
|
+
let body = m[5];
|
|
152
|
+
let meta = {};
|
|
153
|
+
const metaMatch = body.match(/<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/);
|
|
154
|
+
if (metaMatch) {
|
|
155
|
+
try { meta = JSON.parse(metaMatch[1]); } catch {}
|
|
156
|
+
body = body.replace(metaMatch[0], "");
|
|
157
|
+
}
|
|
158
|
+
body = body.trim();
|
|
159
|
+
out.push({
|
|
160
|
+
ts, channel, direction, author, body, meta,
|
|
161
|
+
agent_slug: meta.agent,
|
|
162
|
+
session_id: typeof meta.apc_session_id === "number" ? meta.apc_session_id : null,
|
|
163
|
+
external_id: meta.external_id,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Pull the recent conversation for a given Telegram chat_id from the messages
|
|
170
|
+
// table. Returns the messages in CHRONOLOGICAL order (oldest first), shaped
|
|
171
|
+
// for use as `previousMessages` to runSuperAgent / callEngine.
|
|
172
|
+
//
|
|
173
|
+
// Filters:
|
|
174
|
+
// - channel = 'telegram'
|
|
175
|
+
// - meta_json.chat_id matches chat_id
|
|
176
|
+
// - ts within `max_age_hours` (default 24)
|
|
177
|
+
// - up to `limit` rows, taking the most recent
|
|
178
|
+
//
|
|
179
|
+
// `direction='in'` becomes role:"user", `direction='out'` becomes
|
|
180
|
+
// role:"assistant". The current inbound (the one we're answering NOW) is
|
|
181
|
+
// expected to be excluded by the caller — usually by passing a `before` ts
|
|
182
|
+
// or by simply running this query BEFORE the inbound is logged.
|
|
183
|
+
export function getRecentTelegramTurns(
|
|
184
|
+
db,
|
|
185
|
+
{ chat_id, limit = 12, max_age_hours = 24 }
|
|
186
|
+
) {
|
|
187
|
+
if (!chat_id) return [];
|
|
188
|
+
const cutoff = new Date(Date.now() - max_age_hours * 3600_000)
|
|
189
|
+
.toISOString()
|
|
190
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
191
|
+
const rows = db
|
|
192
|
+
.prepare(
|
|
193
|
+
`SELECT direction, body, meta_json, ts FROM messages
|
|
194
|
+
WHERE channel = 'telegram'
|
|
195
|
+
AND ts >= ?
|
|
196
|
+
ORDER BY ts DESC
|
|
197
|
+
LIMIT ?`
|
|
198
|
+
)
|
|
199
|
+
.all(cutoff, limit * 4) // overshoot, then filter by chat_id in JS
|
|
200
|
+
.filter((r) => {
|
|
201
|
+
try {
|
|
202
|
+
const meta = JSON.parse(r.meta_json || "{}");
|
|
203
|
+
return String(meta.chat_id ?? "") === String(chat_id);
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
.slice(0, limit);
|
|
209
|
+
|
|
210
|
+
// We pulled DESC; reverse to get oldest-first for the model.
|
|
211
|
+
return rows.reverse().map((r) => {
|
|
212
|
+
const role = r.direction === "in" ? "user" : "assistant";
|
|
213
|
+
let content = r.body;
|
|
214
|
+
if (role === "assistant") content = sanitizeAssistantForContext(content);
|
|
215
|
+
return { role, content };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Aggressively redact assistant turns before sending them as context. The
|
|
220
|
+
// problem we're solving: when the model sees its own past answer with
|
|
221
|
+
// concrete factual claims (agent names, model ids, paths, MCPs), it tends
|
|
222
|
+
// to "amplify" them in the next turn — composing a plausible-looking new
|
|
223
|
+
// answer that mixes fragments of the old one with hallucinations. The
|
|
224
|
+
// failure observed with qwen2.5:14b was:
|
|
225
|
+
//
|
|
226
|
+
// prev assistant: "agente sandbox con modelo ollama:llama3.2:3b"
|
|
227
|
+
// user: "y en el otro proyecto qué agente tiene?"
|
|
228
|
+
// assistant (alucinated): "agente assistant con modelo ollama:llama3.2:3b"
|
|
229
|
+
// (sofia exists, not "assistant", and her model is
|
|
230
|
+
// claude-haiku-4-5, not the carry-over from above)
|
|
231
|
+
//
|
|
232
|
+
// Solution: replace any assistant turn that *looks* like it contains data
|
|
233
|
+
// with a generic "I answered" placeholder. The model loses the cache to
|
|
234
|
+
// copy from but keeps enough hint to track the conversation flow.
|
|
235
|
+
function sanitizeAssistantForContext(content) {
|
|
236
|
+
if (!content) return "";
|
|
237
|
+
// Heuristics — if any of these match, the turn likely contains facts
|
|
238
|
+
// the model should re-derive from tools rather than parrot from cache.
|
|
239
|
+
const FACTUAL_PATTERNS = [
|
|
240
|
+
/\b(claude-|gpt-|gemini|llama|qwen|sonnet|haiku|opus|deepseek|kimi|mistral|gemma)\b/i,
|
|
241
|
+
/\b(ollama:|anthropic:|openai:|gemini:)/i,
|
|
242
|
+
/\b(role|rol|model|modelo|skills?|habilidades?)\s*[:=]/i,
|
|
243
|
+
/^- \w+/m, // bulleted list
|
|
244
|
+
/\*\*\w+\*\*/, // bold names
|
|
245
|
+
/\.(jsonl|md|json|sqlite|db|yaml|toml)\b/i,
|
|
246
|
+
/\/Users\/|\/Volumes\/|\/home\//i,
|
|
247
|
+
];
|
|
248
|
+
for (const re of FACTUAL_PATTERNS) {
|
|
249
|
+
if (re.test(content)) {
|
|
250
|
+
return "(I answered with data here. Re-call the tool to get the current values — do not paraphrase from memory.)";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Otherwise it's conversational small-talk; keep up to 200 chars.
|
|
254
|
+
if (content.length > 200) {
|
|
255
|
+
return content.slice(0, 200).replace(/\s+/g, " ").trim() + "…";
|
|
256
|
+
}
|
|
257
|
+
return content;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// File-based project message queries (no SQL required)
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export function readProjectMessages(projectRoot, { channel, agent_slug, since, limit = 100 } = {}) {
|
|
265
|
+
const dir = path.join(projectRoot, ".apc", "messages");
|
|
266
|
+
if (!fs.existsSync(dir)) return [];
|
|
267
|
+
const all = [];
|
|
268
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
269
|
+
const full = path.join(dir, f);
|
|
270
|
+
const text = fs.readFileSync(full, "utf8");
|
|
271
|
+
let msgs = [];
|
|
272
|
+
if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) msgs = parseDayJsonl(text);
|
|
273
|
+
else if (/^\d{4}-\d{2}-\d{2}\.md$/.test(f)) msgs = parseDayFile(text);
|
|
274
|
+
for (const m of msgs) {
|
|
275
|
+
if (channel && m.channel !== channel) continue;
|
|
276
|
+
if (agent_slug && m.agent_slug !== agent_slug) continue;
|
|
277
|
+
if (since && m.ts < since) continue;
|
|
278
|
+
all.push(m);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
all.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
|
|
282
|
+
return all.slice(0, Math.min(limit, 1000));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function searchProjectMessages(projectRoot, query, limit = 50) {
|
|
286
|
+
if (!query) return [];
|
|
287
|
+
const q = query.toLowerCase();
|
|
288
|
+
const dir = path.join(projectRoot, ".apc", "messages");
|
|
289
|
+
if (!fs.existsSync(dir)) return [];
|
|
290
|
+
const all = [];
|
|
291
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
292
|
+
const full = path.join(dir, f);
|
|
293
|
+
const text = fs.readFileSync(full, "utf8");
|
|
294
|
+
let msgs = [];
|
|
295
|
+
if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) msgs = parseDayJsonl(text);
|
|
296
|
+
else if (/^\d{4}-\d{2}-\d{2}\.md$/.test(f)) msgs = parseDayFile(text);
|
|
297
|
+
for (const m of msgs) {
|
|
298
|
+
if ((m.body || "").toLowerCase().includes(q)) all.push(m);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
all.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
|
|
302
|
+
return all.slice(0, Math.min(limit, 500));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// File-based Telegram turn history: reads from ~/.apx/messages/telegram/ JSONL.
|
|
306
|
+
// Pass _globalMessagesDir to override the default dir (useful in tests).
|
|
307
|
+
export function getRecentTelegramTurnsFromFs({
|
|
308
|
+
chat_id,
|
|
309
|
+
limit = 12,
|
|
310
|
+
max_age_hours = 24,
|
|
311
|
+
_globalMessagesDir,
|
|
312
|
+
} = {}) {
|
|
313
|
+
if (!chat_id) return [];
|
|
314
|
+
const cutoff = new Date(Date.now() - max_age_hours * 3600_000)
|
|
315
|
+
.toISOString()
|
|
316
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
317
|
+
const base = _globalMessagesDir || GLOBAL_MESSAGES_DIR;
|
|
318
|
+
const dir = path.join(base, "telegram");
|
|
319
|
+
if (!fs.existsSync(dir)) return [];
|
|
320
|
+
const all = [];
|
|
321
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
322
|
+
if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) continue;
|
|
323
|
+
const text = fs.readFileSync(path.join(dir, f), "utf8");
|
|
324
|
+
for (const m of parseDayJsonl(text)) {
|
|
325
|
+
if (m.ts < cutoff) continue;
|
|
326
|
+
all.push(m);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
all.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
330
|
+
const filtered = all
|
|
331
|
+
.filter((m) => String(m.meta?.chat_id ?? "") === String(chat_id))
|
|
332
|
+
.slice(-limit);
|
|
333
|
+
return filtered.map((m) => {
|
|
334
|
+
const role = m.direction === "in" ? "user" : "assistant";
|
|
335
|
+
let content = m.body;
|
|
336
|
+
if (role === "assistant") content = sanitizeAssistantForContext(content);
|
|
337
|
+
return { role, content };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Global message store (~/.apx/messages/<channel>/YYYY-MM-DD.jsonl)
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
// Write a message to the global channel store. No SQL cache — JSONL only.
|
|
346
|
+
export function appendGlobalMessage({ channel, direction, author, body, meta = {}, ts, agent_slug, external_id }) {
|
|
347
|
+
ts = ts || nowIso();
|
|
348
|
+
const dir = path.join(GLOBAL_MESSAGES_DIR, channel);
|
|
349
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
350
|
+
const file = path.join(dir, `${ts.slice(0, 10)}.jsonl`);
|
|
351
|
+
const fullMeta = {
|
|
352
|
+
...(agent_slug ? { agent: agent_slug } : {}),
|
|
353
|
+
...(external_id ? { external_id } : {}),
|
|
354
|
+
...meta,
|
|
355
|
+
};
|
|
356
|
+
const record = {
|
|
357
|
+
ts,
|
|
358
|
+
channel,
|
|
359
|
+
direction,
|
|
360
|
+
author: author || null,
|
|
361
|
+
body: body || "",
|
|
362
|
+
...(Object.keys(fullMeta).length ? { meta: fullMeta } : {}),
|
|
363
|
+
};
|
|
364
|
+
fs.appendFileSync(file, JSON.stringify(record) + "\n");
|
|
365
|
+
return { ts, file };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Read recent global channel messages from disk.
|
|
369
|
+
// Returns parsed records sorted oldest-first.
|
|
370
|
+
export function readGlobalMessages({ channel, limit = 100, since } = {}) {
|
|
371
|
+
const channels = channel
|
|
372
|
+
? [channel]
|
|
373
|
+
: (fs.existsSync(GLOBAL_MESSAGES_DIR) ? fs.readdirSync(GLOBAL_MESSAGES_DIR).filter((f) => {
|
|
374
|
+
const full = path.join(GLOBAL_MESSAGES_DIR, f);
|
|
375
|
+
return fs.statSync(full).isDirectory();
|
|
376
|
+
}) : []);
|
|
377
|
+
|
|
378
|
+
const all = [];
|
|
379
|
+
for (const ch of channels) {
|
|
380
|
+
const dir = path.join(GLOBAL_MESSAGES_DIR, ch);
|
|
381
|
+
if (!fs.existsSync(dir)) continue;
|
|
382
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
383
|
+
if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) continue;
|
|
384
|
+
const text = fs.readFileSync(path.join(dir, f), "utf8");
|
|
385
|
+
for (const m of parseDayJsonl(text)) {
|
|
386
|
+
if (since && m.ts < since) continue;
|
|
387
|
+
all.push({ ...m, channel: ch });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
all.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
392
|
+
return all.slice(-limit);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Wipe the cache and re-populate from .apc/messages/*. Reads BOTH `.jsonl`
|
|
396
|
+
// (current format) and `.md` (legacy). Called by rebuild.
|
|
397
|
+
export function rebuildMessagesFromFs(db, projectRoot) {
|
|
398
|
+
const dir = path.join(projectRoot, ".apc", "messages");
|
|
399
|
+
if (!fs.existsSync(dir)) return { count: 0 };
|
|
400
|
+
db.prepare("DELETE FROM messages").run();
|
|
401
|
+
|
|
402
|
+
// Collect every line from every .jsonl + .md, parse, sort by ts so the
|
|
403
|
+
// SQL row ids end up in the right order.
|
|
404
|
+
const all = [];
|
|
405
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
406
|
+
const full = path.join(dir, f);
|
|
407
|
+
const text = fs.readFileSync(full, "utf8");
|
|
408
|
+
if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) {
|
|
409
|
+
all.push(...parseDayJsonl(text));
|
|
410
|
+
} else if (/^\d{4}-\d{2}-\d{2}\.md$/.test(f)) {
|
|
411
|
+
all.push(...parseDayFile(text));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
all.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
415
|
+
|
|
416
|
+
const tx = db.transaction(() => {
|
|
417
|
+
for (const m of all) insertMessageRow(db, m);
|
|
418
|
+
});
|
|
419
|
+
tx();
|
|
420
|
+
return { count: all.length };
|
|
421
|
+
}
|