@agentprojectcontext/apx 1.69.0 → 1.70.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 (36) 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/obsidian-list-notes.js +28 -0
  4. package/src/core/agent/tools/handlers/obsidian-read-note.js +31 -0
  5. package/src/core/agent/tools/handlers/obsidian-search-notes.js +30 -0
  6. package/src/core/agent/tools/handlers/obsidian-write-note.js +38 -0
  7. package/src/core/agent/tools/names.js +10 -0
  8. package/src/core/agent/tools/registry.js +8 -0
  9. package/src/core/integrations/catalog.js +6 -3
  10. package/src/core/integrations/index.js +2 -0
  11. package/src/core/integrations/mcp-sync.js +71 -0
  12. package/src/core/integrations/obsidian-memory.js +108 -0
  13. package/src/core/integrations/plugins/obsidian.js +299 -0
  14. package/src/core/mcp/runner.js +54 -20
  15. package/src/core/memory/broker.js +4 -1
  16. package/src/core/memory/index.js +29 -2
  17. package/src/core/memory/indexer.js +88 -1
  18. package/src/core/memory/store.js +23 -3
  19. package/src/host/daemon/api/integrations.js +27 -2
  20. package/src/host/daemon/api/mcps.js +2 -2
  21. package/src/host/daemon/index.js +7 -1
  22. package/src/interfaces/cli/commands/obsidian.js +79 -0
  23. package/src/interfaces/cli/index.js +48 -1
  24. package/src/interfaces/web/dist/assets/index-CDz9OwCP.css +1 -0
  25. package/src/interfaces/web/dist/assets/{index-_2zKBH4O.js → index-YFsZFhM6.js} +172 -177
  26. package/src/interfaces/web/dist/assets/index-YFsZFhM6.js.map +1 -0
  27. package/src/interfaces/web/dist/index.html +2 -2
  28. package/src/interfaces/web/src/components/integrations/BrandLogos.tsx +29 -0
  29. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +5 -4
  30. package/src/interfaces/web/src/components/integrations/FolderInput.tsx +137 -0
  31. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +129 -10
  32. package/src/interfaces/web/src/i18n/en.ts +21 -0
  33. package/src/interfaces/web/src/i18n/es.ts +21 -0
  34. package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
  35. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js.map +0 -1
  36. package/src/interfaces/web/dist/assets/index-xQYf6_ab.css +0 -1
@@ -0,0 +1,299 @@
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
+ // Normalize a user-supplied note reference to an absolute `.md` path inside the
57
+ // vault, refusing anything that escapes the vault root (path-traversal guard).
58
+ function noteAbs(vaultPath, note) {
59
+ let rel = String(note || "").trim();
60
+ if (!rel) throw new Error("Note path is required");
61
+ rel = rel.replace(/\\/g, "/").replace(/^\/+/, "");
62
+ if (!/\.md$/i.test(rel)) rel += ".md";
63
+ const root = path.resolve(vaultPath);
64
+ const abs = path.resolve(root, rel);
65
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
66
+ throw new Error(`Note path escapes the vault: ${note}`);
67
+ }
68
+ return abs;
69
+ }
70
+
71
+ // ─── vault client (used by the agent tools) ─────────────────────────────────
72
+
73
+ // Recursively list markdown notes as vault-relative POSIX paths. Skips dotfiles
74
+ // and dot-dirs (`.obsidian`, `.trash`, …).
75
+ export function listNotes(vaultPath, { limit = 1000 } = {}) {
76
+ assertVaultDir(vaultPath);
77
+ const root = path.resolve(vaultPath);
78
+ const out = [];
79
+ const walk = (dir) => {
80
+ if (out.length >= limit) return;
81
+ let entries;
82
+ try {
83
+ entries = fs.readdirSync(dir, { withFileTypes: true });
84
+ } catch {
85
+ return;
86
+ }
87
+ for (const e of entries) {
88
+ if (out.length >= limit) return;
89
+ if (e.name.startsWith(".")) continue;
90
+ const abs = path.join(dir, e.name);
91
+ if (e.isDirectory()) walk(abs);
92
+ else if (/\.md$/i.test(e.name)) out.push(path.relative(root, abs).split(path.sep).join("/"));
93
+ }
94
+ };
95
+ walk(root);
96
+ out.sort();
97
+ return out;
98
+ }
99
+
100
+ export function readNote(vaultPath, note) {
101
+ assertVaultDir(vaultPath);
102
+ const abs = noteAbs(vaultPath, note);
103
+ if (!fs.existsSync(abs)) throw new Error(`Note not found: ${note}`);
104
+ return fs.readFileSync(abs, "utf8");
105
+ }
106
+
107
+ // Create or update a note. `mode`: "overwrite" (default) | "append".
108
+ export function writeNote(vaultPath, note, content, { mode = "overwrite" } = {}) {
109
+ assertVaultDir(vaultPath);
110
+ const abs = noteAbs(vaultPath, note);
111
+ const body = String(content ?? "");
112
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
113
+ if (mode === "append" && fs.existsSync(abs)) {
114
+ const prev = fs.readFileSync(abs, "utf8");
115
+ fs.writeFileSync(abs, prev + (prev.endsWith("\n") ? "" : "\n") + body);
116
+ } else {
117
+ fs.writeFileSync(abs, body);
118
+ }
119
+ const root = path.resolve(vaultPath);
120
+ return {
121
+ note: path.relative(root, abs).split(path.sep).join("/"),
122
+ bytes: Buffer.byteLength(body),
123
+ mode,
124
+ };
125
+ }
126
+
127
+ // Dependency-free substring search across note bodies + filenames. Returns a
128
+ // short context snippet per hit.
129
+ export function searchNotes(vaultPath, query, { limit = 50, caseSensitive = false } = {}) {
130
+ assertVaultDir(vaultPath);
131
+ const q = String(query || "").trim();
132
+ if (!q) throw new Error("Search query is required");
133
+ const needle = caseSensitive ? q : q.toLowerCase();
134
+ const notes = listNotes(vaultPath, { limit: 10000 });
135
+ const results = [];
136
+ for (const rel of notes) {
137
+ if (results.length >= limit) break;
138
+ let body;
139
+ try {
140
+ body = fs.readFileSync(path.join(vaultPath, rel), "utf8");
141
+ } catch {
142
+ continue;
143
+ }
144
+ const hay = caseSensitive ? body : body.toLowerCase();
145
+ const nameHit = (caseSensitive ? rel : rel.toLowerCase()).includes(needle);
146
+ const idx = hay.indexOf(needle);
147
+ if (idx === -1 && !nameHit) continue;
148
+ let snippet = "";
149
+ if (idx !== -1) {
150
+ const start = Math.max(0, idx - 40);
151
+ const end = Math.min(body.length, idx + needle.length + 40);
152
+ snippet =
153
+ (start > 0 ? "…" : "") +
154
+ body.slice(start, end).replace(/\s+/g, " ").trim() +
155
+ (end < body.length ? "…" : "");
156
+ }
157
+ results.push({ note: rel, snippet, title_match: nameHit });
158
+ }
159
+ return results;
160
+ }
161
+
162
+ // ─── plugin descriptor + lifecycle ──────────────────────────────────────────
163
+
164
+ function asBool(v, fallback) {
165
+ if (v === undefined || v === null) return fallback;
166
+ return v === true || v === "true" || v === "on" || v === 1 || v === "1";
167
+ }
168
+
169
+ export const obsidianPlugin = {
170
+ slug: "obsidian",
171
+ name: "Obsidian",
172
+ type: "knowledge",
173
+ description:
174
+ "Conectá un Vault de Obsidian para que los agentes lean, busquen y escriban notas",
175
+ auth: "path",
176
+ tools: [
177
+ { slug: "obsidian_search_notes", desc: "Buscar notas en el Vault" },
178
+ { slug: "obsidian_read_note", desc: "Leer una nota" },
179
+ { slug: "obsidian_write_note", desc: "Crear o actualizar una nota" },
180
+ { slug: "obsidian_list_notes", desc: "Listar notas del Vault" },
181
+ ],
182
+ // Structure only — display text lives in web i18n (integrations.obsidian.*).
183
+ ui: {
184
+ accent: "purple",
185
+ configFields: [
186
+ { key: "vault_path", type: "path", placeholder: "/Users/tu-usuario/Obsidian/MiVault" },
187
+ { key: "auto_mcp", type: "toggle", default: false },
188
+ { key: "memory_sync", type: "toggle", default: false },
189
+ ],
190
+ connectedFields: ["vault_path", "vault_name", "note_count"],
191
+ // Buttons shown in the connected view (label from i18n).
192
+ actions: [{ action: "sync_memory" }],
193
+ },
194
+
195
+ configure(record, body = {}) {
196
+ const rawPath = String(body.vault_path ?? record?.config?.vault_path ?? "").trim();
197
+ if (!rawPath) throw new Error("Provide the path to your Obsidian vault");
198
+ const config = {
199
+ vault_path: rawPath,
200
+ auto_mcp: asBool(body.auto_mcp, record?.config?.auto_mcp ?? false),
201
+ memory_sync: asBool(body.memory_sync, record?.config?.memory_sync ?? false),
202
+ };
203
+ return {
204
+ patch: {
205
+ name: this.name,
206
+ type: this.type,
207
+ description: this.description,
208
+ status: "pending_validation",
209
+ config,
210
+ },
211
+ };
212
+ },
213
+
214
+ async validate(record) {
215
+ const config = record?.config || {};
216
+ let vaultPath;
217
+ try {
218
+ vaultPath = resolveVaultPath(config.vault_path);
219
+ assertVaultDir(vaultPath);
220
+ } catch (e) {
221
+ return {
222
+ patch: { status: "error", is_enabled: false, config: { last_error: String(e.message || e) } },
223
+ result: { ok: false, error: String(e.message || e) },
224
+ };
225
+ }
226
+ const isVault = isObsidianVault(vaultPath);
227
+ const noteCount = listNotes(vaultPath, { limit: 10000 }).length;
228
+ const vaultName = path.basename(vaultPath);
229
+ // Persist the resolved absolute path so downstream consumers (tools, MCP,
230
+ // sync) never re-expand `~`.
231
+ return {
232
+ patch: {
233
+ status: "active",
234
+ is_enabled: true,
235
+ config: {
236
+ vault_path: vaultPath,
237
+ vault_name: vaultName,
238
+ is_vault: isVault,
239
+ note_count: noteCount,
240
+ last_error: null,
241
+ },
242
+ },
243
+ result: { ok: true, vault_path: vaultPath, vault_name: vaultName, is_vault: isVault, note_count: noteCount },
244
+ };
245
+ },
246
+
247
+ status(record) {
248
+ const c = record?.config || {};
249
+ return {
250
+ slug: this.slug,
251
+ status: record?.status || "disconnected",
252
+ is_enabled: !!record?.is_enabled,
253
+ vault_path: c.vault_path || null,
254
+ vault_name: c.vault_name || null,
255
+ note_count: c.note_count ?? null,
256
+ is_vault: c.is_vault ?? null,
257
+ auto_mcp: !!c.auto_mcp,
258
+ memory_sync: !!c.memory_sync,
259
+ };
260
+ },
261
+
262
+ deactivate() {
263
+ return { patch: { status: "inactive", is_enabled: false } };
264
+ },
265
+
266
+ // Generic MCP-reconcile hook (see host/daemon/api/integrations.js). Returns
267
+ // the desired `{ name, def }` for a community Obsidian MCP when the vault is
268
+ // active AND the user opted into `auto_mcp`; otherwise `def: null` so the
269
+ // reconcile removes any stale server. Command/args overridable via config.
270
+ mcpServer(record) {
271
+ const c = record?.config || {};
272
+ const active = record?.status === "active" && !!record?.is_enabled && !!c.auto_mcp && !!c.vault_path;
273
+ if (!active) return { name: "obsidian", def: null };
274
+ const command = c.mcp_command || "npx";
275
+ const args =
276
+ Array.isArray(c.mcp_args) && c.mcp_args.length ? c.mcp_args : ["-y", "obsidian-mcp", c.vault_path];
277
+ return { name: "obsidian", def: { command, args, enabled: true } };
278
+ },
279
+
280
+ actions: {
281
+ // Quick connectivity probe (web refresh / smoke test): list a few notes.
282
+ async notes(record) {
283
+ const vaultPath = resolveVaultPath(record?.config?.vault_path);
284
+ const notes = listNotes(vaultPath, { limit: 25 });
285
+ return { notes, count: listNotes(vaultPath, { limit: 10000 }).length, sample: notes };
286
+ },
287
+ // Mirror APX memory into the vault. Needs project context (all projects +
288
+ // the current one), passed as the 2nd arg by the daemon action dispatch.
289
+ async sync_memory(record, ctx = {}) {
290
+ const vaultPath = resolveVaultPath(record?.config?.vault_path);
291
+ const folder = record?.config?.memory_folder || "APX";
292
+ const { syncMemoryToVault, collectMemorySources } = await import("../obsidian-memory.js");
293
+ const sources = collectMemorySources(ctx);
294
+ return syncMemoryToVault({ vaultPath, folder, sources });
295
+ },
296
+ },
297
+ };
298
+
299
+ 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,6 +87,9 @@ 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 ("project:<id>" / "agent:…").
92
+ const scope = opts.scope || "global";
90
93
  const query = clean(message);
91
94
 
92
95
  // memory.md entries are read synchronously and always make the deadline.
@@ -99,7 +102,7 @@ export async function buildMemoryBlock(message, opts = {}) {
99
102
  const { vector, embedder, dim } = await embedOne(query, opts.embed || {});
100
103
  const family = embedder.startsWith("ollama") ? "ollama" : "tf";
101
104
  const floor = MIN_SCORE[family] ?? 0;
102
- const results = store.search(vector, { embedder, k: topK + 3 });
105
+ const results = store.search(vector, { embedder, k: topK + 3, scope });
103
106
  return results.filter((r) => r.score >= floor && (r.dim ?? dim) === dim);
104
107
  })();
105
108
  hits = await withTimeout(rag, budgetMs, []);
@@ -30,14 +30,17 @@ let _store = null;
30
30
  let _ready = null;
31
31
  let _timer = null;
32
32
  let _cfg = {};
33
+ let _projects = null;
33
34
  let _indexing = false;
34
35
 
35
36
  // Run one index pass unless one is already in flight (a full re-embed can take
36
37
  // longer than the timer interval — overlapping passes would race on clear()).
38
+ // `_projects` (the daemon registry) lets the indexer reach each project's
39
+ // .apc/memory.md; agent memory is walked straight off the filesystem.
37
40
  function indexOnce(note) {
38
41
  if (_indexing || !_store) return Promise.resolve();
39
42
  _indexing = true;
40
- return indexNewMessages(_store, { embed: embedOptsFromConfig(_cfg), log: note })
43
+ return indexNewMessages(_store, { embed: embedOptsFromConfig(_cfg), projects: _projects, log: note })
41
44
  .catch(() => {})
42
45
  .finally(() => {
43
46
  _indexing = false;
@@ -58,7 +61,7 @@ function embedOptsFromConfig(config) {
58
61
 
59
62
  // Boot the subsystem (Pieza 1 file creation + Pieza 2 store/index). Safe to
60
63
  // call once from the daemon. Never throws.
61
- export async function initMemory({ config, log } = {}) {
64
+ export async function initMemory({ config, log, projects } = {}) {
62
65
  const note = typeof log === "function" ? log : () => {};
63
66
  try {
64
67
  const created = ensureSelfMemoryFile();
@@ -71,6 +74,7 @@ export async function initMemory({ config, log } = {}) {
71
74
  return null;
72
75
  }
73
76
  _cfg = config || {};
77
+ _projects = projects || null;
74
78
  _ready = (async () => {
75
79
  try {
76
80
  _store = await openMemoryStore({ dbPath: DB_PATH, jsonPath: JSON_PATH, log: note });
@@ -151,6 +155,29 @@ export async function memoryBlockFor(message, { config, channel, budgetMs } = {}
151
155
  store,
152
156
  config,
153
157
  channel,
158
+ scope: "global", // super-agent recall — never pulls project/agent rows
159
+ budgetMs: budgetMs || config?.memory?.broker_budget_ms || 800,
160
+ topK: config?.memory?.rag_top_k || 5,
161
+ embed: embedOptsFromConfig(config),
162
+ });
163
+ } catch {
164
+ return "";
165
+ }
166
+ }
167
+
168
+ // Scoped recall for a specific project or agent turn. `scope` is the row channel
169
+ // key: "project:<id>" or "agent:<projdir>:<slug>". `memoryPath` (optional) is
170
+ // that scope's own notebook, read as the always-included flat slice. Returns ""
171
+ // on any failure so the prompt builder can drop the block.
172
+ export async function scopedMemoryBlockFor(message, { scope, memoryPath, config, budgetMs } = {}) {
173
+ try {
174
+ if (!scope || !memoryEnabled(config)) return "";
175
+ const store = await getMemoryStore();
176
+ return await buildMemoryBlock(message, {
177
+ store,
178
+ config,
179
+ scope,
180
+ memoryPath,
154
181
  budgetMs: budgetMs || config?.memory?.broker_budget_ms || 800,
155
182
  topK: config?.memory?.rag_top_k || 5,
156
183
  embed: embedOptsFromConfig(config),
@@ -21,12 +21,14 @@ import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { GLOBAL_MESSAGES_DIR, APX_HOME } from "../config/index.js";
23
23
  import { SELF_MEMORY_PATH, parseSelfMemoryEntries } from "../agent/self-memory.js";
24
+ import { apcMemoryFile } from "../apc/paths.js";
24
25
  import { embedBatch, embedOne } from "./embeddings.js";
25
26
 
26
27
  export const CURSOR_PATH = path.join(APX_HOME, "memory-cursor.json");
27
28
 
28
29
  const BODY_CAP = 1200; // chars kept per user/agent chunk
29
30
  const TOOL_CAP = 400; // chars kept per tool-result chunk
31
+ const SCOPED_CAP = 800; // chars kept per project/agent memory block
30
32
 
31
33
  function fnv1aHex(str) {
32
34
  let h = 0x811c9dc5;
@@ -37,6 +39,14 @@ function fnv1aHex(str) {
37
39
  return (h >>> 0).toString(16);
38
40
  }
39
41
 
42
+ function readIfExists(p) {
43
+ try {
44
+ return fs.readFileSync(p, "utf8");
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
40
50
  function readCursor(cursorPath) {
41
51
  try {
42
52
  return JSON.parse(fs.readFileSync(cursorPath, "utf8"));
@@ -177,6 +187,78 @@ function collectMemoryChunks(store, memoryPath) {
177
187
  return fresh;
178
188
  }
179
189
 
190
+ // Split free-form markdown (project/agent memory — NOT the dated-notebook
191
+ // format) into meaningful blocks: blank-line separated, whitespace-collapsed,
192
+ // capped, and filtered so empty template sections ("## Identity\n- ") never
193
+ // pollute the store. Each block becomes one scoped chunk.
194
+ function chunkFreeMarkdown(text) {
195
+ return String(text || "")
196
+ .split(/\n\s*\n/)
197
+ .map((block) => {
198
+ // Drop pure-heading blocks (a "# Title" with no body under it) — they add
199
+ // noise to retrieval. Keep blocks that have at least one non-heading line.
200
+ const lines = block.split("\n").map((l) => l.trim()).filter(Boolean);
201
+ if (!lines.some((l) => !l.startsWith("#"))) return "";
202
+ return block.replace(/\s+/g, " ").trim();
203
+ })
204
+ .filter((b) => meaningfulBody(b))
205
+ .map((b) => b.slice(0, SCOPED_CAP));
206
+ }
207
+
208
+ // Per-agent memory lives at ~/.apx/projects/<projdir>/agents/<slug>/memory.md.
209
+ // Walkable straight off the filesystem — no registry needed. Scoped by channel
210
+ // "agent:<projdir>:<slug>" so retrieval never leaks between agents.
211
+ function collectAgentMemoryChunks(store, apxHome) {
212
+ const fresh = [];
213
+ const projectsRoot = path.join(apxHome, "projects");
214
+ let projDirs;
215
+ try {
216
+ projDirs = fs.readdirSync(projectsRoot);
217
+ } catch {
218
+ return fresh;
219
+ }
220
+ for (const projDir of projDirs) {
221
+ const agentsDir = path.join(projectsRoot, projDir, "agents");
222
+ let slugs;
223
+ try {
224
+ slugs = fs.readdirSync(agentsDir);
225
+ } catch {
226
+ continue;
227
+ }
228
+ for (const slug of slugs) {
229
+ const body = readIfExists(path.join(agentsDir, slug, "memory.md"));
230
+ if (body == null) continue;
231
+ const channel = `agent:${projDir}:${slug}`;
232
+ for (const block of chunkFreeMarkdown(body)) {
233
+ const id = `agentmem:${projDir}:${slug}:${fnv1aHex(block)}`;
234
+ if (store.hasId(id)) continue;
235
+ fresh.push({ id, source: "agent-memory", channel, ts: "", tag: "agent-memory", text: block });
236
+ }
237
+ }
238
+ }
239
+ return fresh;
240
+ }
241
+
242
+ // Project memory (.apc/memory.md) for every registered project. Needs the
243
+ // registry to map id → repo path. Scoped by channel "project:<id>".
244
+ function collectProjectMemoryChunks(store, projects) {
245
+ const fresh = [];
246
+ const list = typeof projects?.list === "function" ? projects.list() : Array.isArray(projects) ? projects : [];
247
+ for (const entry of list) {
248
+ const root = entry?.path;
249
+ if (!root) continue; // the default project (id 0) has no repo root
250
+ const body = readIfExists(apcMemoryFile(root));
251
+ if (body == null) continue;
252
+ const channel = `project:${entry.id}`;
253
+ for (const block of chunkFreeMarkdown(body)) {
254
+ const id = `projmem:${entry.id}:${fnv1aHex(block)}`;
255
+ if (store.hasId(id)) continue;
256
+ fresh.push({ id, source: "project-memory", channel, ts: "", tag: "project-memory", text: block });
257
+ }
258
+ }
259
+ return fresh;
260
+ }
261
+
180
262
  // Run one incremental indexing pass. Returns { indexed, backend }.
181
263
  // `opts.embed` overrides embedding options (baseUrl/model/timeoutMs) for tests.
182
264
  export async function indexNewMessages(store, opts = {}) {
@@ -220,7 +302,12 @@ export async function indexNewMessages(store, opts = {}) {
220
302
 
221
303
  const { fresh: msgChunks, maxTsByChannel } = collectMessageChunks(store, cursor, messagesDir);
222
304
  const memChunks = collectMemoryChunks(store, memoryPath);
223
- let chunks = [...msgChunks, ...memChunks];
305
+ // Scoped memory (Pieza 5): per-agent + per-project notebooks, tagged with a
306
+ // scoped channel so retrieval can be isolated. Idempotent by content hash, so
307
+ // re-derived every pass like memory.md (cheap, few blocks).
308
+ const agentChunks = collectAgentMemoryChunks(store, opts.apxHome || APX_HOME);
309
+ const projChunks = collectProjectMemoryChunks(store, opts.projects);
310
+ let chunks = [...msgChunks, ...memChunks, ...agentChunks, ...projChunks];
224
311
  if (chunks.length === 0) return { indexed: 0, backend: store.backend };
225
312
 
226
313
  // Cap per-run work so a huge first index doesn't block; the rest is picked
@@ -8,18 +8,30 @@
8
8
  //
9
9
  // Both expose the same interface:
10
10
  // upsert(rows) rows: {id, source, channel, ts, tag, text, embedder, dim, vector}
11
- // search(vector, {embedder, k, channel}) -> [{...row, score}]
11
+ // search(vector, {embedder, k, channel, scope}) -> [{...row, score}]
12
12
  // hasId(id) / count()
13
13
  // close()
14
14
  //
15
15
  // Cosine is only meaningful within one embedder space, so search() filters to
16
16
  // rows whose `embedder` matches the query's embedder. Everything here is
17
17
  // best-effort: open() never throws — on any failure it returns a JsonStore.
18
+ //
19
+ // Scope isolation (memory belongs to whoever wrote it):
20
+ // - project/agent memory rows carry a scoped `channel` ("project:<id>" or
21
+ // "agent:<projdir>:<slug>"). Global conversational rows use the channel name
22
+ // (telegram/web/…) or "memory".
23
+ // - search({scope}) keeps retrieval from leaking across scopes:
24
+ // scope === "global" → EXCLUDE project:/agent: rows (super-agent recall)
25
+ // scope === "<value>" → ONLY rows whose channel equals that value
26
+ // scope omitted → no scope filter (back-compat)
18
27
 
19
28
  import fs from "node:fs";
20
29
  import path from "node:path";
21
30
  import { cosineSim } from "./embeddings.js";
22
31
 
32
+ export const SCOPE_PREFIXES = ["project:", "agent:"];
33
+ const isScopedChannel = (c) => SCOPE_PREFIXES.some((p) => String(c || "").startsWith(p));
34
+
23
35
  function vecToBlob(vec) {
24
36
  const f = new Float32Array(vec);
25
37
  return Buffer.from(f.buffer, f.byteOffset, f.byteLength);
@@ -87,11 +99,13 @@ export class JsonStore {
87
99
  this._flush();
88
100
  }
89
101
 
90
- search(vector, { embedder, k = 5, channel } = {}) {
102
+ search(vector, { embedder, k = 5, channel, scope } = {}) {
91
103
  const scored = [];
92
104
  for (const row of this.rows.values()) {
93
105
  if (embedder && row.embedder !== embedder) continue;
94
106
  if (channel && row.channel !== channel) continue;
107
+ if (scope === "global" && isScopedChannel(row.channel)) continue;
108
+ if (scope && scope !== "global" && row.channel !== scope) continue;
95
109
  if (!Array.isArray(row.vector) || row.vector.length !== vector.length) continue;
96
110
  scored.push({ ...row, score: cosineSim(vector, row.vector) });
97
111
  }
@@ -171,7 +185,7 @@ class SqliteVecStore {
171
185
  this.db.prepare("DELETE FROM chunks").run();
172
186
  }
173
187
 
174
- search(vector, { embedder, k = 5, channel } = {}) {
188
+ search(vector, { embedder, k = 5, channel, scope } = {}) {
175
189
  const blob = vecToBlob(vector);
176
190
  const where = ["embedder = ?", "dim = ?"];
177
191
  const params = [embedder, vector.length];
@@ -179,6 +193,12 @@ class SqliteVecStore {
179
193
  where.push("channel = ?");
180
194
  params.push(channel);
181
195
  }
196
+ if (scope === "global") {
197
+ where.push("(channel NOT LIKE 'project:%' AND channel NOT LIKE 'agent:%')");
198
+ } else if (scope) {
199
+ where.push("channel = ?");
200
+ params.push(scope);
201
+ }
182
202
  const rows = this.db
183
203
  .prepare(
184
204
  `SELECT id, source, channel, ts, tag, text, embedder, dim,