@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.
- package/package.json +1 -1
- package/src/core/agent/tools/handlers/_obsidian.js +28 -0
- package/src/core/agent/tools/handlers/call-agent.js +7 -1
- package/src/core/agent/tools/handlers/obsidian-list-notes.js +28 -0
- package/src/core/agent/tools/handlers/obsidian-read-note.js +31 -0
- package/src/core/agent/tools/handlers/obsidian-search-notes.js +30 -0
- package/src/core/agent/tools/handlers/obsidian-write-note.js +38 -0
- package/src/core/agent/tools/names.js +10 -0
- package/src/core/agent/tools/registry.js +8 -0
- package/src/core/channels/telegram/dispatch.js +3 -2
- package/src/core/integrations/catalog.js +6 -3
- package/src/core/integrations/index.js +2 -0
- package/src/core/integrations/mcp-sync.js +71 -0
- package/src/core/integrations/obsidian-memory.js +179 -0
- package/src/core/integrations/plugins/obsidian.js +339 -0
- package/src/core/mcp/runner.js +54 -20
- package/src/core/memory/broker.js +11 -3
- package/src/core/memory/index.js +62 -2
- package/src/core/memory/indexer.js +88 -1
- package/src/core/memory/store.js +35 -3
- package/src/host/daemon/api/integrations.js +27 -2
- package/src/host/daemon/api/mcps.js +2 -2
- package/src/host/daemon/index.js +7 -1
- package/src/interfaces/cli/commands/obsidian.js +79 -0
- package/src/interfaces/cli/index.js +48 -1
- package/src/interfaces/web/dist/assets/index-CDz9OwCP.css +1 -0
- package/src/interfaces/web/dist/assets/index-CX15mZXM.js +798 -0
- package/src/interfaces/web/dist/assets/index-CX15mZXM.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/common/TabNav.tsx +10 -3
- package/src/interfaces/web/src/components/integrations/BrandLogos.tsx +29 -0
- package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +5 -4
- package/src/interfaces/web/src/components/integrations/FolderInput.tsx +137 -0
- package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +129 -10
- package/src/interfaces/web/src/i18n/en.ts +21 -0
- package/src/interfaces/web/src/i18n/es.ts +21 -0
- package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +12 -2
- package/src/interfaces/web/dist/assets/index-_2zKBH4O.js +0 -803
- package/src/interfaces/web/dist/assets/index-_2zKBH4O.js.map +0 -1
- 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;
|
package/src/core/mcp/runner.js
CHANGED
|
@@ -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 = (
|
|
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
|
-
"
|
|
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.",
|
package/src/core/memory/index.js
CHANGED
|
@@ -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),
|