@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,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
+ }