@agentprojectcontext/apx 1.68.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.
- package/package.json +1 -1
- package/src/core/agent/tools/handlers/_obsidian.js +28 -0
- 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/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 +108 -0
- package/src/core/integrations/plugins/obsidian.js +299 -0
- package/src/core/mcp/runner.js +54 -20
- package/src/core/memory/broker.js +4 -1
- package/src/core/memory/index.js +29 -2
- package/src/core/memory/indexer.js +88 -1
- package/src/core/memory/store.js +23 -3
- package/src/host/daemon/api/agents.js +37 -1
- 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-YFsZFhM6.js +798 -0
- package/src/interfaces/web/dist/assets/index-YFsZFhM6.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- 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 +25 -0
- package/src/interfaces/web/src/i18n/es.ts +25 -0
- package/src/interfaces/web/src/lib/api/agents.ts +2 -1
- package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
- package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +169 -47
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +108 -34
- package/src/interfaces/web/src/screens/project/AgentsTab.tsx +72 -16
- package/src/interfaces/web/src/screens/project/Overview.tsx +90 -1
- package/src/interfaces/web/src/types/daemon.ts +10 -0
- package/src/interfaces/web/dist/assets/index-D4BmWoDM.css +0 -1
- package/src/interfaces/web/dist/assets/index-vwd6yQVw.js +0 -803
- package/src/interfaces/web/dist/assets/index-vwd6yQVw.js.map +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;
|
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,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, []);
|
package/src/core/memory/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/core/memory/store.js
CHANGED
|
@@ -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,
|