@agentprojectcontext/apx 1.69.0 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_obsidian.js +28 -0
  3. package/src/core/agent/tools/handlers/call-agent.js +7 -1
  4. package/src/core/agent/tools/handlers/obsidian-list-notes.js +28 -0
  5. package/src/core/agent/tools/handlers/obsidian-read-note.js +31 -0
  6. package/src/core/agent/tools/handlers/obsidian-search-notes.js +30 -0
  7. package/src/core/agent/tools/handlers/obsidian-write-note.js +38 -0
  8. package/src/core/agent/tools/names.js +10 -0
  9. package/src/core/agent/tools/registry.js +8 -0
  10. package/src/core/channels/telegram/dispatch.js +3 -2
  11. package/src/core/integrations/catalog.js +6 -3
  12. package/src/core/integrations/index.js +2 -0
  13. package/src/core/integrations/mcp-sync.js +71 -0
  14. package/src/core/integrations/obsidian-memory.js +179 -0
  15. package/src/core/integrations/plugins/obsidian.js +339 -0
  16. package/src/core/mcp/runner.js +54 -20
  17. package/src/core/memory/broker.js +11 -3
  18. package/src/core/memory/index.js +62 -2
  19. package/src/core/memory/indexer.js +88 -1
  20. package/src/core/memory/store.js +35 -3
  21. package/src/host/daemon/api/integrations.js +27 -2
  22. package/src/host/daemon/api/mcps.js +2 -2
  23. package/src/host/daemon/index.js +7 -1
  24. package/src/interfaces/cli/commands/obsidian.js +79 -0
  25. package/src/interfaces/cli/index.js +48 -1
  26. package/src/interfaces/web/dist/assets/index-CDz9OwCP.css +1 -0
  27. package/src/interfaces/web/dist/assets/index-CX15mZXM.js +798 -0
  28. package/src/interfaces/web/dist/assets/index-CX15mZXM.js.map +1 -0
  29. package/src/interfaces/web/dist/index.html +2 -2
  30. package/src/interfaces/web/src/components/common/TabNav.tsx +10 -3
  31. package/src/interfaces/web/src/components/integrations/BrandLogos.tsx +29 -0
  32. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +5 -4
  33. package/src/interfaces/web/src/components/integrations/FolderInput.tsx +137 -0
  34. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +129 -10
  35. package/src/interfaces/web/src/i18n/en.ts +21 -0
  36. package/src/interfaces/web/src/i18n/es.ts +21 -0
  37. package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
  38. package/src/interfaces/web/src/screens/ProjectScreen.tsx +12 -2
  39. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js +0 -803
  40. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js.map +0 -1
  41. package/src/interfaces/web/dist/assets/index-xQYf6_ab.css +0 -1
@@ -0,0 +1,339 @@
1
+ // Obsidian integration plugin — the "credential" is a local Vault (a directory
2
+ // of Markdown notes), not a remote API token. Unlike Asana/GitHub there is no
3
+ // provider to call: the plugin's config is a filesystem `vault_path` plus a
4
+ // couple of toggles. This same module doubles as a pure vault client that the
5
+ // agent tools (obsidian-*.js) call to read/search/write notes directly on disk
6
+ // — no external process, works offline, fully under APX's control.
7
+ //
8
+ // Two extra hooks beyond the standard lifecycle contract:
9
+ // - mcpServer(record): the generic MCP-reconcile hook the daemon uses to
10
+ // auto-register (opt-in) a community Obsidian MCP pointing at the vault.
11
+ // - actions.sync_memory(record, ctx): mirrors APX memory into the vault.
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ // ─── path helpers ───────────────────────────────────────────────────────────
17
+
18
+ // Expand a leading `~` to the user's home dir (Obsidian users type paths by hand).
19
+ export function expandHome(p) {
20
+ const s = String(p || "");
21
+ if (s === "~") return os.homedir();
22
+ if (s.startsWith("~/") || s.startsWith("~\\")) return path.join(os.homedir(), s.slice(2));
23
+ return s;
24
+ }
25
+
26
+ // Turn a user-supplied vault path into a resolved absolute path.
27
+ export function resolveVaultPath(raw) {
28
+ const expanded = expandHome(String(raw || "").trim());
29
+ if (!expanded) throw new Error("Vault path is empty");
30
+ return path.resolve(expanded);
31
+ }
32
+
33
+ // A directory is a "real" Obsidian vault when it holds a `.obsidian` config
34
+ // folder. We don't require it — a plain folder of markdown works too — but we
35
+ // report it so the UI can confirm the pick.
36
+ export function isObsidianVault(vaultPath) {
37
+ try {
38
+ return fs.statSync(path.join(vaultPath, ".obsidian")).isDirectory();
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ // Throw unless `vaultPath` points at an existing directory.
45
+ export function assertVaultDir(vaultPath) {
46
+ let st;
47
+ try {
48
+ st = fs.statSync(vaultPath);
49
+ } catch {
50
+ throw new Error(`Vault path does not exist: ${vaultPath}`);
51
+ }
52
+ if (!st.isDirectory()) throw new Error(`Vault path is not a directory: ${vaultPath}`);
53
+ return true;
54
+ }
55
+
56
+ // Append `entry` to <root>/.gitignore unless already present. Idempotent.
57
+ function ensureGitignore(root, entry) {
58
+ const gi = path.join(root, ".gitignore");
59
+ let text = "";
60
+ try {
61
+ text = fs.readFileSync(gi, "utf8");
62
+ } catch {
63
+ /* no .gitignore yet */
64
+ }
65
+ const bare = entry.replace(/\/$/, "");
66
+ const present = text.split("\n").some((l) => {
67
+ const t = l.trim();
68
+ return t === entry || t === bare;
69
+ });
70
+ if (present) return false;
71
+ const prefix = text && !text.endsWith("\n") ? `${text}\n` : text;
72
+ fs.writeFileSync(gi, `${prefix}${entry}\n`);
73
+ return true;
74
+ }
75
+
76
+ // Turn a project folder into an Obsidian vault so "the project IS the vault":
77
+ // create a minimal `.obsidian/` config (makes the app recognize it) and
78
+ // gitignore that config so per-machine vault settings never land in the repo.
79
+ // Idempotent — safe to call on every activation. Returns { created, gitignored }.
80
+ // NOTE: building block for auto-vault-on-activate — not yet wired into the
81
+ // plugin lifecycle (that changes the configure contract + writes into repos;
82
+ // pending an explicit product call on global-vs-per-project).
83
+ export function ensureProjectVault(projectRoot) {
84
+ const root = path.resolve(projectRoot);
85
+ assertVaultDir(root);
86
+ const dot = path.join(root, ".obsidian");
87
+ let created = false;
88
+ if (!fs.existsSync(dot)) {
89
+ fs.mkdirSync(dot, { recursive: true });
90
+ fs.writeFileSync(path.join(dot, "app.json"), "{}\n");
91
+ created = true;
92
+ }
93
+ return { created, gitignored: ensureGitignore(root, ".obsidian/") };
94
+ }
95
+
96
+ // Normalize a user-supplied note reference to an absolute `.md` path inside the
97
+ // vault, refusing anything that escapes the vault root (path-traversal guard).
98
+ function noteAbs(vaultPath, note) {
99
+ let rel = String(note || "").trim();
100
+ if (!rel) throw new Error("Note path is required");
101
+ rel = rel.replace(/\\/g, "/").replace(/^\/+/, "");
102
+ if (!/\.md$/i.test(rel)) rel += ".md";
103
+ const root = path.resolve(vaultPath);
104
+ const abs = path.resolve(root, rel);
105
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
106
+ throw new Error(`Note path escapes the vault: ${note}`);
107
+ }
108
+ return abs;
109
+ }
110
+
111
+ // ─── vault client (used by the agent tools) ─────────────────────────────────
112
+
113
+ // Recursively list markdown notes as vault-relative POSIX paths. Skips dotfiles
114
+ // and dot-dirs (`.obsidian`, `.trash`, …).
115
+ export function listNotes(vaultPath, { limit = 1000 } = {}) {
116
+ assertVaultDir(vaultPath);
117
+ const root = path.resolve(vaultPath);
118
+ const out = [];
119
+ const walk = (dir) => {
120
+ if (out.length >= limit) return;
121
+ let entries;
122
+ try {
123
+ entries = fs.readdirSync(dir, { withFileTypes: true });
124
+ } catch {
125
+ return;
126
+ }
127
+ for (const e of entries) {
128
+ if (out.length >= limit) return;
129
+ if (e.name.startsWith(".")) continue;
130
+ const abs = path.join(dir, e.name);
131
+ if (e.isDirectory()) walk(abs);
132
+ else if (/\.md$/i.test(e.name)) out.push(path.relative(root, abs).split(path.sep).join("/"));
133
+ }
134
+ };
135
+ walk(root);
136
+ out.sort();
137
+ return out;
138
+ }
139
+
140
+ export function readNote(vaultPath, note) {
141
+ assertVaultDir(vaultPath);
142
+ const abs = noteAbs(vaultPath, note);
143
+ if (!fs.existsSync(abs)) throw new Error(`Note not found: ${note}`);
144
+ return fs.readFileSync(abs, "utf8");
145
+ }
146
+
147
+ // Create or update a note. `mode`: "overwrite" (default) | "append".
148
+ export function writeNote(vaultPath, note, content, { mode = "overwrite" } = {}) {
149
+ assertVaultDir(vaultPath);
150
+ const abs = noteAbs(vaultPath, note);
151
+ const body = String(content ?? "");
152
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
153
+ if (mode === "append" && fs.existsSync(abs)) {
154
+ const prev = fs.readFileSync(abs, "utf8");
155
+ fs.writeFileSync(abs, prev + (prev.endsWith("\n") ? "" : "\n") + body);
156
+ } else {
157
+ fs.writeFileSync(abs, body);
158
+ }
159
+ const root = path.resolve(vaultPath);
160
+ return {
161
+ note: path.relative(root, abs).split(path.sep).join("/"),
162
+ bytes: Buffer.byteLength(body),
163
+ mode,
164
+ };
165
+ }
166
+
167
+ // Dependency-free substring search across note bodies + filenames. Returns a
168
+ // short context snippet per hit.
169
+ export function searchNotes(vaultPath, query, { limit = 50, caseSensitive = false } = {}) {
170
+ assertVaultDir(vaultPath);
171
+ const q = String(query || "").trim();
172
+ if (!q) throw new Error("Search query is required");
173
+ const needle = caseSensitive ? q : q.toLowerCase();
174
+ const notes = listNotes(vaultPath, { limit: 10000 });
175
+ const results = [];
176
+ for (const rel of notes) {
177
+ if (results.length >= limit) break;
178
+ let body;
179
+ try {
180
+ body = fs.readFileSync(path.join(vaultPath, rel), "utf8");
181
+ } catch {
182
+ continue;
183
+ }
184
+ const hay = caseSensitive ? body : body.toLowerCase();
185
+ const nameHit = (caseSensitive ? rel : rel.toLowerCase()).includes(needle);
186
+ const idx = hay.indexOf(needle);
187
+ if (idx === -1 && !nameHit) continue;
188
+ let snippet = "";
189
+ if (idx !== -1) {
190
+ const start = Math.max(0, idx - 40);
191
+ const end = Math.min(body.length, idx + needle.length + 40);
192
+ snippet =
193
+ (start > 0 ? "…" : "") +
194
+ body.slice(start, end).replace(/\s+/g, " ").trim() +
195
+ (end < body.length ? "…" : "");
196
+ }
197
+ results.push({ note: rel, snippet, title_match: nameHit });
198
+ }
199
+ return results;
200
+ }
201
+
202
+ // ─── plugin descriptor + lifecycle ──────────────────────────────────────────
203
+
204
+ function asBool(v, fallback) {
205
+ if (v === undefined || v === null) return fallback;
206
+ return v === true || v === "true" || v === "on" || v === 1 || v === "1";
207
+ }
208
+
209
+ export const obsidianPlugin = {
210
+ slug: "obsidian",
211
+ name: "Obsidian",
212
+ type: "knowledge",
213
+ description:
214
+ "Conectá un Vault de Obsidian para que los agentes lean, busquen y escriban notas",
215
+ auth: "path",
216
+ tools: [
217
+ { slug: "obsidian_search_notes", desc: "Buscar notas en el Vault" },
218
+ { slug: "obsidian_read_note", desc: "Leer una nota" },
219
+ { slug: "obsidian_write_note", desc: "Crear o actualizar una nota" },
220
+ { slug: "obsidian_list_notes", desc: "Listar notas del Vault" },
221
+ ],
222
+ // Structure only — display text lives in web i18n (integrations.obsidian.*).
223
+ ui: {
224
+ accent: "purple",
225
+ configFields: [
226
+ { key: "vault_path", type: "path", placeholder: "/Users/tu-usuario/Obsidian/MiVault" },
227
+ { key: "auto_mcp", type: "toggle", default: false },
228
+ { key: "memory_sync", type: "toggle", default: false },
229
+ ],
230
+ connectedFields: ["vault_path", "vault_name", "note_count"],
231
+ // Buttons shown in the connected view (label from i18n).
232
+ actions: [{ action: "sync_memory" }],
233
+ },
234
+
235
+ configure(record, body = {}) {
236
+ const rawPath = String(body.vault_path ?? record?.config?.vault_path ?? "").trim();
237
+ if (!rawPath) throw new Error("Provide the path to your Obsidian vault");
238
+ const config = {
239
+ vault_path: rawPath,
240
+ auto_mcp: asBool(body.auto_mcp, record?.config?.auto_mcp ?? false),
241
+ memory_sync: asBool(body.memory_sync, record?.config?.memory_sync ?? false),
242
+ };
243
+ return {
244
+ patch: {
245
+ name: this.name,
246
+ type: this.type,
247
+ description: this.description,
248
+ status: "pending_validation",
249
+ config,
250
+ },
251
+ };
252
+ },
253
+
254
+ async validate(record) {
255
+ const config = record?.config || {};
256
+ let vaultPath;
257
+ try {
258
+ vaultPath = resolveVaultPath(config.vault_path);
259
+ assertVaultDir(vaultPath);
260
+ } catch (e) {
261
+ return {
262
+ patch: { status: "error", is_enabled: false, config: { last_error: String(e.message || e) } },
263
+ result: { ok: false, error: String(e.message || e) },
264
+ };
265
+ }
266
+ const isVault = isObsidianVault(vaultPath);
267
+ const noteCount = listNotes(vaultPath, { limit: 10000 }).length;
268
+ const vaultName = path.basename(vaultPath);
269
+ // Persist the resolved absolute path so downstream consumers (tools, MCP,
270
+ // sync) never re-expand `~`.
271
+ return {
272
+ patch: {
273
+ status: "active",
274
+ is_enabled: true,
275
+ config: {
276
+ vault_path: vaultPath,
277
+ vault_name: vaultName,
278
+ is_vault: isVault,
279
+ note_count: noteCount,
280
+ last_error: null,
281
+ },
282
+ },
283
+ result: { ok: true, vault_path: vaultPath, vault_name: vaultName, is_vault: isVault, note_count: noteCount },
284
+ };
285
+ },
286
+
287
+ status(record) {
288
+ const c = record?.config || {};
289
+ return {
290
+ slug: this.slug,
291
+ status: record?.status || "disconnected",
292
+ is_enabled: !!record?.is_enabled,
293
+ vault_path: c.vault_path || null,
294
+ vault_name: c.vault_name || null,
295
+ note_count: c.note_count ?? null,
296
+ is_vault: c.is_vault ?? null,
297
+ auto_mcp: !!c.auto_mcp,
298
+ memory_sync: !!c.memory_sync,
299
+ };
300
+ },
301
+
302
+ deactivate() {
303
+ return { patch: { status: "inactive", is_enabled: false } };
304
+ },
305
+
306
+ // Generic MCP-reconcile hook (see host/daemon/api/integrations.js). Returns
307
+ // the desired `{ name, def }` for a community Obsidian MCP when the vault is
308
+ // active AND the user opted into `auto_mcp`; otherwise `def: null` so the
309
+ // reconcile removes any stale server. Command/args overridable via config.
310
+ mcpServer(record) {
311
+ const c = record?.config || {};
312
+ const active = record?.status === "active" && !!record?.is_enabled && !!c.auto_mcp && !!c.vault_path;
313
+ if (!active) return { name: "obsidian", def: null };
314
+ const command = c.mcp_command || "npx";
315
+ const args =
316
+ Array.isArray(c.mcp_args) && c.mcp_args.length ? c.mcp_args : ["-y", "obsidian-mcp", c.vault_path];
317
+ return { name: "obsidian", def: { command, args, enabled: true } };
318
+ },
319
+
320
+ actions: {
321
+ // Quick connectivity probe (web refresh / smoke test): list a few notes.
322
+ async notes(record) {
323
+ const vaultPath = resolveVaultPath(record?.config?.vault_path);
324
+ const notes = listNotes(vaultPath, { limit: 25 });
325
+ return { notes, count: listNotes(vaultPath, { limit: 10000 }).length, sample: notes };
326
+ },
327
+ // Mirror APX memory into the vault. Needs project context (all projects +
328
+ // the current one), passed as the 2nd arg by the daemon action dispatch.
329
+ async sync_memory(record, ctx = {}) {
330
+ const vaultPath = resolveVaultPath(record?.config?.vault_path);
331
+ const folder = record?.config?.memory_folder || "APX";
332
+ const { syncMemoryToVault, collectMemorySources } = await import("../obsidian-memory.js");
333
+ const sources = collectMemorySources(ctx);
334
+ return syncMemoryToVault({ vaultPath, folder, sources });
335
+ },
336
+ },
337
+ };
338
+
339
+ export default obsidianPlugin;
@@ -18,6 +18,20 @@ function nowIso() {
18
18
  return new Date().toISOString();
19
19
  }
20
20
 
21
+ // Serializes cold-starts that share an npm/npx cache dir. Two MCP processes
22
+ // with the same command+args but different env — e.g. the same
23
+ // `npx @ahdev/dokploy-mcp@1.6.0` registered in two projects pointing at
24
+ // different DOKPLOY_URLs — resolve to ONE shared npm cache dir
25
+ // (~/.npm/_npx/<hash>). npm does not guard that dir against concurrent writes,
26
+ // so overlapping cold-starts corrupt it (ENOTEMPTY / partial node_modules) and
27
+ // the server dies on start, which respawns and corrupts it again. Chaining
28
+ // start-ups by spec keeps the cache-touching phase from ever overlapping.
29
+ const startGates = new Map(); // spec key -> promise resolved when current starter finishes
30
+
31
+ function startKey(command, args) {
32
+ return JSON.stringify([command, args || []]);
33
+ }
34
+
21
35
  class McpProcess {
22
36
  constructor({ name, command, args = [], env = {} }) {
23
37
  this.name = name;
@@ -127,30 +141,50 @@ class McpProcess {
127
141
  async _ensureInitialized() {
128
142
  if (this._initialized) return;
129
143
  if (!this._initPromise) {
130
- this._initPromise = (async () => {
131
- await this._send(
132
- "initialize",
133
- {
134
- protocolVersion: "2024-11-05",
135
- capabilities: {},
136
- clientInfo: { name: "apx-daemon", version: "0.1.0" },
137
- },
138
- 10_000
139
- );
140
- try {
141
- this.proc.stdin.write(
142
- JSON.stringify({
143
- jsonrpc: "2.0",
144
- method: "notifications/initialized",
145
- }) + "\n"
146
- );
147
- } catch {}
148
- this._initialized = true;
149
- })();
144
+ this._initPromise = this._gatedInit();
150
145
  }
151
146
  return this._initPromise;
152
147
  }
153
148
 
149
+ // Acquire the per-spec start gate around the whole (spawn + npm cache work +
150
+ // JSON-RPC initialize) sequence so processes sharing an npx cache never
151
+ // cold-start concurrently. See startGates.
152
+ async _gatedInit() {
153
+ const key = startKey(this.command, this.args);
154
+ const prev = startGates.get(key) || Promise.resolve();
155
+ let release;
156
+ const gate = new Promise((r) => (release = r));
157
+ // The next starter of this spec waits on us — whether we succeed or fail.
158
+ startGates.set(key, prev.then(() => gate, () => gate));
159
+ try {
160
+ await prev.catch(() => {});
161
+ await this._doInitialize();
162
+ } finally {
163
+ release();
164
+ }
165
+ }
166
+
167
+ async _doInitialize() {
168
+ await this._send(
169
+ "initialize",
170
+ {
171
+ protocolVersion: "2024-11-05",
172
+ capabilities: {},
173
+ clientInfo: { name: "apx-daemon", version: "0.1.0" },
174
+ },
175
+ 10_000
176
+ );
177
+ try {
178
+ this.proc.stdin.write(
179
+ JSON.stringify({
180
+ jsonrpc: "2.0",
181
+ method: "notifications/initialized",
182
+ }) + "\n"
183
+ );
184
+ } catch {}
185
+ this._initialized = true;
186
+ }
187
+
154
188
  async listTools() {
155
189
  await this._ensureInitialized();
156
190
  return collectToolPages((cursor) =>
@@ -87,10 +87,18 @@ export async function buildMemoryBlock(message, opts = {}) {
87
87
  const budgetMs = opts.budgetMs || DEFAULT_BUDGET_MS;
88
88
  const topK = opts.topK || DEFAULT_TOP_K;
89
89
  const store = opts.store || null;
90
+ // Scope isolation: the super-agent recalls only global rows ("global"), a
91
+ // project/agent turn recalls only its own — a single channel or an array of
92
+ // channels (["agent:…","project:…"]).
93
+ const scope = opts.scope || "global";
94
+ // The flat notebook slice is always included for the super-agent, but a
95
+ // project-agent turn already gets its own memory.md injected elsewhere, so it
96
+ // opts out (includeFlat:false) to keep this block RAG-only.
97
+ const includeFlat = opts.includeFlat !== false;
90
98
  const query = clean(message);
91
99
 
92
100
  // memory.md entries are read synchronously and always make the deadline.
93
- const memEntries = lastMemoryEntries(memoryPath, 10);
101
+ const memEntries = includeFlat ? lastMemoryEntries(memoryPath, 10) : [];
94
102
 
95
103
  // RAG retrieval is the slow part — race it against the budget.
96
104
  let hits = [];
@@ -99,7 +107,7 @@ export async function buildMemoryBlock(message, opts = {}) {
99
107
  const { vector, embedder, dim } = await embedOne(query, opts.embed || {});
100
108
  const family = embedder.startsWith("ollama") ? "ollama" : "tf";
101
109
  const floor = MIN_SCORE[family] ?? 0;
102
- const results = store.search(vector, { embedder, k: topK + 3 });
110
+ const results = store.search(vector, { embedder, k: topK + 3, scope });
103
111
  return results.filter((r) => r.score >= floor && (r.dim ?? dim) === dim);
104
112
  })();
105
113
  hits = await withTimeout(rag, budgetMs, []);
@@ -132,7 +140,7 @@ export async function buildMemoryBlock(message, opts = {}) {
132
140
  if (bullets.length === 0) return "";
133
141
 
134
142
  return [
135
- "# Relevant memory (cross-channel)",
143
+ `# ${opts.heading || "Relevant memory (cross-channel)"}`,
136
144
  "Context recovered from your notebook and from the message log across channels.",
137
145
  "Treat these as known facts. If a fresh session opens and something here is still",
138
146
  "open, bring it up naturally in the user's language (e.g. \"yesterday we were on X — shall we continue?\") without being asked.",
@@ -14,6 +14,7 @@
14
14
  import path from "node:path";
15
15
  import { APX_HOME } from "../config/index.js";
16
16
  import { ensureSelfMemoryFile } from "../agent/self-memory.js";
17
+ import { agentRuntimeDir } from "../agent/memory.js";
17
18
  import fs from "node:fs";
18
19
  import { openMemoryStore } from "./store.js";
19
20
  import { indexNewMessages, CURSOR_PATH } from "./indexer.js";
@@ -30,14 +31,17 @@ let _store = null;
30
31
  let _ready = null;
31
32
  let _timer = null;
32
33
  let _cfg = {};
34
+ let _projects = null;
33
35
  let _indexing = false;
34
36
 
35
37
  // Run one index pass unless one is already in flight (a full re-embed can take
36
38
  // longer than the timer interval — overlapping passes would race on clear()).
39
+ // `_projects` (the daemon registry) lets the indexer reach each project's
40
+ // .apc/memory.md; agent memory is walked straight off the filesystem.
37
41
  function indexOnce(note) {
38
42
  if (_indexing || !_store) return Promise.resolve();
39
43
  _indexing = true;
40
- return indexNewMessages(_store, { embed: embedOptsFromConfig(_cfg), log: note })
44
+ return indexNewMessages(_store, { embed: embedOptsFromConfig(_cfg), projects: _projects, log: note })
41
45
  .catch(() => {})
42
46
  .finally(() => {
43
47
  _indexing = false;
@@ -58,7 +62,7 @@ function embedOptsFromConfig(config) {
58
62
 
59
63
  // Boot the subsystem (Pieza 1 file creation + Pieza 2 store/index). Safe to
60
64
  // call once from the daemon. Never throws.
61
- export async function initMemory({ config, log } = {}) {
65
+ export async function initMemory({ config, log, projects } = {}) {
62
66
  const note = typeof log === "function" ? log : () => {};
63
67
  try {
64
68
  const created = ensureSelfMemoryFile();
@@ -71,6 +75,7 @@ export async function initMemory({ config, log } = {}) {
71
75
  return null;
72
76
  }
73
77
  _cfg = config || {};
78
+ _projects = projects || null;
74
79
  _ready = (async () => {
75
80
  try {
76
81
  _store = await openMemoryStore({ dbPath: DB_PATH, jsonPath: JSON_PATH, log: note });
@@ -151,6 +156,61 @@ export async function memoryBlockFor(message, { config, channel, budgetMs } = {}
151
156
  store,
152
157
  config,
153
158
  channel,
159
+ scope: "global", // super-agent recall — never pulls project/agent rows
160
+ budgetMs: budgetMs || config?.memory?.broker_budget_ms || 800,
161
+ topK: config?.memory?.rag_top_k || 5,
162
+ embed: embedOptsFromConfig(config),
163
+ });
164
+ } catch {
165
+ return "";
166
+ }
167
+ }
168
+
169
+ // Scoped recall for a specific project or agent turn. `scope` is the row channel
170
+ // key: "project:<id>" or "agent:<projdir>:<slug>" (or an array of them).
171
+ // `memoryPath` (optional) is that scope's own notebook, read as the flat slice.
172
+ // Returns "" on any failure so the prompt builder can drop the block.
173
+ export async function scopedMemoryBlockFor(message, { scope, memoryPath, config, budgetMs } = {}) {
174
+ try {
175
+ if (!scope || !memoryEnabled(config)) return "";
176
+ const store = await getMemoryStore();
177
+ return await buildMemoryBlock(message, {
178
+ store,
179
+ config,
180
+ scope,
181
+ memoryPath,
182
+ budgetMs: budgetMs || config?.memory?.broker_budget_ms || 800,
183
+ topK: config?.memory?.rag_top_k || 5,
184
+ embed: embedOptsFromConfig(config),
185
+ });
186
+ } catch {
187
+ return "";
188
+ }
189
+ }
190
+
191
+ // Consumer for a project-agent turn (Pieza 5). Retrieves the agent's OWN memory
192
+ // plus its project's memory in one scoped query, isolated from every other
193
+ // agent/project and from the super-agent's global recall. RAG-only (the agent's
194
+ // flat memory.md is already injected by buildAgentSystem). Returns a `# Relevant
195
+ // memory (this agent & project)` block, or "" on disabled/empty/error — so the
196
+ // caller can just append it to `extraParts`.
197
+ export async function agentScopedMemoryBlock(message, { project, agent, config, budgetMs } = {}) {
198
+ try {
199
+ if (!message || !agent?.slug || !memoryEnabled(config)) return "";
200
+ const store = await getMemoryStore();
201
+ if (!store) return "";
202
+ // The storage root is <APX_HOME>/projects/<apxId>; agent rows are indexed
203
+ // under channel "agent:<apxId>:<slug>" — derive apxId the same way the
204
+ // indexer's filesystem walk names it, straight from agentRuntimeDir.
205
+ const apxId = path.basename(path.dirname(path.dirname(agentRuntimeDir(project, agent.slug))));
206
+ const scope = [`agent:${apxId}:${agent.slug}`];
207
+ if (project?.id != null && String(project.id) !== "0") scope.push(`project:${project.id}`);
208
+ return await buildMemoryBlock(message, {
209
+ store,
210
+ config,
211
+ scope,
212
+ includeFlat: false,
213
+ heading: "Relevant memory (this agent & project)",
154
214
  budgetMs: budgetMs || config?.memory?.broker_budget_ms || 800,
155
215
  topK: config?.memory?.rag_top_k || 5,
156
216
  embed: embedOptsFromConfig(config),